From be2ae86e1a13b247a1021eecce6b53c474bea2be Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 3 Oct 2026 11:26:03 +0800 Subject: [PATCH 1/2] Select configured TOTP from modern Microsoft sign-in tiles --- package.json | 1 + .../tests/fixtures/modernSignInMethods.html | 36 +++++ scripts/tests/loginTotpSelection.test.js | 142 ++++++++++++++++++ src/browser/auth/Login.ts | 14 +- 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100644 scripts/tests/fixtures/modernSignInMethods.html create mode 100644 scripts/tests/loginTotpSelection.test.js diff --git a/package.json b/package.json index 9e736bc9..b3dde585 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "test:log-parser": "node --test scripts/api/logParser.test.js", + "test:login-totp": "node --test scripts/tests/loginTotpSelection.test.js", "format": "prettier --write .", "format:check": "prettier --check .", "clear-diagnostics": "rimraf diagnostics", diff --git a/scripts/tests/fixtures/modernSignInMethods.html b/scripts/tests/fixtures/modernSignInMethods.html new file mode 100644 index 00000000..2f859dcf --- /dev/null +++ b/scripts/tests/fixtures/modernSignInMethods.html @@ -0,0 +1,36 @@ + + + + + + +
+ + diff --git a/scripts/tests/loginTotpSelection.test.js b/scripts/tests/loginTotpSelection.test.js new file mode 100644 index 00000000..aa338133 --- /dev/null +++ b/scripts/tests/loginTotpSelection.test.js @@ -0,0 +1,142 @@ +/* global document */ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { after, before, test } from 'node:test' +import { chromium } from 'patchright' +import loginModule from '../../dist/browser/auth/Login.js' + +const { Login } = loginModule +const fixture = await readFile(new URL('./fixtures/modernSignInMethods.html', import.meta.url), 'utf8') +let browser + +before(async () => { + browser = await chromium.launch({ headless: true }) +}) + +after(async () => { + await browser?.close() +}) + +async function withPicker(run) { + const context = await browser.newContext({ offline: true }) + const page = await context.newPage() + await page.setContent(fixture) + const login = new Login({ + isMobile: false, + logger: { info() {}, warn() {}, debug() {}, error() {} }, + browser: { + utils: { + ghostClick: async (target, selector) => { + await target.locator(selector).click() + return true + } + } + } + }) + // These fixtures do not navigate. Keep selector checks immediate; exercise real option extraction, + // classification, method selection and DOM clicks without Microsoft or account credentials. + login.waitForIdle = async () => {} + login.checkSelector = async (target, selector) => target.locator(selector).first().isVisible() + try { + await run(login, page) + } finally { + await context.close() + } +} + +test('extracts the modern TOTP SVG and classifies it with a translated label', async () => { + await withPicker(async (login, page) => { + await page + .locator('[data-testid="tile"]') + .nth(1) + .evaluate(tile => { + tile.lastChild.textContent = '输入验证器代码' + }) + const options = await login.getSignInMethodOptions(page) + assert.equal(options.length, 2) + assert.match(options[1].label, /输入验证器代码/) + assert.deepEqual( + options.map(option => login.classifySignInMethod(option)), + ['AUTHENTICATOR', 'TOTP'] + ) + }) +}) + +test('configured TOTP selects the code tile and opens the existing TOTP input', async () => { + await withPicker(async (login, page) => { + assert.equal(await login.handleState('SIGN_IN_METHOD_PICKER', page, { totpSecret: 'test-only' }), true) + assert.equal(await page.locator('body').getAttribute('data-selected'), 'totp') + assert.equal(await page.locator('input[name="otc"]').isVisible(), true) + }) +}) + +test('without a configured secret the offered push method remains selected', async () => { + await withPicker(async (login, page) => { + assert.equal(await login.handleState('SIGN_IN_METHOD_PICKER', page, {}), true) + assert.equal(await page.locator('body').getAttribute('data-selected'), 'push') + }) +}) + +test('a configured secret does not invent a TOTP option when Microsoft offers only push', async () => { + await withPicker(async (login, page) => { + await page + .locator('[data-testid="tile"]') + .nth(1) + .evaluate(tile => tile.remove()) + assert.equal(await login.handleState('SIGN_IN_METHOD_PICKER', page, { totpSecret: 'test-only' }), true) + assert.equal(await page.locator('body').getAttribute('data-selected'), 'push') + }) +}) + +test('an offered password retains precedence when password and TOTP are both configured', async () => { + await withPicker(async (login, page) => { + await page.locator('body').evaluate(body => { + const tile = document.createElement('button') + tile.dataset.testid = 'tile' + tile.innerHTML = 'Use your password' + tile.onclick = () => { + body.dataset.selected = 'password' + } + body.appendChild(tile) + }) + assert.equal( + await login.handleState('SIGN_IN_METHOD_PICKER', page, { password: 'test-only', totpSecret: 'test-only' }), + true + ) + assert.equal(await page.locator('body').getAttribute('data-selected'), 'password') + }) +}) + +test('a TOTP-only picker without a configured secret does not click the code tile', async () => { + await withPicker(async (login, page) => { + await page + .locator('[data-testid="tile"]') + .nth(0) + .evaluate(tile => tile.remove()) + assert.equal(await login.handleState('SIGN_IN_METHOD_PICKER', page, {}), false) + assert.equal(await page.locator('body').getAttribute('data-selected'), null) + }) +}) + +test('hidden code tiles are excluded and cannot replace an offered push method', async () => { + await withPicker(async (login, page) => { + await page + .locator('[data-testid="tile"]') + .nth(1) + .evaluate(tile => { + tile.hidden = true + }) + const options = await login.getSignInMethodOptions(page) + assert.equal(options.length, 1) + assert.equal(await login.handleState('SIGN_IN_METHOD_PICKER', page, { totpSecret: 'test-only' }), true) + assert.equal(await page.locator('body').getAttribute('data-selected'), 'push') + }) +}) + +test('a failed code-tile click does not claim successful selection or send a push request', async () => { + await withPicker(async (login, page) => { + login.clickSignInMethodOption = async () => false + assert.equal(await login.handleState('SIGN_IN_METHOD_PICKER', page, { totpSecret: 'test-only' }), false) + assert.equal(await page.locator('body').getAttribute('data-selected'), null) + }) +}) diff --git a/src/browser/auth/Login.ts b/src/browser/auth/Login.ts index 4b5e84d8..1758925a 100644 --- a/src/browser/auth/Login.ts +++ b/src/browser/auth/Login.ts @@ -404,7 +404,8 @@ export class Login { if (signature.includes('m11.78 10.22a.75')) return 'PASSWORD' // Known passkey/security-key SVG signature keeps it out of the Authenticator fallback if (/picker_fido|passkey|fido|m18 16\.66a3\.51/.test(signature)) return 'PASSKEY' - if (/phone[\s_-]*app[\s_-]*otp|\btotp\b/.test(signature)) return 'TOTP' + // Modern authenticator-code tiles expose the phone/monitor SVG rather than a proof-type attribute. + if (/phone[\s_-]*app[\s_-]*otp|\btotp\b|m8\.25 9c\.97 0 1\.75\.78/.test(signature)) return 'TOTP' // Known Remote NGC/mobile-app SVG signature identifies Microsoft Authenticator language-independently if ( /remote[\s_-]*ngc|picker_remote_ngc|phone[\s_-]*app[\s_-]*notification|push[\s_-]*notification|m15\.75 2c16\.99 2 18 3/.test( @@ -536,13 +537,14 @@ export class Login { return true } - // Sign in another way - prefer offered password, then Authenticator, then interactive email code + // Prefer an offered password, then configured TOTP, then interactive sign-in methods. case 'SIGN_IN_METHOD_PICKER': { const options = await this.getSignInMethodOptions(page) this.logAvailableSignInMethods(options) const methods = options.map(option => ({ option, type: this.classifySignInMethod(option) })) const passwordOption = methods.find(method => method.type === 'PASSWORD')?.option + const totpOption = methods.find(method => method.type === 'TOTP')?.option const authenticatorOption = methods.find(method => method.type === 'AUTHENTICATOR')?.option const emailOption = methods.find(method => method.type === 'EMAIL')?.option @@ -559,6 +561,14 @@ export class Login { return true } + if (account.totpSecret && totpOption) { + this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting authenticator verification code') + this.passwordlessMethodSelected = false + if (!(await this.clickSignInMethodOption(page, totpOption))) return false + await this.waitForIdle(page, 'after authenticator code selection') + return true + } + const passwordlessOptionOldFound = await this.checkSelector(page, this.selectors.passwordlessOptionOld) if (authenticatorOption || passwordlessOptionOldFound) { this.bot.logger.info(this.bot.isMobile, 'LOGIN', 'Selecting Microsoft Authenticator sign-in') From 5bc7f6fc99f5c5282da8c57014a80da584312b57 Mon Sep 17 00:00:00 2001 From: Disaster-Terminator <2557058999@qq.com> Date: Sat, 3 Oct 2026 12:31:44 +0800 Subject: [PATCH 2/2] Verify picker transition dispatches the existing TOTP handler --- .../tests/fixtures/modernSignInMethods.html | 1 + scripts/tests/loginTotpSelection.test.js | 43 +++++++------------ 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/scripts/tests/fixtures/modernSignInMethods.html b/scripts/tests/fixtures/modernSignInMethods.html index 2f859dcf..441941cb 100644 --- a/scripts/tests/fixtures/modernSignInMethods.html +++ b/scripts/tests/fixtures/modernSignInMethods.html @@ -21,6 +21,7 @@ data-testid="tile" onclick=" document.body.dataset.selected = 'totp' + document.querySelectorAll('[data-testid=tile]').forEach(tile => tile.remove()) document.getElementById('challenge').innerHTML = '' " > diff --git a/scripts/tests/loginTotpSelection.test.js b/scripts/tests/loginTotpSelection.test.js index aa338133..076cadbf 100644 --- a/scripts/tests/loginTotpSelection.test.js +++ b/scripts/tests/loginTotpSelection.test.js @@ -62,11 +62,24 @@ test('extracts the modern TOTP SVG and classifies it with a translated label', a }) }) -test('configured TOTP selects the code tile and opens the existing TOTP input', async () => { +test('configured TOTP advances from the picker to the existing TOTP handler', async () => { await withPicker(async (login, page) => { - assert.equal(await login.handleState('SIGN_IN_METHOD_PICKER', page, { totpSecret: 'test-only' }), true) + const account = { totpSecret: 'test-only' } + let totpHandled = false + login.totp2FALogin.handle = async (target, secret) => { + assert.equal(target, page) + assert.equal(secret, account.totpSecret) + totpHandled = true + } + + assert.equal(await login.detectCurrentState(page), 'SIGN_IN_METHOD_PICKER') + assert.equal(await login.handleState('SIGN_IN_METHOD_PICKER', page, account), true) assert.equal(await page.locator('body').getAttribute('data-selected'), 'totp') assert.equal(await page.locator('input[name="otc"]').isVisible(), true) + const nextState = await login.detectCurrentState(page) + assert.equal(nextState, '2FA_TOTP') + assert.equal(await login.handleState(nextState, page, account), true) + assert.equal(totpHandled, true) }) }) @@ -107,32 +120,6 @@ test('an offered password retains precedence when password and TOTP are both con }) }) -test('a TOTP-only picker without a configured secret does not click the code tile', async () => { - await withPicker(async (login, page) => { - await page - .locator('[data-testid="tile"]') - .nth(0) - .evaluate(tile => tile.remove()) - assert.equal(await login.handleState('SIGN_IN_METHOD_PICKER', page, {}), false) - assert.equal(await page.locator('body').getAttribute('data-selected'), null) - }) -}) - -test('hidden code tiles are excluded and cannot replace an offered push method', async () => { - await withPicker(async (login, page) => { - await page - .locator('[data-testid="tile"]') - .nth(1) - .evaluate(tile => { - tile.hidden = true - }) - const options = await login.getSignInMethodOptions(page) - assert.equal(options.length, 1) - assert.equal(await login.handleState('SIGN_IN_METHOD_PICKER', page, { totpSecret: 'test-only' }), true) - assert.equal(await page.locator('body').getAttribute('data-selected'), 'push') - }) -}) - test('a failed code-tile click does not claim successful selection or send a push request', async () => { await withPicker(async (login, page) => { login.clickSignInMethodOption = async () => false