+ 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}
+
+
+{/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);
});