Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 37 additions & 0 deletions scripts/tests/fixtures/modernSignInMethods.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<!doctype html>
<html lang="en">
<body>
<!-- Minimal reconstruction of observed method tiles. SVG paths use recorded, normalized metadata;
labels and click handlers are test data. No account or session data is included. -->
<button
data-testid="tile"
onclick="
document.body.dataset.selected = 'push'
document.getElementById('challenge').innerHTML = '<span data-testid=displaySign>Test challenge</span>'
"
>
<svg data-testid="tileHeaderImage">
<path
d="m15.75 2c16.99 2 18 3 18 4.25v15.5c0 1.24-1 2.25-2.25 2.25h-7.5c7.01 22 6 21 6 19.75v4.25c6 3.01 7 2 8.25 2h7.5zm0 1.5h-7.5a.75.75 0 0 0-.75.75v15.5c0 .41.34.75.75.75h7.5c.41 0 .75-.34.75-.75v4.25a.75.75 0 0 0-.75-.75zm-1 1a.75.75 0 0 1 0 1.5h-5.5a.75.75 0 0 1 0-1.5h5.5z"
/>
</svg>
Approve sign-in request
</button>
<button
data-testid="tile"
onclick="
document.body.dataset.selected = 'totp'
document.querySelectorAll('[data-testid=tile]').forEach(tile => tile.remove())
document.getElementById('challenge').innerHTML = '<input name=otc aria-label=Verification-code>'
"
>
<svg data-testid="tileHeaderImage">
<path
d="m8.25 9c.97 0 1.75.78 1.75 1.75v9.5c0 .97-.78 1.75-1.75 1.75h-4.5c2.78 22 2 21.22 2 20.25v-9.5c2 9.78 2.78 9 3.75 9h4.5zm0 1.5h-4.5a.25.25 0 0 0-.25.25v9.5c0 .14.11.25.25.25h4.5c.14 0 .25-.11.25-.25v-9.5a.25.25 0 0 0-.25-.25zm-2 7.5a.75.75 0 0 1 .1 1.5h-.6a.75.75 0 0 1-.1-1.5h.6zm13.5-16c1.19 0 2.16.93 2.24 2.1v9.15c0 1.2-.92 2.17-2.09 2.24l-.15.01h16v2h1.25a.75.75 0 0 1 .1 1.5h11v-1.5h3.5v-2h11v14h8.75c.38 0 .7-.28.74-.65v-9.1a.75.75 0 0 0-.74-.75h6.25c-.38 0-.7.28-.74.65l-.01.1v8h4v4.25c0-1.2.92-2.17 2.1-2.24l6.25 2h13.5z"
/>
</svg>
Enter a code from an authenticator app
</button>
<div id="challenge"></div>
</body>
</html>
129 changes: 129 additions & 0 deletions scripts/tests/loginTotpSelection.test.js
Original file line number Diff line number Diff line change
@@ -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 = '<svg><path d="m11.78 10.22a.75.75 0 0 1 1.06 0"/></svg>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)
})
})
14 changes: 12 additions & 2 deletions src/browser/auth/Login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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

Expand All @@ -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')
Expand Down