From c7f1cf0d3f22b65056c6e85d108cd952fc35b1cc Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 26 Oct 2026 19:16:18 -0400 Subject: [PATCH 1/8] feat(client): sign in with an authenticator app code --- .changeset/totp-login-client.md | 5 ++++ .../src/client/createSeamlessAuthClient.ts | 11 +++++++ packages/client/src/transport.ts | 1 + .../tests/createSeamlessAuthClient.test.ts | 30 +++++++++++++++++++ packages/client/tests/transport.node.test.ts | 1 + 5 files changed, 48 insertions(+) create mode 100644 .changeset/totp-login-client.md diff --git a/.changeset/totp-login-client.md b/.changeset/totp-login-client.md new file mode 100644 index 0000000..383caac --- /dev/null +++ b/.changeset/totp-login-client.md @@ -0,0 +1,5 @@ +--- +'@seamless-auth/client': minor +--- + +Adds `verifyTotpLogin(code)`, which signs in with an authenticator app code on the sign-in `login()` started (`POST /totp/verify-login`). The bearer transport sends it on the pre-auth token and stores the session it issues. diff --git a/packages/client/src/client/createSeamlessAuthClient.ts b/packages/client/src/client/createSeamlessAuthClient.ts index 518ea31..dae422c 100644 --- a/packages/client/src/client/createSeamlessAuthClient.ts +++ b/packages/client/src/client/createSeamlessAuthClient.ts @@ -366,6 +366,8 @@ export interface SeamlessAuthClient { verifyTotpEnrollment: (code: string) => Promise>; disableTotp: (code: string) => Promise>; verifyStepUpWithTotp: (code: string) => Promise>; + /** Signs in with an authenticator app code, on the sign-in `login()` started. */ + verifyTotpLogin: (code: string) => Promise>; /** * Whether this client holds a recovery session: one a recovery code or a * completed account recovery issued, which has not enrolled a factor since. @@ -1053,6 +1055,15 @@ export const createSeamlessAuthClient = ( 'Failed to disable the authenticator.' ), + verifyTotpLogin: code => + requestResult( + fetchWithAuth(`/totp/verify-login`, { + method: 'POST', + body: JSON.stringify({ code }), + }), + 'Failed to verify the authenticator code.' + ), + verifyStepUpWithTotp: async code => { const verified = await requestResult( fetchWithAuth(`/totp/verify-mfa`, { diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts index f730e80..f88abb4 100644 --- a/packages/client/src/transport.ts +++ b/packages/client/src/transport.ts @@ -232,6 +232,7 @@ const ROUTE_RULES: readonly RouteRule[] = [ { match: /^\/logout(\/all)?$/, identity: 'access', effect: 'end' }, { match: /^\/users\/delete$/, identity: 'access', effect: 'end' }, { match: /^\/organizations\/[^/]+\/switch$/, identity: 'access', effect: 'issue' }, + { match: /^\/totp\/verify-login$/, identity: 'preAuth', effect: 'issue' }, { match: /^\/recovery-codes$/, identity: 'access' }, { match: /^\/recovery-codes\/redeem$/, identity: 'preAuth', effect: 'issue' }, { match: /^\/recovery\/(start|verify|status)$/, identity: 'preAuth' }, diff --git a/packages/client/tests/createSeamlessAuthClient.test.ts b/packages/client/tests/createSeamlessAuthClient.test.ts index 2e0d643..2362e2d 100644 --- a/packages/client/tests/createSeamlessAuthClient.test.ts +++ b/packages/client/tests/createSeamlessAuthClient.test.ts @@ -1076,6 +1076,36 @@ describe('createSeamlessAuthClient', () => { }); }); + it('signs in with an authenticator code', async () => { + mockFetchWithAuth.mockResolvedValueOnce({ + ok: true, + json: async () => ({ message: 'Success' }), + }); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + + expect((await client.verifyTotpLogin('123456')).error).toBeNull(); + expect(mockFetchWithAuth).toHaveBeenCalledWith('/totp/verify-login', { + method: 'POST', + body: JSON.stringify({ code: '123456' }), + }); + }); + + it('reports a rejected authenticator sign-in code', async () => { + mockFetchWithAuth.mockResolvedValueOnce({ + ok: false, + status: 401, + json: async () => ({ error: 'totp_verification_failed' }), + }); + + const client = createSeamlessAuthClient({ apiHost: 'https://api.example.com' }); + + await expect(client.verifyTotpLogin('000000')).resolves.toMatchObject({ + data: null, + error: expect.objectContaining({ status: 401 }), + }); + }); + describe('WebAuthn ceremony failures', () => { const CEREMONY_MESSAGE = 'The operation either timed out or was not allowed.'; let consoleError: jest.SpyInstance; diff --git a/packages/client/tests/transport.node.test.ts b/packages/client/tests/transport.node.test.ts index 63a6341..5945b35 100644 --- a/packages/client/tests/transport.node.test.ts +++ b/packages/client/tests/transport.node.test.ts @@ -62,6 +62,7 @@ describe('resolveRouteRule', () => { ['/magic-link/check', 'preAuth', 'issue'], ['/otp/generate-login-email-otp', 'preAuth', undefined], ['/otp/verify-email-otp', 'preAuth', 'issue'], + ['/totp/verify-login', 'preAuth', 'issue'], ['/webAuthn/login/start', 'preAuth', undefined], ['/webAuthn/login/finish', 'preAuth', 'issue'], ['/refresh', 'none', 'issue'], From 44f1529b378c7cdbf104f69f15f05f7456644f98 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 26 Oct 2026 19:20:05 -0400 Subject: [PATCH 2/8] feat(client): authenticator flow helpers, and no resend of a rejected code --- .changeset/totp-flow-helpers.md | 7 ++ packages/client/src/flows/index.ts | 1 + packages/client/src/flows/totp.ts | 103 ++++++++++++++++++ packages/client/src/transport.ts | 28 ++++- packages/client/tests/totpFlows.test.ts | 105 +++++++++++++++++++ packages/client/tests/transport.node.test.ts | 44 ++++++++ 6 files changed, 286 insertions(+), 2 deletions(-) create mode 100644 .changeset/totp-flow-helpers.md create mode 100644 packages/client/src/flows/totp.ts create mode 100644 packages/client/tests/totpFlows.test.ts diff --git a/.changeset/totp-flow-helpers.md b/.changeset/totp-flow-helpers.md new file mode 100644 index 0000000..f4454f4 --- /dev/null +++ b/.changeset/totp-flow-helpers.md @@ -0,0 +1,7 @@ +--- +'@seamless-auth/client': minor +--- + +Adds shared authenticator app (TOTP) flow helpers for the bundled screens: `signInWithTotp()`, `totpErrorMessage()`, `formatTotpSecret()` and `TOTP_CODE_LENGTH`. + +The bearer transport no longer refreshes and resends a request whose `401` names a rejected factor (`totp_verification_failed`, `totp_disable_failed`, `step_up_failed`, `invalid_recovery_code`). That `401` is the answer to a wrong code, not an expired token, and resending the same code only spent another attempt against the rate limit and lockout. diff --git a/packages/client/src/flows/index.ts b/packages/client/src/flows/index.ts index 732cce3..14a0c30 100644 --- a/packages/client/src/flows/index.ts +++ b/packages/client/src/flows/index.ts @@ -14,4 +14,5 @@ export * from './passkeySupport'; export * from './recovery'; export * from './redirects'; export * from './signIn'; +export * from './totp'; export * from './validation'; diff --git a/packages/client/src/flows/totp.ts b/packages/client/src/flows/totp.ts new file mode 100644 index 0000000..626c75f --- /dev/null +++ b/packages/client/src/flows/totp.ts @@ -0,0 +1,103 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import type { SeamlessAuthClient } from '../client/createSeamlessAuthClient'; +import { SeamlessAuthError } from '../client/errors'; +import type { AuthSessionActions } from '../session/createAuthSession'; + +export const TOTP_CODE_LENGTH = 6; + +export const TOTP_GENERIC_ERROR = 'Something went wrong. Please try again.'; + +function errorCode(error: SeamlessAuthError): string | undefined { + const code = (error.body as { error?: unknown } | null | undefined)?.error; + return typeof code === 'string' ? code : undefined; +} + +function retryAfterSeconds(error: SeamlessAuthError): number | undefined { + const value = (error.body as { retryAfterSeconds?: unknown } | null | undefined) + ?.retryAfterSeconds; + return typeof value === 'number' && value > 0 ? value : undefined; +} + +/** + * What to tell someone whose authenticator code was refused. + * + * A wrong code and one already used answer the same, because the API does not + * say which: a code is accepted once, so the next one the app shows is the fix + * for both. + */ +export function totpErrorMessage( + error: unknown, + fallback: string = TOTP_GENERIC_ERROR +): string { + if (!(error instanceof SeamlessAuthError)) return fallback; + + const code = errorCode(error); + + if (code === 'totp_verification_failed' || code === 'totp_disable_failed') { + return 'That code did not work. Enter the current code from your authenticator app. Each code works once.'; + } + + if (error.status === 423 || code === 'account_locked') { + const seconds = retryAfterSeconds(error); + if (!seconds) return 'Too many failed attempts. Try again later.'; + const minutes = Math.ceil(seconds / 60); + return `Too many failed attempts. Try again in ${minutes} ${minutes === 1 ? 'minute' : 'minutes'}.`; + } + + if (error.status === 429) { + return 'Too many attempts. Wait a moment, then try again.'; + } + + if (code === 'login_method_disabled') { + return 'This account signs in with a passkey. Use your passkey instead.'; + } + + if (code === 'secret_required') { + return 'Authenticator apps are not available for this application yet.'; + } + + return fallback; +} + +/** The secret in groups of four, which is easier to type into an app by hand. */ +export function formatTotpSecret(secret: string): string { + return ( + secret + .replace(/\s+/g, '') + .match(/.{1,4}/g) + ?.join(' ') ?? '' + ); +} + +/** Signs in with an authenticator code on the sign-in `login()` started. */ +export async function signInWithTotp( + deps: { + client: Pick; + refreshSession: AuthSessionActions['refreshSession']; + }, + code: string +): Promise<{ error: null } | { error: string }> { + const trimmed = code.replace(/\s+/g, ''); + + if (!new RegExp(`^\\d{${TOTP_CODE_LENGTH}}$`).test(trimmed)) { + return { + error: `Enter the ${TOTP_CODE_LENGTH}-digit code from your authenticator app.`, + }; + } + + const { error } = await deps.client.verifyTotpLogin(trimmed); + + if (error) { + return { + error: totpErrorMessage(error, 'We could not sign you in with that code.'), + }; + } + + await deps.refreshSession(); + return { error: null }; +} diff --git a/packages/client/src/transport.ts b/packages/client/src/transport.ts index f88abb4..637e460 100644 --- a/packages/client/src/transport.ts +++ b/packages/client/src/transport.ts @@ -244,6 +244,23 @@ const ROUTE_RULES: readonly RouteRule[] = [ const DEFAULT_RULE: RouteRule = { match: /.*/, identity: 'access' }; +const FACTOR_REJECTIONS = new Set([ + 'totp_verification_failed', + 'totp_disable_failed', + 'step_up_failed', + 'invalid_recovery_code', +]); + +async function isFactorRejection(response: Response): Promise { + try { + const body: unknown = await response.clone().json(); + const code = (body as { error?: unknown } | null)?.error; + return typeof code === 'string' && FACTOR_REJECTIONS.has(code); + } catch { + return false; + } +} + export function resolveRouteRule(path: string): RouteRule { return ROUTE_RULES.find(rule => rule.match.test(path)) ?? DEFAULT_RULE; } @@ -591,7 +608,9 @@ export function createTransport(options: TransportOptions): Transport { } // An expired access token is the one 401 this layer can do something about. - // Retried once; a second 401 is the caller's to handle. + // Retried once; a second 401 is the caller's to handle. A 401 that names a + // rejected factor (a wrong TOTP or recovery code) is the answer, not an expired + // token, and resending the same code would only spend another attempt. async function sendWithRefresh( url: string, init: RequestInit | undefined, @@ -600,7 +619,12 @@ export function createTransport(options: TransportOptions): Transport { ) { let response = await send(url, init, identity, markTransport); - if (response.status === 401 && identity === 'access' && (await readTokens())) { + if ( + response.status === 401 && + identity === 'access' && + !(await isFactorRejection(response)) && + (await readTokens()) + ) { if (await refreshOnce()) { response = await send(url, init, identity, markTransport); } diff --git a/packages/client/tests/totpFlows.test.ts b/packages/client/tests/totpFlows.test.ts new file mode 100644 index 0000000..2727cab --- /dev/null +++ b/packages/client/tests/totpFlows.test.ts @@ -0,0 +1,105 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { SeamlessAuthError } from '../src/client/errors'; +import { formatTotpSecret, signInWithTotp, totpErrorMessage } from '../src/flows'; + +const failure = (status: number, body?: unknown) => + new SeamlessAuthError('failed', status, body); + +describe('totpErrorMessage', () => { + it.each(['totp_verification_failed', 'totp_disable_failed'])( + 'asks for the current code when %s', + error => { + expect(totpErrorMessage(failure(401, { error }))).toBe( + 'That code did not work. Enter the current code from your authenticator app. Each code works once.' + ); + } + ); + + it('says how long a lockout lasts', () => { + expect( + totpErrorMessage(failure(423, { error: 'account_locked', retryAfterSeconds: 61 })) + ).toBe('Too many failed attempts. Try again in 2 minutes.'); + expect( + totpErrorMessage(failure(423, { error: 'account_locked', retryAfterSeconds: 30 })) + ).toBe('Too many failed attempts. Try again in 1 minute.'); + expect(totpErrorMessage(failure(423, { error: 'account_locked' }))).toBe( + 'Too many failed attempts. Try again later.' + ); + }); + + it('covers rate limits, passkey-only accounts and a missing server key', () => { + expect(totpErrorMessage(failure(429))).toMatch(/Wait a moment/); + expect(totpErrorMessage(failure(403, { error: 'login_method_disabled' }))).toMatch( + /passkey/ + ); + expect(totpErrorMessage(failure(409, { error: 'secret_required' }))).toMatch( + /not available/ + ); + }); + + it('falls back for anything else', () => { + expect(totpErrorMessage(failure(500), 'Nope.')).toBe('Nope.'); + expect(totpErrorMessage(new Error('boom'))).toBe( + 'Something went wrong. Please try again.' + ); + }); +}); + +describe('formatTotpSecret', () => { + it('groups the secret in fours', () => { + expect(formatTotpSecret('JBSWY3DPEHPK3PXPJB')).toBe('JBSW Y3DP EHPK 3PXP JB'); + expect(formatTotpSecret(' JBSW Y3DP ')).toBe('JBSW Y3DP'); + expect(formatTotpSecret('')).toBe(''); + }); +}); + +describe('signInWithTotp', () => { + const setup = (result: { data: unknown; error: SeamlessAuthError | null }) => { + const client = { verifyTotpLogin: jest.fn().mockResolvedValue(result) }; + const refreshSession = jest.fn().mockResolvedValue(undefined); + return { client, refreshSession }; + }; + + it('verifies the code and refreshes the session', async () => { + const deps = setup({ data: { message: 'Success' }, error: null }); + + await expect(signInWithTotp(deps, '123 456')).resolves.toEqual({ error: null }); + expect(deps.client.verifyTotpLogin).toHaveBeenCalledWith('123456'); + expect(deps.refreshSession).toHaveBeenCalled(); + }); + + it('refuses a malformed code without calling the API', async () => { + const deps = setup({ data: null, error: null }); + + await expect(signInWithTotp(deps, '12a456')).resolves.toEqual({ + error: 'Enter the 6-digit code from your authenticator app.', + }); + expect(deps.client.verifyTotpLogin).not.toHaveBeenCalled(); + }); + + it('explains a refused code and stays signed out', async () => { + const deps = setup({ + data: null, + error: failure(401, { error: 'totp_verification_failed' }), + }); + + await expect(signInWithTotp(deps, '123456')).resolves.toEqual({ + error: + 'That code did not work. Enter the current code from your authenticator app. Each code works once.', + }); + expect(deps.refreshSession).not.toHaveBeenCalled(); + }); + + it('uses the sign-in fallback for an unknown failure', async () => { + const deps = setup({ data: null, error: failure(500) }); + + await expect(signInWithTotp(deps, '123456')).resolves.toEqual({ + error: 'We could not sign you in with that code.', + }); + }); +}); diff --git a/packages/client/tests/transport.node.test.ts b/packages/client/tests/transport.node.test.ts index 5945b35..3ba2ad4 100644 --- a/packages/client/tests/transport.node.test.ts +++ b/packages/client/tests/transport.node.test.ts @@ -545,6 +545,50 @@ describe('bearer transport', () => { expect(calls.filter(c => c.url.endsWith('/refresh'))).toHaveLength(0); }); + it.each([ + ['/totp/enroll/verify', 'totp_verification_failed'], + ['/totp/verify-mfa', 'totp_verification_failed'], + ['/totp/disable', 'totp_disable_failed'], + ['/step-up/webauthn/finish', 'step_up_failed'], + ])('does not resend a rejected code to %s', async (path, error) => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access', refreshToken: 'refresh' }); + const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(401, { error })); + + const response = await bearer(fetchImpl, storage).fetch(path, { + method: 'POST', + body: JSON.stringify({ code: '123456' }), + }); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error }); + expect(calls).toHaveLength(1); + expect(await storage.get()).toEqual({ + accessToken: 'access', + refreshToken: 'refresh', + }); + }); + + it('still refreshes a 401 whose body is not JSON', async () => { + const storage = createMemoryTokenStorage(); + await storage.set({ accessToken: 'access-old', refreshToken: 'refresh-old' }); + const { calls, fetchImpl } = scriptedFetch(call => { + if (call.url.endsWith('/refresh')) { + return jsonResponse(200, { token: 'access-new', refreshToken: 'refresh-new' }); + } + return headersOf(call).Authorization === 'Bearer access-new' + ? jsonResponse(200, {}) + : new Response('Unauthorized', { status: 401 }); + }); + + const response = await bearer(fetchImpl, storage).fetch('/users/me', { + method: 'GET', + }); + + expect(response.status).toBe(200); + expect(calls.filter(c => c.url.endsWith('/refresh'))).toHaveLength(1); + }); + it('does not refresh a 401 when there is no session to refresh', async () => { const { calls, fetchImpl } = scriptedFetch(() => jsonResponse(401, {})); From 1632096461b0e4794cdfe849451524b18ca7b80c Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 26 Oct 2026 19:22:17 -0400 Subject: [PATCH 3/8] feat(client): QR code encoder for authenticator enrollment --- .changeset/totp-qr-code.md | 5 + package-lock.json | 8 + package.json | 1 + packages/client/src/flows/index.ts | 1 + packages/client/src/flows/qrCode.ts | 378 +++++++++++++++++++++++++++ packages/client/tests/qrCode.test.ts | 121 +++++++++ 6 files changed, 514 insertions(+) create mode 100644 .changeset/totp-qr-code.md create mode 100644 packages/client/src/flows/qrCode.ts create mode 100644 packages/client/tests/qrCode.test.ts diff --git a/.changeset/totp-qr-code.md b/.changeset/totp-qr-code.md new file mode 100644 index 0000000..b38dd67 --- /dev/null +++ b/.changeset/totp-qr-code.md @@ -0,0 +1,5 @@ +--- +'@seamless-auth/client': minor +--- + +Adds `qrCode(text)`, which turns the `otpauth://` URL from authenticator app enrollment into a QR code a phone camera can scan, with no extra dependency. It returns the symbol's `size` in modules (quiet zone included) and SVG `path` data, so any binding can render it as ``. It throws a `RangeError` for text too long for a QR code. `qrMatrix(text)` returns the raw module grid for custom rendering. diff --git a/package-lock.json b/package-lock.json index aaf3fab..6417add 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "jest-environment-jsdom": "^30.2.0", "jest-preset-angular": "^14.6.2", "jsdom": "^30.1.2", + "jsqr": "^1.4.0", "lint-staged": "^16.2.5", "ng-packagr": "~20.3.0", "prettier": "^3.6.2", @@ -12810,6 +12811,13 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", diff --git a/package.json b/package.json index f9475bb..ae8f528 100644 --- a/package.json +++ b/package.json @@ -93,6 +93,7 @@ "jest-environment-jsdom": "^30.2.0", "jest-preset-angular": "^14.6.2", "jsdom": "^30.1.2", + "jsqr": "^1.4.0", "lint-staged": "^16.2.5", "ng-packagr": "~20.3.0", "prettier": "^3.6.2", diff --git a/packages/client/src/flows/index.ts b/packages/client/src/flows/index.ts index 14a0c30..481eb7d 100644 --- a/packages/client/src/flows/index.ts +++ b/packages/client/src/flows/index.ts @@ -11,6 +11,7 @@ export * from './messages'; export * from './oauth'; export * from './otp'; export * from './passkeySupport'; +export * from './qrCode'; export * from './recovery'; export * from './redirects'; export * from './signIn'; diff --git a/packages/client/src/flows/qrCode.ts b/packages/client/src/flows/qrCode.ts new file mode 100644 index 0000000..80ba849 --- /dev/null +++ b/packages/client/src/flows/qrCode.ts @@ -0,0 +1,378 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +export interface QrCode { + /** Modules per side, including the 4-module quiet zone. */ + size: number; + /** SVG path data drawing every dark module as a 1x1 square, in module units. Use with viewBox `0 0 size size`. */ + path: string; +} + +const QUIET_ZONE = 4; + +// Error correction level M, indexed by version (index 0 unused). +const ECC_PER_BLOCK = [ + 0, 10, 16, 26, 18, 24, 16, 18, 22, 22, 26, 30, 22, 22, 24, 24, 28, 28, 26, 26, 26, 26, + 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, 28, +]; +const BLOCKS = [ + 0, 1, 1, 1, 2, 2, 4, 4, 4, 5, 5, 5, 8, 9, 9, 10, 10, 11, 13, 14, 16, 17, 17, 18, 20, 21, + 23, 25, 26, 28, 29, 31, 33, 35, 37, 38, 40, 43, 45, 47, 49, +]; + +// Modules left for codewords once every function pattern is placed. +function rawDataModules(version: number): number { + let modules = (16 * version + 128) * version + 64; + if (version >= 2) { + const align = Math.floor(version / 7) + 2; + modules -= (25 * align - 10) * align - 55; + if (version >= 7) modules -= 36; + } + return modules; +} + +function dataCodewords(version: number): number { + return ( + Math.floor(rawDataModules(version) / 8) - ECC_PER_BLOCK[version] * BLOCKS[version] + ); +} + +function utf8(text: string): number[] { + const bytes: number[] = []; + for (let i = 0; i < text.length; i++) { + let c = text.charCodeAt(i); + if (c >= 0xd800 && c <= 0xdbff && i + 1 < text.length) { + const low = text.charCodeAt(i + 1); + if (low >= 0xdc00 && low <= 0xdfff) { + c = 0x10000 + ((c - 0xd800) << 10) + (low - 0xdc00); + i++; + } + } + if (c >= 0xd800 && c <= 0xdfff) c = 0xfffd; + if (c < 0x80) bytes.push(c); + else if (c < 0x800) bytes.push(0xc0 | (c >> 6), 0x80 | (c & 63)); + else if (c < 0x10000) + bytes.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 63), 0x80 | (c & 63)); + else + bytes.push( + 0xf0 | (c >> 18), + 0x80 | ((c >> 12) & 63), + 0x80 | ((c >> 6) & 63), + 0x80 | (c & 63) + ); + } + return bytes; +} + +function gfMultiply(x: number, y: number): number { + let z = 0; + for (let i = 7; i >= 0; i--) { + z = (z << 1) ^ ((z >>> 7) * 0x11d); + z ^= ((y >>> i) & 1) * x; + } + return z; +} + +// Coefficients of the product of (x - 2^i) for i < degree, leading 1 omitted. +function rsDivisor(degree: number): number[] { + const result = new Array(degree).fill(0); + result[degree - 1] = 1; + let root = 1; + for (let i = 0; i < degree; i++) { + for (let j = 0; j < degree; j++) { + result[j] = gfMultiply(result[j], root); + if (j + 1 < degree) result[j] ^= result[j + 1]; + } + root = gfMultiply(root, 2); + } + return result; +} + +function rsRemainder(data: number[], divisor: number[]): number[] { + const result = divisor.map(() => 0); + for (const byte of data) { + const factor = byte ^ (result.shift() as number); + result.push(0); + divisor.forEach((coef, i) => (result[i] ^= gfMultiply(coef, factor))); + } + return result; +} + +function encodeCodewords(bytes: number[], version: number): number[] { + const bits: number[] = []; + const append = (value: number, length: number) => { + for (let i = length - 1; i >= 0; i--) bits.push((value >>> i) & 1); + }; + append(4, 4); + append(bytes.length, version < 10 ? 8 : 16); + bytes.forEach(b => append(b, 8)); + const capacity = dataCodewords(version) * 8; + append(0, Math.min(4, capacity - bits.length)); + append(0, (8 - (bits.length % 8)) % 8); + for (let pad = 0xec; bits.length < capacity; pad ^= 0xec ^ 0x11) append(pad, 8); + + const data: number[] = []; + for (let i = 0; i < bits.length; i += 8) { + data.push(bits.slice(i, i + 8).reduce((acc, bit) => (acc << 1) | bit, 0)); + } + + // Blocks differ in length by at most one data codeword; the short ones come first. + const numBlocks = BLOCKS[version]; + const eccLen = ECC_PER_BLOCK[version]; + const raw = Math.floor(rawDataModules(version) / 8); + const shortBlocks = numBlocks - (raw % numBlocks); + const shortLen = Math.floor(raw / numBlocks) - eccLen; + const divisor = rsDivisor(eccLen); + const dataBlocks: number[][] = []; + const eccBlocks: number[][] = []; + for (let i = 0, k = 0; i < numBlocks; i++) { + const block = data.slice(k, k + shortLen + (i < shortBlocks ? 0 : 1)); + k += block.length; + dataBlocks.push(block); + eccBlocks.push(rsRemainder(block, divisor)); + } + + const result: number[] = []; + for (let i = 0; i <= shortLen; i++) { + dataBlocks.forEach(block => { + if (i < block.length) result.push(block[i]); + }); + } + for (let i = 0; i < eccLen; i++) eccBlocks.forEach(block => result.push(block[i])); + return result; +} + +const MASKS: ((x: number, y: number) => boolean)[] = [ + (x, y) => (x + y) % 2 === 0, + (_x, y) => y % 2 === 0, + x => x % 3 === 0, + (x, y) => (x + y) % 3 === 0, + (x, y) => (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0, + (x, y) => ((x * y) % 2) + ((x * y) % 3) === 0, + (x, y) => (((x * y) % 2) + ((x * y) % 3)) % 2 === 0, + (x, y) => (((x + y) % 2) + ((x * y) % 3)) % 2 === 0, +]; + +class QrSymbol { + readonly size: number; + readonly dark: boolean[][]; + readonly reserved: boolean[][]; + + constructor(readonly version: number) { + this.size = version * 4 + 17; + this.dark = Array.from({ length: this.size }, () => + new Array(this.size).fill(false) + ); + this.reserved = this.dark.map(row => row.slice()); + } + + clone(): QrSymbol { + const copy = new QrSymbol(this.version); + this.dark.forEach((row, y) => (copy.dark[y] = row.slice())); + this.reserved.forEach((row, y) => (copy.reserved[y] = row.slice())); + return copy; + } + + set(x: number, y: number, dark: boolean): void { + this.dark[y][x] = dark; + this.reserved[y][x] = true; + } + + drawFunctionPatterns(): void { + const { size, version } = this; + for (let i = 0; i < size; i++) { + this.set(6, i, i % 2 === 0); + this.set(i, 6, i % 2 === 0); + } + for (const [cx, cy] of [ + [3, 3], + [size - 4, 3], + [3, size - 4], + ]) { + for (let dy = -4; dy <= 4; dy++) { + for (let dx = -4; dx <= 4; dx++) { + const x = cx + dx; + const y = cy + dy; + const dist = Math.max(Math.abs(dx), Math.abs(dy)); + if (x >= 0 && x < size && y >= 0 && y < size) + this.set(x, y, dist !== 2 && dist !== 4); + } + } + } + const align = alignmentPositions(version); + const last = align.length - 1; + align.forEach((cy, i) => + align.forEach((cx, j) => { + if ((i === 0 && j === 0) || (i === 0 && j === last) || (i === last && j === 0)) + return; + for (let dy = -2; dy <= 2; dy++) { + for (let dx = -2; dx <= 2; dx++) { + this.set(cx + dx, cy + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1); + } + } + }) + ); + this.drawFormat(0); + if (version >= 7) { + let rem = version; + for (let i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1f25); + const bits = (version << 12) | rem; + for (let i = 0; i < 18; i++) { + const bit = ((bits >>> i) & 1) === 1; + const a = size - 11 + (i % 3); + const b = Math.floor(i / 3); + this.set(a, b, bit); + this.set(b, a, bit); + } + } + } + + // Level M's format indicator is 00, so the five data bits are the mask number. + drawFormat(mask: number): void { + let rem = mask; + for (let i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537); + const bits = ((mask << 10) | rem) ^ 0x5412; + const bit = (i: number) => ((bits >>> i) & 1) === 1; + const { size } = this; + for (let i = 0; i <= 5; i++) this.set(8, i, bit(i)); + this.set(8, 7, bit(6)); + this.set(8, 8, bit(7)); + this.set(7, 8, bit(8)); + for (let i = 9; i < 15; i++) this.set(14 - i, 8, bit(i)); + for (let i = 0; i < 8; i++) this.set(size - 1 - i, 8, bit(i)); + for (let i = 8; i < 15; i++) this.set(8, size - 15 + i, bit(i)); + this.set(8, size - 8, true); + } + + drawCodewords(codewords: number[]): void { + const { size } = this; + let i = 0; + for (let right = size - 1; right >= 1; right -= 2) { + if (right === 6) right = 5; + const upward = ((right + 1) & 2) === 0; + for (let vert = 0; vert < size; vert++) { + const y = upward ? size - 1 - vert : vert; + for (let j = 0; j < 2; j++) { + const x = right - j; + if (!this.reserved[y][x] && i < codewords.length * 8) { + this.dark[y][x] = ((codewords[i >>> 3] >>> (7 - (i & 7))) & 1) === 1; + i++; + } + } + } + } + } + + applyMask(mask: number): void { + const fn = MASKS[mask]; + for (let y = 0; y < this.size; y++) { + for (let x = 0; x < this.size; x++) { + if (!this.reserved[y][x] && fn(x, y)) this.dark[y][x] = !this.dark[y][x]; + } + } + } +} + +function alignmentPositions(version: number): number[] { + if (version === 1) return []; + const count = Math.floor(version / 7) + 2; + const step = version === 32 ? 26 : Math.ceil((version * 4 + 4) / (count * 2 - 2)) * 2; + const result = [6]; + for (let pos = version * 4 + 10; result.length < count; pos -= step) + result.splice(1, 0, pos); + return result; +} + +const FINDER_LIKE = [ + [true, false, true, true, true, false, true, false, false, false, false], + [false, false, false, false, true, false, true, true, true, false, true], +]; + +function linePenalty(line: boolean[]): number { + let penalty = 0; + let run = 1; + for (let i = 1; i <= line.length; i++) { + if (i < line.length && line[i] === line[i - 1]) { + run++; + } else { + if (run >= 5) penalty += run - 2; + run = 1; + } + } + for (let i = 0; i + 11 <= line.length; i++) { + if (FINDER_LIKE.some(pattern => pattern.every((v, k) => line[i + k] === v))) + penalty += 40; + } + return penalty; +} + +function penalty(m: boolean[][]): number { + const size = m.length; + let score = 0; + let darkCount = 0; + for (let y = 0; y < size; y++) { + score += linePenalty(m[y]); + score += linePenalty(m.map(row => row[y])); + for (let x = 0; x < size; x++) { + if (m[y][x]) darkCount++; + if ( + x + 1 < size && + y + 1 < size && + m[y][x] === m[y][x + 1] && + m[y][x] === m[y + 1][x] && + m[y][x] === m[y + 1][x + 1] + ) { + score += 3; + } + } + } + return score + Math.floor(Math.abs((darkCount * 100) / (size * size) - 50) / 5) * 10; +} + +/** The module matrix (true is dark), without the quiet zone. Throws RangeError when the text does not fit version 40. */ +export function qrMatrix(text: string): boolean[][] { + const bytes = utf8(text); + let version = 1; + while (4 + (version < 10 ? 8 : 16) + bytes.length * 8 > dataCodewords(version) * 8) { + if (++version > 40) { + throw new RangeError(`Text of ${bytes.length} bytes does not fit in a QR code`); + } + } + + const base = new QrSymbol(version); + base.drawFunctionPatterns(); + base.drawCodewords(encodeCodewords(bytes, version)); + + let best: boolean[][] = []; + let bestPenalty = Infinity; + for (let mask = 0; mask < MASKS.length; mask++) { + const candidate = base.clone(); + candidate.applyMask(mask); + candidate.drawFormat(mask); + const score = penalty(candidate.dark); + if (score < bestPenalty) { + best = candidate.dark; + bestPenalty = score; + } + } + return best; +} + +/** Encodes text (UTF-8, byte mode, error correction level M) as a QR code. Throws RangeError when it does not fit version 40. */ +export function qrCode(text: string): QrCode { + const matrix = qrMatrix(text); + let path = ''; + matrix.forEach((row, y) => { + for (let x = 0; x < row.length; x++) { + if (!row[x]) continue; + const start = x; + while (x + 1 < row.length && row[x + 1]) x++; + const width = x - start + 1; + path += `M${start + QUIET_ZONE} ${y + QUIET_ZONE}h${width}v1h-${width}z`; + } + }); + return { size: matrix.length + QUIET_ZONE * 2, path }; +} diff --git a/packages/client/tests/qrCode.test.ts b/packages/client/tests/qrCode.test.ts new file mode 100644 index 0000000..7ea4d2f --- /dev/null +++ b/packages/client/tests/qrCode.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import jsQR from 'jsqr'; + +import { qrCode, qrMatrix } from '../src'; + +const OTPAUTH = + 'otpauth://totp/Seamless%20Auth:alice%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP&issuer=Seamless%20Auth&algorithm=SHA1&digits=6&period=30'; + +const SCALE = 4; +const QUIET = 4; + +function decode(matrix: boolean[][]) { + const side = (matrix.length + QUIET * 2) * SCALE; + const pixels = new Uint8ClampedArray(side * side * 4).fill(255); + matrix.forEach((row, y) => + row.forEach((dark, x) => { + if (!dark) return; + for (let dy = 0; dy < SCALE; dy++) { + for (let dx = 0; dx < SCALE; dx++) { + const offset = + (((y + QUIET) * SCALE + dy) * side + (x + QUIET) * SCALE + dx) * 4; + pixels.fill(0, offset, offset + 3); + } + } + }) + ); + return jsQR(pixels, side, side, { inversionAttempts: 'dontInvert' }); +} + +// ISO/IEC 18004 Table 7, byte mode at error correction level M, versions 1 to 40. +const BYTE_CAPACITY_M = [ + 14, 26, 42, 62, 84, 106, 122, 152, 180, 213, 251, 287, 331, 362, 412, 450, 504, 560, + 624, 666, 711, 779, 857, 911, 997, 1059, 1125, 1190, 1264, 1370, 1452, 1538, 1628, 1722, + 1809, 1911, 1989, 2099, 2213, 2331, +]; + +const versionOf = (text: string) => (qrMatrix(text).length - 17) / 4; + +describe('qrCode', () => { + it('encodes an otpauth enrollment URL that decodes back to the same text', () => { + const result = decode(qrMatrix(OTPAUTH)); + expect(result?.data).toBe(OTPAUTH); + expect(result?.version).toBe(versionOf(OTPAUTH)); + }); + + it('uses version 1 for short strings', () => { + const matrix = qrMatrix('hello'); + expect(matrix).toHaveLength(21); + expect(decode(matrix)?.data).toBe('hello'); + }); + + it('picks the smallest version that fits at level M', () => { + expect(versionOf('a'.repeat(14))).toBe(1); + expect(versionOf('a'.repeat(15))).toBe(2); + expect(versionOf('a'.repeat(26))).toBe(2); + expect(versionOf('a'.repeat(27))).toBe(3); + expect(versionOf('a'.repeat(213))).toBe(10); + expect(versionOf('a'.repeat(214))).toBe(11); + }); + + it('writes version information that decodes from version 7 up', () => { + const text = 'x'.repeat(150); + const matrix = qrMatrix(text); + expect(versionOf(text)).toBeGreaterThanOrEqual(7); + const result = decode(matrix); + expect(result?.data).toBe(text); + expect(result?.version).toBe(versionOf(text)); + }); + + it('fills every version from 1 to 40 to its byte capacity and decodes', () => { + BYTE_CAPACITY_M.forEach((capacity, i) => { + const text = Array.from({ length: capacity }, (_, k) => + String.fromCharCode(33 + (k % 90)) + ).join(''); + const matrix = qrMatrix(text); + expect((matrix.length - 17) / 4).toBe(i + 1); + if (i < 39) expect(versionOf(text + '!')).toBe(i + 2); + expect(decode(matrix)?.data).toBe(text); + }); + }); + + it('encodes non-ASCII text as UTF-8', () => { + const text = 'Zoë 日本語 🔐'; + const result = decode(qrMatrix(text)); + expect(result?.binaryData).toEqual(Array.from(new TextEncoder().encode(text))); + expect(result?.data).toBe(text); + }); + + it('replaces a lone surrogate with U+FFFD, as TextEncoder does', () => { + expect(decode(qrMatrix('a\ud800b'))?.binaryData).toEqual([ + 0x61, 0xef, 0xbf, 0xbd, 0x62, + ]); + }); + + it('draws dark modules as one rect per horizontal run, offset by the quiet zone', () => { + const matrix = qrMatrix(OTPAUTH); + const { size, path } = qrCode(OTPAUTH); + expect(size).toBe(matrix.length + 8); + expect(path).toMatch(/^(M\d+ \d+h\d+v1h-\d+z)+$/); + expect(path.startsWith('M4 4h7v1h-7z')).toBe(true); + + const drawn = matrix.map(row => row.map(() => false)); + for (const [, x, y, w, back] of path.matchAll(/M(\d+) (\d+)h(\d+)v1h-(\d+)z/g)) { + expect(back).toBe(w); + for (let i = 0; i < Number(w); i++) drawn[Number(y) - 4][Number(x) - 4 + i] = true; + expect(matrix[Number(y) - 4][Number(x) - 5] ?? false).toBe(false); + expect(matrix[Number(y) - 4][Number(x) - 4 + Number(w)] ?? false).toBe(false); + } + expect(drawn).toEqual(matrix); + }); + + it('throws a RangeError when the text does not fit version 40', () => { + expect(() => qrCode('a'.repeat(2332))).toThrow(RangeError); + expect(() => qrMatrix('é'.repeat(1166))).toThrow(RangeError); + }); +}); From f424e99fe815620ed9d45d6fd5038dbbd190cf1f Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 26 Oct 2026 19:31:15 -0400 Subject: [PATCH 4/8] feat(svelte): authenticator app enrollment, sign-in and settings screens Adds SaRegisterAuthenticator, SaAuthenticatorCode and SaAuthenticatorAppSettings over the shared client flows. The sign-in fallbacks offer an authenticator app, and passkey enrollment offers one when the device cannot register a passkey and always to a recovery session, which can no longer skip. Refs #216 --- packages/svelte/src/index.ts | 4 + packages/svelte/src/navigation.ts | 2 + .../screens/AuthenticatorAppSettings.svelte | 140 ++++++++ .../src/screens/AuthenticatorCode.svelte | 71 ++++ .../svelte/src/screens/FallbackOptions.svelte | 14 +- packages/svelte/src/screens/Login.svelte | 1 + .../src/screens/RegisterAuthenticator.svelte | 49 +++ .../svelte/src/screens/RegisterPasskey.svelte | 29 +- .../svelte/src/screens/TotpEnrollment.svelte | 148 ++++++++ packages/svelte/tests/Harness.svelte | 9 + packages/svelte/tests/fakeNavigator.svelte.ts | 4 +- packages/svelte/tests/screens.test.ts | 320 +++++++++++++++++- 12 files changed, 782 insertions(+), 9 deletions(-) create mode 100644 packages/svelte/src/screens/AuthenticatorAppSettings.svelte create mode 100644 packages/svelte/src/screens/AuthenticatorCode.svelte create mode 100644 packages/svelte/src/screens/RegisterAuthenticator.svelte create mode 100644 packages/svelte/src/screens/TotpEnrollment.svelte diff --git a/packages/svelte/src/index.ts b/packages/svelte/src/index.ts index 0864333..3338ad5 100644 --- a/packages/svelte/src/index.ts +++ b/packages/svelte/src/index.ts @@ -15,6 +15,8 @@ export { export { authRoutePaths } from './navigation.js'; export type { AuthNavigator, AuthScreen } from './navigation.js'; +export { default as SaAuthenticatorAppSettings } from './screens/AuthenticatorAppSettings.svelte'; +export { default as SaAuthenticatorCode } from './screens/AuthenticatorCode.svelte'; export { default as SaAuthLayout } from './screens/AuthLayout.svelte'; export { default as SaFallbackOptions } from './screens/FallbackOptions.svelte'; export { default as SaLogin } from './screens/Login.svelte'; @@ -23,6 +25,7 @@ export { default as SaOAuthCallback } from './screens/OAuthCallback.svelte'; export { default as SaOAuthProviderButtons } from './screens/OAuthProviderButtons.svelte'; export { default as SaOtpInput } from './screens/OtpInput.svelte'; export { default as SaPasskeyLogin } from './screens/PasskeyLogin.svelte'; +export { default as SaRegisterAuthenticator } from './screens/RegisterAuthenticator.svelte'; export { default as SaRegisterPasskey } from './screens/RegisterPasskey.svelte'; export { default as SaVerifyMagicLink } from './screens/VerifyMagicLink.svelte'; export { default as SaVerifyOtp } from './screens/VerifyOtp.svelte'; @@ -52,5 +55,6 @@ export type { SeamlessAuthClient, SeamlessAuthResult, StepUpStatus, + TotpStatus, User, } from '@seamless-auth/client'; diff --git a/packages/svelte/src/navigation.ts b/packages/svelte/src/navigation.ts index 5e01f33..c99bb9d 100644 --- a/packages/svelte/src/navigation.ts +++ b/packages/svelte/src/navigation.ts @@ -19,6 +19,8 @@ export const authRoutePaths = { verifyMagicLink: '/verify-magiclink', oauthCallback: '/oauth/callback', registerPasskey: '/register-passkey', + registerAuthenticator: '/register-authenticator', + authenticatorCode: '/authenticator-code', magicLinkSent: '/magic-link-sent', } as const; diff --git a/packages/svelte/src/screens/AuthenticatorAppSettings.svelte b/packages/svelte/src/screens/AuthenticatorAppSettings.svelte new file mode 100644 index 0000000..0c45771 --- /dev/null +++ b/packages/svelte/src/screens/AuthenticatorAppSettings.svelte @@ -0,0 +1,140 @@ + + + + + +
+

Authenticator app

+

Sign in with a code from an app such as 1Password or Google Authenticator.

+ + {#if notice} +

{notice}

+ {/if} + + {#if loadError} + + + {:else if !status} +
+ + Loading... +
+ {:else if mode === 'enroll'} + + + {:else if mode === 'disable'} +
+ Enter a current code from your authenticator app to turn it off + + {#if error} + + {/if} + + + + {:else if status.enabled} +

+ On{status.verifiedAt ? `, added ${formatDate(status.verifiedAt)}` : ''}{status.lastUsedAt + ? `, last used ${formatDate(status.lastUsedAt)}` + : ''}. +

+ + {:else} +

Not set up.

+ + {/if} +
+
diff --git a/packages/svelte/src/screens/AuthenticatorCode.svelte b/packages/svelte/src/screens/AuthenticatorCode.svelte new file mode 100644 index 0000000..9c28f19 --- /dev/null +++ b/packages/svelte/src/screens/AuthenticatorCode.svelte @@ -0,0 +1,71 @@ + + + + + +

Enter your authenticator code

+

Open your authenticator app and enter the code it shows for this account.

+ + {#if error} + + {/if} + +
+
+ Authenticator Code + +
+ + + + +
+
diff --git a/packages/svelte/src/screens/FallbackOptions.svelte b/packages/svelte/src/screens/FallbackOptions.svelte index bad4592..cf87b09 100644 --- a/packages/svelte/src/screens/FallbackOptions.svelte +++ b/packages/svelte/src/screens/FallbackOptions.svelte @@ -18,6 +18,7 @@ onEmailOtp, onPhoneOtp, onPasskeyRetry, + onAuthenticatorApp, }: { identifier: string; /** Null while the methods are unknown; the options then stay permissive. */ @@ -26,6 +27,11 @@ onEmailOtp?: () => void; onPhoneOtp: () => void; onPasskeyRetry?: () => void; + /** + * Offered whenever it is passed: `/login` never lists the authenticator + * app, and the API answers a code for any identifier alike. + */ + onAuthenticatorApp?: () => void; } = $props(); const options = $derived( @@ -36,7 +42,7 @@ ); -{#if hasFallbackSignInOption(options)} +{#if hasFallbackSignInOption(options) || onAuthenticatorApp}
Choose a sign-in method

Choose another secure sign-in method.

@@ -60,6 +66,12 @@ Receive a one-time code via SMS {/if} + {#if onAuthenticatorApp} + + {/if}
{#if options.passkeyRetry} diff --git a/packages/svelte/src/screens/Login.svelte b/packages/svelte/src/screens/Login.svelte index c7e4167..f3e404b 100644 --- a/packages/svelte/src/screens/Login.svelte +++ b/packages/svelte/src/screens/Login.svelte @@ -203,6 +203,7 @@ onEmailOtp={sendEmailOtp} onPhoneOtp={sendPhoneOtp} onPasskeyRetry={auth.passkeySupported ? retryPasskey : undefined} + onAuthenticatorApp={() => navigator.toScreen('authenticatorCode')} /> {/if} diff --git a/packages/svelte/src/screens/RegisterAuthenticator.svelte b/packages/svelte/src/screens/RegisterAuthenticator.svelte new file mode 100644 index 0000000..567337c --- /dev/null +++ b/packages/svelte/src/screens/RegisterAuthenticator.svelte @@ -0,0 +1,49 @@ + + + + + + {#if enrolled} +

Authenticator app added

+

+ You can now sign in with a code from your authenticator app. +

+ + {:else} +

Set up an authenticator app

+ { + enrolled = true; + }} + /> + + {/if} +
diff --git a/packages/svelte/src/screens/RegisterPasskey.svelte b/packages/svelte/src/screens/RegisterPasskey.svelte index 8d0cc44..be19f97 100644 --- a/packages/svelte/src/screens/RegisterPasskey.svelte +++ b/packages/svelte/src/screens/RegisterPasskey.svelte @@ -32,8 +32,11 @@ let message = $state(''); // With passkey as the only enabled method, a user who skipped would have no - // way back into the account they just made. - const canSkip = $derived(hasNonPasskeyLoginMethod(auth.loginMethods)); + // way back into the account they just made. A recovery session stays + // restricted until it adds a factor, so it cannot skip either. + const canSkip = $derived( + !auth.recoverySession && hasNonPasskeyLoginMethod(auth.loginMethods) + ); const busy = $derived(status === 'loading'); onMount(() => { @@ -60,6 +63,12 @@ await finish(); } + const useAuthenticator = () => + navigator.toScreen( + 'registerAuthenticator', + typeof returnTo === 'string' ? { returnTo } : undefined + ); + async function finishWithoutPasskey() { await auth.refreshSession(); await finish(); @@ -75,10 +84,15 @@ {:else if !auth.passkeySupported}

Passkeys are not available here

- {canSkip - ? 'This device does not support passkeys. You can continue without one and add a passkey later from a device that does.' - : 'This device does not support passkeys, and this application requires one to sign in. Try again from a device or browser that supports them.'} + {auth.recoverySession + ? 'This device does not support passkeys. Set up an authenticator app to finish securing your account.' + : canSkip + ? 'This device does not support passkeys. You can continue without one and add a passkey later from a device that does.' + : 'This device does not support passkeys, and this application requires one to sign in. Try again from a device or browser that supports them.'}

+ {#if canSkip} {/if} @@ -98,6 +112,11 @@ > Use a security key instead + {#if auth.recoverySession} + + {/if} {#if message}

{message}

{/if} diff --git a/packages/svelte/src/screens/TotpEnrollment.svelte b/packages/svelte/src/screens/TotpEnrollment.svelte new file mode 100644 index 0000000..82d1a16 --- /dev/null +++ b/packages/svelte/src/screens/TotpEnrollment.svelte @@ -0,0 +1,148 @@ + + + + +{#if starting} +
+ + Preparing your authenticator app... +
+{:else if !enrollment || !qr} + + +{:else} +

+ Scan this QR code with your authenticator app, or enter the setup key by hand. +

+
+ + + + +
+ + Setup key +

{formatTotpSecret(enrollment.secret)}

+ + {#if copyMessage} +

{copyMessage}

+ {/if} + {#if appLink} +

+ Open in an authenticator app on this device +

+ {/if} + +
+ Enter the code your authenticator app shows + + {#if error} + + {/if} + + +{/if} diff --git a/packages/svelte/tests/Harness.svelte b/packages/svelte/tests/Harness.svelte index 28ec945..832c9ff 100644 --- a/packages/svelte/tests/Harness.svelte +++ b/packages/svelte/tests/Harness.svelte @@ -9,10 +9,13 @@ import { setAuthNavigator, setSeamlessAuth, type SeamlessAuth } from '../src'; import type { AuthNavigator } from '../src/navigation'; import { + SaAuthenticatorAppSettings, + SaAuthenticatorCode, SaLogin, SaMagicLinkSent, SaOAuthCallback, SaPasskeyLogin, + SaRegisterAuthenticator, SaRegisterPasskey, SaVerifyMagicLink, SaVerifyOtp, @@ -52,5 +55,11 @@ {:else if nav.current === 'registerPasskey'} + {:else if nav.current === 'registerAuthenticator'} + + {:else if nav.current === 'authenticatorCode'} + + {:else if nav.current === 'settings'} + {/if} {/key} diff --git a/packages/svelte/tests/fakeNavigator.svelte.ts b/packages/svelte/tests/fakeNavigator.svelte.ts index 27f7773..f7f325e 100644 --- a/packages/svelte/tests/fakeNavigator.svelte.ts +++ b/packages/svelte/tests/fakeNavigator.svelte.ts @@ -11,11 +11,11 @@ import type { AuthNavigator, AuthScreen } from '../src/navigation'; * `location` is what the address bar would show. */ export function createFakeNavigator(start: { - screen: AuthScreen | 'app'; + screen: AuthScreen | 'app' | 'settings'; query?: string; }) { const nav = $state({ - current: start.screen as AuthScreen | 'app', + current: start.screen as AuthScreen | 'app' | 'settings', path: '/', state: {} as Record, query: new URLSearchParams(start.query ?? ''), diff --git a/packages/svelte/tests/screens.test.ts b/packages/svelte/tests/screens.test.ts index fa9e5d4..13e9ea2 100644 --- a/packages/svelte/tests/screens.test.ts +++ b/packages/svelte/tests/screens.test.ts @@ -31,7 +31,7 @@ const ok = { body: { message: 'Success' } }; let nav: ReturnType['nav']; async function open( - start: AuthScreen, + start: AuthScreen | 'settings', routes: Record, options: { passkeys?: boolean; @@ -632,6 +632,324 @@ describe('passkey screens', () => { }); }); +const enrollment = { + body: { + message: 'Success', + secret: 'JBSWY3DPEHPK3PXPJBSWY3DP', + otpauthUrl: + 'otpauth://totp/Example:ada%40example.com?secret=JBSWY3DPEHPK3PXPJBSWY3DP', + issuer: 'Example', + accountName: 'ada@example.com', + algorithm: 'SHA1', + digits: 6, + period: 30, + }, +}; +const totpRefused = { status: 401, body: { error: 'totp_verification_failed' } }; + +describe('authenticator app sign-in', () => { + it('is offered with the other ways in, even though /login never lists it', async () => { + localStorage.setItem('seamlessauth_seen', 'true'); + await open('login', { + 'POST /login': { body: { loginMethods: ['passkey'] } }, + 'GET /system-config/public': { body: { loginMethods: ['passkey'] } }, + }); + + await type('#identifier', 'ada@example.com'); + await submit(); + + expect(text()).toContain('Choose a sign-in method'); + expect(text()).not.toContain('Email Magic Link'); + await click(/Use an authenticator app/); + expect(nav.current).toBe('authenticatorCode'); + expect(heading()).toBe('Enter your authenticator code'); + }); + + it('signs in with a code', async () => { + let session = signedOut; + const { adapter } = await open('authenticatorCode', { + 'GET /users/me': () => session, + 'POST /totp/verify-login': () => { + session = signedIn; + return ok; + }, + }); + + await typeCode('123456'); + await submit(); + + expect(bodyOf(adapter, 'POST', '/totp/verify-login')).toEqual({ code: '123456' }); + expect(nav.current).toBe('app'); + }); + + it('asks for a whole code before sending one', async () => { + const { adapter } = await open('authenticatorCode', {}); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Enter the 6-digit code'); + expect(adapter.called('POST', '/totp/verify-login')).toHaveLength(0); + }); + + it('explains a wrong or reused code, a lockout and a rate limit', async () => { + let reply: AdapterReply = totpRefused; + await open('authenticatorCode', { 'POST /totp/verify-login': () => reply }); + + await typeCode('123456'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Each code works once.'); + expect(nav.current).toBe('authenticatorCode'); + + reply = { status: 423, body: { error: 'account_locked', retryAfterSeconds: 120 } }; + await typeCode('234567'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Try again in 2 minutes.'); + + reply = { status: 429, body: { error: 'too_many_requests' } }; + await typeCode('345678'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Too many attempts.'); + + await click('Back to login'); + expect(nav.current).toBe('login'); + }); +}); + +describe('authenticator app enrollment', () => { + let clipboard: { writeText: ReturnType }; + + beforeEach(() => { + clipboard = { writeText: vi.fn().mockResolvedValue(undefined) }; + Object.defineProperty(globalThis.navigator, 'clipboard', { + value: clipboard, + configurable: true, + }); + }); + + it('shows a QR code and setup key, verifies the first code, and continues', async () => { + const { adapter } = await open( + 'registerAuthenticator', + { 'POST /totp/enroll/start': enrollment, 'POST /totp/enroll/verify': ok }, + { state: { returnTo: '/settings' } } + ); + await waitSettled(); + + expect(heading()).toBe('Set up an authenticator app'); + const qr = screen.getByRole('img', { name: 'QR code for your authenticator app' }); + expect(qr.querySelector('rect')?.getAttribute('fill')).toBe('#ffffff'); + expect(qr.querySelector('path')?.getAttribute('d')).toMatch(/^M/); + expect(text()).toContain('JBSW Y3DP EHPK 3PXP JBSW Y3DP'); + expect( + screen.getByRole('link', { name: /Open in an authenticator app/ }) + ).toHaveAttribute('href', enrollment.body.otpauthUrl); + + await click('Copy setup key'); + expect(clipboard.writeText).toHaveBeenCalledWith('JBSWY3DPEHPK3PXPJBSWY3DP'); + expect(text()).toContain('Setup key copied.'); + + await typeCode('123456'); + await submit(); + + expect(bodyOf(adapter, 'POST', '/totp/enroll/verify')).toEqual({ code: '123456' }); + expect(heading()).toBe('Authenticator app added'); + expect(screen.getByRole('status').textContent).toContain('sign in with a code'); + + await click('Continue'); + expect(nav.current).toBe('app'); + expect(nav.path).toBe('/settings'); + }); + + it('reports a refused code and a failed copy, and lets the user go back', async () => { + clipboard.writeText.mockRejectedValue(new Error('denied')); + await open( + 'registerAuthenticator', + { 'POST /totp/enroll/start': enrollment, 'POST /totp/enroll/verify': totpRefused }, + { state: { returnTo: '/settings' } } + ); + await waitSettled(); + + await click('Copy setup key'); + expect(text()).toContain('Could not copy.'); + + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Enter the 6-digit code'); + + await typeCode('123456'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('That code did not work.'); + expect(heading()).toBe('Set up an authenticator app'); + + await click('Back'); + expect(nav.current).toBe('registerPasskey'); + expect(nav.state).toEqual({ returnTo: '/settings' }); + }); + + it('explains an enrollment that cannot start, and retries it', async () => { + let reply: AdapterReply = { status: 409, body: { error: 'secret_required' } }; + await open('registerAuthenticator', { 'POST /totp/enroll/start': () => reply }); + await waitSettled(); + + expect(screen.getByRole('alert').textContent).toContain( + 'Authenticator apps are not available' + ); + + reply = { + body: { ...enrollment.body, otpauthUrl: 'javascript:alert(1)' }, + }; + await click('Try again'); + expect(screen.getByRole('img', { name: /QR code/ })).toBeInTheDocument(); + expect(screen.queryByRole('link')).toBeNull(); + }); + + it('is offered when the device cannot register a passkey', async () => { + await open('registerPasskey', { + 'GET /system-config/public': { body: { loginMethods: ['passkey'] } }, + }); + await waitSettled(); + + expect(text()).toContain('requires one to sign in'); + await click('Use an authenticator app'); + expect(nav.current).toBe('registerAuthenticator'); + }); + + it('lets a recovery session finish with an authenticator app, and never skip', async () => { + let recovery = true; + const { auth } = await open( + 'registerPasskey', + { + 'GET /users/me': () => ({ body: { ...(signedIn.body as object), recovery } }), + 'GET /system-config/public': { body: { loginMethods: ['passkey', 'email_otp'] } }, + 'POST /totp/enroll/start': enrollment, + 'POST /totp/enroll/verify': () => { + recovery = false; + return ok; + }, + 'POST /refresh': ok, + }, + { passkeys: true } + ); + await waitSettled(); + + expect(auth.recoverySession).toBe(true); + expect(heading()).toBe('Secure Your Account with a Passkey'); + expect(screen.queryByRole('button', { name: 'Skip for now' })).toBeNull(); + + await click('Use an authenticator app'); + await waitSettled(); + await typeCode('123456'); + await submit(); + + expect(heading()).toBe('Authenticator app added'); + expect(auth.recoverySession).toBe(false); + }); + + it('gives a recovery session on a device without passkeys a way forward', async () => { + await open('registerPasskey', { + 'GET /users/me': { body: { ...(signedIn.body as object), recovery: true } }, + 'GET /system-config/public': { body: { loginMethods: ['passkey', 'email_otp'] } }, + }); + await waitSettled(); + + expect(text()).toContain( + 'Set up an authenticator app to finish securing your account.' + ); + expect(screen.queryByRole('button', { name: 'Continue' })).toBeNull(); + expect(button('Use an authenticator app')).toBeInTheDocument(); + }); +}); + +describe('authenticator app settings', () => { + const status = (enabled: boolean) => ({ + body: { + enabled, + verifiedAt: enabled ? '2026-10-01T12:00:00.000Z' : null, + lastUsedAt: enabled ? '2026-10-20T12:00:00.000Z' : null, + }, + }); + + it('sets one up', async () => { + let enabled = false; + await open('settings', { + 'GET /users/me': signedIn, + 'GET /totp/status': () => status(enabled), + 'POST /totp/enroll/start': enrollment, + 'POST /totp/enroll/verify': () => { + enabled = true; + return ok; + }, + }); + await waitSettled(); + + expect(heading()).toBe('Authenticator app'); + expect(text()).toContain('Not set up.'); + + await click('Set up authenticator app'); + await waitSettled(); + expect(screen.getByRole('img', { name: /QR code/ })).toBeInTheDocument(); + + await click('Cancel'); + expect(text()).toContain('Not set up.'); + + await click('Set up authenticator app'); + await waitSettled(); + await typeCode('123456'); + await submit(); + + expect(screen.getByRole('status').textContent).toContain('Authenticator app added.'); + expect(text()).toContain('On, added '); + expect(text()).toContain('last used '); + }); + + it('turns one off with a current code', async () => { + let enabled = true; + let reply: AdapterReply = totpRefused; + const { adapter } = await open('settings', { + 'GET /users/me': signedIn, + 'GET /totp/status': () => status(enabled), + 'POST /totp/disable': () => { + if (reply === ok) enabled = false; + return reply; + }, + }); + await waitSettled(); + + await click('Turn off'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('Enter the 6-digit code'); + expect(adapter.called('POST', '/totp/disable')).toHaveLength(0); + + await typeCode('123456'); + await submit(); + expect(screen.getByRole('alert').textContent).toContain('That code did not work.'); + + reply = ok; + await typeCode('234567'); + await submit(); + + expect(bodyOf(adapter, 'POST', '/totp/disable')).toEqual({ code: '123456' }); + expect(adapter.called('POST', '/totp/disable')).toHaveLength(2); + expect(screen.getByRole('status').textContent).toContain( + 'Authenticator app turned off.' + ); + expect(text()).toContain('Not set up.'); + }); + + it('retries a status that will not load', async () => { + let reply: AdapterReply = { status: 500 }; + await open('settings', { + 'GET /users/me': signedIn, + 'GET /totp/status': () => reply, + }); + await waitSettled(); + + expect(screen.getByRole('alert').textContent).toContain( + 'Could not load your authenticator app.' + ); + + reply = status(false); + await click('Try again'); + expect(text()).toContain('Not set up.'); + }); +}); + describe('stylesheet', () => { it('adds the stylesheet with a nonce, or not at all', async () => { await open('passkeyLogin', {}, { config: { cspNonce: 'n0nce' } }); From 6f325f9715f5db20e79f9b6d1487e0cc9ad3c231 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 26 Oct 2026 19:31:15 -0400 Subject: [PATCH 5/8] docs(svelte): document the authenticator app screens Refs #216 --- .changeset/totp-svelte-ui.md | 13 +++++++++++++ packages/svelte/README.md | 34 +++++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 11 deletions(-) create mode 100644 .changeset/totp-svelte-ui.md diff --git a/.changeset/totp-svelte-ui.md b/.changeset/totp-svelte-ui.md new file mode 100644 index 0000000..694be34 --- /dev/null +++ b/.changeset/totp-svelte-ui.md @@ -0,0 +1,13 @@ +--- +'@seamless-auth/svelte': minor +--- + +The bundled screens cover authenticator apps (TOTP). + +- `SaRegisterAuthenticator` (`/register-authenticator`) adds an authenticator app: a QR code drawn in the page with no extra dependency, the setup key with a copy button and an `otpauth://` link, and the first code to confirm it. It ends on a success state, then continues where passkey enrolment does. +- `SaAuthenticatorCode` (`/authenticator-code`) signs in with a code from the app, and explains a wrong or reused code, a lockout and a rate limit. +- `SaLogin` offers "Use an authenticator app" with the other sign-in options. `SaRegisterPasskey` offers it when the device cannot register a passkey, and always to a recovery session, which can no longer skip enrolment. Enrolling an authenticator app lifts the recovery restriction. +- `SaAuthenticatorAppSettings` is a router-free settings section: status, set up, and turn off with a current code. +- `authRoutePaths` gains `registerAuthenticator` and `authenticatorCode`, and the package re-exports the `TotpStatus` type. + +Mount the two new screens at their paths (or pass `paths` to `createKitNavigator`). Needs a server adapter that proxies the auth API's `/totp/*` routes. diff --git a/packages/svelte/README.md b/packages/svelte/README.md index 14aab4e..ae03d33 100644 --- a/packages/svelte/README.md +++ b/packages/svelte/README.md @@ -175,16 +175,27 @@ Put `requireGuest` only on the screens that start a sign-in (`login`, `passkey-l Mount each at its route. The paths below are what the screens navigate between by default; pass `paths` to `createKitNavigator` if you mount them elsewhere. -| Default path | Component | Purpose | -| ------------------- | ----------------------------- | ----------------------------------------------------------- | -| `/login` | `SaLogin` | Sign in with an email or phone number, or create an account | -| `/passkey-login` | `SaPasskeyLogin` | Sign in with a passkey alone | -| `/verify-email-otp` | `SaVerifyOtp channel="email"` | Check an email code | -| `/verify-phone-otp` | `SaVerifyOtp channel="phone"` | Check a text message code | -| `/magic-link-sent` | `SaMagicLinkSent` | Wait for an emailed link to be used | -| `/verify-magiclink` | `SaVerifyMagicLink` | Where the emailed link lands. The auth API builds this URL | -| `/oauth/callback` | `SaOAuthCallback` | The OAuth redirect URI to register with providers | -| `/register-passkey` | `SaRegisterPasskey` | Offer passkey enrolment after sign-up | +| Default path | Component | Purpose | +| ------------------------- | ----------------------------- | ----------------------------------------------------------- | +| `/login` | `SaLogin` | Sign in with an email or phone number, or create an account | +| `/passkey-login` | `SaPasskeyLogin` | Sign in with a passkey alone | +| `/verify-email-otp` | `SaVerifyOtp channel="email"` | Check an email code | +| `/verify-phone-otp` | `SaVerifyOtp channel="phone"` | Check a text message code | +| `/magic-link-sent` | `SaMagicLinkSent` | Wait for an emailed link to be used | +| `/verify-magiclink` | `SaVerifyMagicLink` | Where the emailed link lands. The auth API builds this URL | +| `/oauth/callback` | `SaOAuthCallback` | The OAuth redirect URI to register with providers | +| `/register-passkey` | `SaRegisterPasskey` | Offer passkey enrolment after sign-up | +| `/register-authenticator` | `SaRegisterAuthenticator` | Add an authenticator app instead of a passkey | +| `/authenticator-code` | `SaAuthenticatorCode` | Sign in with a code from an authenticator app | + +The sign-in screen always offers "Use an authenticator app" among the other ways in, since `/login` +never lists it. `SaRegisterPasskey` offers it when the device cannot register a passkey, and always +to a recovery session, which cannot skip enrolment: adding either factor lifts the recovery +restriction. + +`SaAuthenticatorAppSettings` is a router-free settings section for a signed-in page: it shows +whether an authenticator app is set up, adds one (QR code, setup key with a copy button, first code) +and turns it off with a current code. The building blocks are exported too: `SaOtpInput` (bind with `bind:value`), `SaFallbackOptions`, `SaOAuthProviderButtons`, `SaAuthLayout`, and `authRoutePaths`. @@ -204,7 +215,8 @@ The screens never import a router. They navigate through an `AuthNavigator` set Every screen is a thin view over a flow in `@seamless-auth/client` (`beginSignIn`, `registerWithEmail`, `verifyOtp`, `watchMagicLink`, `finishMagicLinkSignIn`, -`confirmMagicLinkSignIn`, `startOAuthSignIn`, `completeOAuthCallback`, `enrollPasskey`, and helpers). A custom screen calls the same flows with the +`confirmMagicLinkSignIn`, `startOAuthSignIn`, `completeOAuthCallback`, `enrollPasskey`, +`signInWithTotp`, `qrCode`, and helpers). A custom screen calls the same flows with the session as its actions: ```ts From 40a58b4355b4f4771b62cd3f82a60532d95e1035 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 26 Oct 2026 19:37:34 -0400 Subject: [PATCH 6/8] fix(svelte): offer an authenticator app only where it can sign in --- .changeset/totp-svelte-ui.md | 2 +- packages/svelte/README.md | 2 +- .../svelte/src/screens/RegisterPasskey.svelte | 8 +++++--- packages/svelte/tests/screens.test.ts | 16 ++++++++++++++-- 4 files changed, 21 insertions(+), 7 deletions(-) diff --git a/.changeset/totp-svelte-ui.md b/.changeset/totp-svelte-ui.md index 694be34..f73c9e7 100644 --- a/.changeset/totp-svelte-ui.md +++ b/.changeset/totp-svelte-ui.md @@ -6,7 +6,7 @@ The bundled screens cover authenticator apps (TOTP). - `SaRegisterAuthenticator` (`/register-authenticator`) adds an authenticator app: a QR code drawn in the page with no extra dependency, the setup key with a copy button and an `otpauth://` link, and the first code to confirm it. It ends on a success state, then continues where passkey enrolment does. - `SaAuthenticatorCode` (`/authenticator-code`) signs in with a code from the app, and explains a wrong or reused code, a lockout and a rate limit. -- `SaLogin` offers "Use an authenticator app" with the other sign-in options. `SaRegisterPasskey` offers it when the device cannot register a passkey, and always to a recovery session, which can no longer skip enrolment. Enrolling an authenticator app lifts the recovery restriction. +- `SaLogin` offers "Use an authenticator app" with the other sign-in options. `SaRegisterPasskey` offers it when the device cannot register a passkey (unless the application signs in with passkeys only, where the API would refuse the app), and always to a recovery session, which can no longer skip enrolment. Enrolling an authenticator app lifts the recovery restriction. - `SaAuthenticatorAppSettings` is a router-free settings section: status, set up, and turn off with a current code. - `authRoutePaths` gains `registerAuthenticator` and `authenticatorCode`, and the package re-exports the `TotpStatus` type. diff --git a/packages/svelte/README.md b/packages/svelte/README.md index ae03d33..6f5048b 100644 --- a/packages/svelte/README.md +++ b/packages/svelte/README.md @@ -189,7 +189,7 @@ Mount each at its route. The paths below are what the screens navigate between b | `/authenticator-code` | `SaAuthenticatorCode` | Sign in with a code from an authenticator app | The sign-in screen always offers "Use an authenticator app" among the other ways in, since `/login` -never lists it. `SaRegisterPasskey` offers it when the device cannot register a passkey, and always +never lists it. `SaRegisterPasskey` offers it when the device cannot register a passkey (unless the application signs in with passkeys only, where the API would refuse the app), and always to a recovery session, which cannot skip enrolment: adding either factor lifts the recovery restriction. diff --git a/packages/svelte/src/screens/RegisterPasskey.svelte b/packages/svelte/src/screens/RegisterPasskey.svelte index be19f97..acb356e 100644 --- a/packages/svelte/src/screens/RegisterPasskey.svelte +++ b/packages/svelte/src/screens/RegisterPasskey.svelte @@ -90,9 +90,11 @@ ? 'This device does not support passkeys. You can continue without one and add a passkey later from a device that does.' : 'This device does not support passkeys, and this application requires one to sign in. Try again from a device or browser that supports them.'}

- + {#if auth.recoverySession || canSkip} + + {/if} {#if canSkip} {/if} diff --git a/packages/svelte/tests/screens.test.ts b/packages/svelte/tests/screens.test.ts index 13e9ea2..ab0454e 100644 --- a/packages/svelte/tests/screens.test.ts +++ b/packages/svelte/tests/screens.test.ts @@ -801,15 +801,27 @@ describe('authenticator app enrollment', () => { it('is offered when the device cannot register a passkey', async () => { await open('registerPasskey', { - 'GET /system-config/public': { body: { loginMethods: ['passkey'] } }, + 'GET /system-config/public': { body: { loginMethods: ['passkey', 'email_otp'] } }, }); await waitSettled(); - expect(text()).toContain('requires one to sign in'); + expect(text()).toContain('You can continue without one'); await click('Use an authenticator app'); expect(nav.current).toBe('registerAuthenticator'); }); + // A passkey-only application refuses authenticator sign-in, so an app set up + // here could never sign the user in. + it('is not offered when the application requires a passkey', async () => { + await open('registerPasskey', { + 'GET /system-config/public': { body: { loginMethods: ['passkey'] } }, + }); + await waitSettled(); + + expect(text()).toContain('requires one to sign in'); + expect(screen.queryByRole('button', { name: 'Use an authenticator app' })).toBeNull(); + }); + it('lets a recovery session finish with an authenticator app, and never skip', async () => { let recovery = true; const { auth } = await open( From f3a05b52f283bcba8a74542ac171a48235030a71 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 26 Oct 2026 19:37:58 -0400 Subject: [PATCH 7/8] fix(client): neutral wording for a failed recovery code step-up --- .changeset/totp-flow-helpers.md | 2 ++ packages/client/src/flows/recovery.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/totp-flow-helpers.md b/.changeset/totp-flow-helpers.md index f4454f4..3296602 100644 --- a/.changeset/totp-flow-helpers.md +++ b/.changeset/totp-flow-helpers.md @@ -5,3 +5,5 @@ Adds shared authenticator app (TOTP) flow helpers for the bundled screens: `signInWithTotp()`, `totpErrorMessage()`, `formatTotpSecret()` and `TOTP_CODE_LENGTH`. The bearer transport no longer refreshes and resends a request whose `401` names a rejected factor (`totp_verification_failed`, `totp_disable_failed`, `step_up_failed`, `invalid_recovery_code`). That `401` is the answer to a wrong code, not an expired token, and resending the same code only spent another attempt against the rate limit and lockout. + +`generateRecoveryCodesWithStepUp()` now reports a failed step-up as "Confirm it is you to replace your recovery codes." instead of naming the passkey, since the step-up can also be an authenticator app code. diff --git a/packages/client/src/flows/recovery.ts b/packages/client/src/flows/recovery.ts index 7180b10..e8ae9f3 100644 --- a/packages/client/src/flows/recovery.ts +++ b/packages/client/src/flows/recovery.ts @@ -148,7 +148,7 @@ export async function generateRecoveryCodesWithStepUp(deps: { if (stepUp.error) { return { kind: 'error', - message: 'Confirm it is you with your passkey to replace your recovery codes.', + message: 'Confirm it is you to replace your recovery codes.', }; } From acae4be828c5f2ec7f2d2539e54cb3b632cc891a Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Mon, 26 Oct 2026 19:38:37 -0400 Subject: [PATCH 8/8] test(client): expect the neutral step-up wording --- packages/client/tests/recovery.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/tests/recovery.test.ts b/packages/client/tests/recovery.test.ts index 2269ce6..49d8659 100644 --- a/packages/client/tests/recovery.test.ts +++ b/packages/client/tests/recovery.test.ts @@ -526,7 +526,7 @@ describe('recovery flows', () => { generateRecoveryCodesWithStepUp({ client, verifyStepUp }) ).resolves.toMatchObject({ kind: 'error', - message: expect.stringMatching(/passkey/), + message: 'Confirm it is you to replace your recovery codes.', }); expect(client.generateRecoveryCodes).toHaveBeenCalledTimes(1); });