diff --git a/.changeset/totp-svelte-ui.md b/.changeset/totp-svelte-ui.md
new file mode 100644
index 0000000..f73c9e7
--- /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 (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.
+
+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..6f5048b 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 (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.
+
+`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
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}
+
{loadError}
+
+ {:else if !status}
+
+
+ Loading...
+
+ {:else if mode === 'enroll'}
+
+
+ {:else if mode === 'disable'}
+
+ {:else if status.enabled}
+
+ On{status.verifiedAt ? `, added ${formatDate(status.verifiedAt)}` : ''}{status.lastUsedAt
+ ? `, last used ${formatDate(status.lastUsedAt)}`
+ : ''}.
+
+ 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..acb356e 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,17 @@
{: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.'}