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..441941cb --- /dev/null +++ b/scripts/tests/fixtures/modernSignInMethods.html @@ -0,0 +1,37 @@ + + + + + + +
+ + diff --git a/scripts/tests/loginTotpSelection.test.js b/scripts/tests/loginTotpSelection.test.js new file mode 100644 index 00000000..076cadbf --- /dev/null +++ b/scripts/tests/loginTotpSelection.test.js @@ -0,0 +1,129 @@ +/* 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 advances from the picker to the existing TOTP handler', async () => { + await withPicker(async (login, page) => { + 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) + }) +}) + +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 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')