diff --git a/.changeset/totp-support.md b/.changeset/totp-support.md new file mode 100644 index 0000000..b44f88d --- /dev/null +++ b/.changeset/totp-support.md @@ -0,0 +1,18 @@ +--- +'@seamless-auth/react': minor +--- + +Add TOTP (authenticator app) support. The headless client gains +`getTotpStatus()`, `startTotpEnrollment()`, `verifyTotpEnrollment(code)`, and +`disableTotp(code)` for enrollment and management, plus +`verifyStepUpWithTotp(code)` for TOTP-based step-up verification. +`AuthProvider`/`useAuth()` expose `verifyStepUpWithTotp(code)`, which refreshes +`stepUpStatus` on success alongside the existing passkey step-up helpers. The +`StepUpMethod` type now includes `'totp'`, and `TotpStatus` and +`TotpEnrollmentStartResult` are exported. + +TOTP applies to step-up verification, not to the login flow: the auth API issues +a full session on the first factor and does not gate login on TOTP. + +Requires an auth backend that exposes the `/totp/*` routes (available in +`@seamless-auth/express` 0.6+). diff --git a/README.md b/README.md index 503eb38..4a7ac70 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ You are still responsible for your app’s route protection and redirects. refreshStepUpStatus(): Promise; verifyStepUpWithPasskey(): Promise; verifyStepUpWithPasskeyPrf(input: PasskeyPrfInput): Promise; + verifyStepUpWithTotp(code: string): Promise; logout(): Promise; logoutAllSessions(): Promise; deleteUser(): Promise; @@ -214,7 +215,48 @@ function DeleteAccountButton() { } ``` -The current step-up backend supports WebAuthn/passkeys. `refreshStepUpStatus()` calls `/step-up/status`, and `verifyStepUpWithPasskey()` performs the `/step-up/webauthn/start` and `/step-up/webauthn/finish` challenge flow. +Step-up supports WebAuthn/passkeys and TOTP (authenticator apps). `refreshStepUpStatus()` calls `/step-up/status`, `verifyStepUpWithPasskey()` performs the `/step-up/webauthn/start` and `/step-up/webauthn/finish` challenge flow, and `verifyStepUpWithTotp(code)` verifies a 6-digit authenticator code via `/totp/verify-mfa`. Both verification helpers return a `StepUpVerificationResult` and refresh the provider's `stepUpStatus` on success. + +```tsx +const { verifyStepUpWithTotp } = useAuth(); + +const result = await verifyStepUpWithTotp('123456'); // 6-digit code from the user's authenticator app +if (result.success) { + // step-up is fresh; proceed with the sensitive action +} +``` + +### TOTP (authenticator apps) + +TOTP lets users register an authenticator app (Google Authenticator, 1Password, etc.) as a second factor for step-up verification. The SDK exposes headless client methods for enrollment and management; use them from a settings screen. All require an authenticated session. + +```ts +import { createSeamlessAuthClient } from '@seamless-auth/react'; +import type { TotpStatus, TotpEnrollmentStartResult } from '@seamless-auth/react'; + +const authClient = createSeamlessAuthClient({ apiHost: 'https://your.api' }); + +// 1. Check whether TOTP is already enabled +const status: TotpStatus = await (await authClient.getTotpStatus()).json(); + +// 2. Start enrollment: render `otpauthUrl` as a QR code (or show `secret` for manual entry) +const enrollment: TotpEnrollmentStartResult = await ( + await authClient.startTotpEnrollment() +).json(); + +// 3. Confirm the first code from the user's authenticator app +const verifyResponse = await authClient.verifyTotpEnrollment('123456'); +if (verifyResponse.ok) { + // TOTP is now enabled +} + +// Disabling requires a current code +await authClient.disableTotp('123456'); +``` + +`getTotpStatus`, `startTotpEnrollment`, `verifyTotpEnrollment`, and `disableTotp` return raw `Response` objects, so check `response.ok` and parse the body yourself. Enrolling TOTP is a sensitive change; gate it behind a fresh step-up when appropriate. + +> TOTP is not currently a login second factor. The Seamless Auth API issues a full session on the first factor and does not gate login on TOTP, so TOTP applies to step-up verification, not to the login flow. ### WebAuthn PRF @@ -396,16 +438,18 @@ The headless client exposes helpers for: - magic-link request, verify, and polling - OAuth provider listing, start, and callback completion - passkey registration -- step-up status and passkey verification +- step-up status, passkey verification, and TOTP verification +- TOTP enrollment, status, and disable - logout and delete-user - credential update and deletion -Client methods return raw `Response` objects except for the passkey convenience helpers: +Client methods return raw `Response` objects except for the passkey and step-up convenience helpers: - `loginWithPasskey(options?: PasskeyLoginOptions): Promise` - `registerPasskey(metadata | { metadata, requestPrf?, requirePrf? }): Promise` - `isPasskeyPrfSupported(): Promise` - `verifyStepUpWithPasskey(): Promise` +- `verifyStepUpWithTotp(code): Promise` - `verifyStepUpWithPasskeyPrf(input): Promise` - `listOAuthProviders(): Promise` - `startOAuthLogin(input): Promise` @@ -493,6 +537,11 @@ The built-in flows assume compatible endpoints for: - `/step-up/status` - `/step-up/webauthn/start` - `/step-up/webauthn/finish` +- `/totp/status` +- `/totp/enroll/start` +- `/totp/enroll/verify` +- `/totp/disable` +- `/totp/verify-mfa` - `/users/me` - `/users/credentials` - `/users/delete` diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index 50cc189..65d5db3 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -58,6 +58,7 @@ export interface AuthContextType { verifyStepUpWithPasskeyPrf: ( input: PasskeyPrfInput ) => Promise; + verifyStepUpWithTotp: (code: string) => Promise; loading: boolean; } @@ -318,6 +319,25 @@ export const AuthProvider: React.FC = ({ [authClient] ); + const verifyStepUpWithTotp = useCallback( + async (code: string) => { + const result = await authClient.verifyStepUpWithTotp(code); + + if (result.success) { + setStepUpStatus({ + fresh: result.fresh, + method: result.method, + verifiedAt: result.verifiedAt, + expiresAt: result.expiresAt, + maxAgeSeconds: result.maxAgeSeconds, + }); + } + + return result; + }, + [authClient] + ); + useEffect(() => { void validateToken(); }, [validateToken]); @@ -358,6 +378,7 @@ export const AuthProvider: React.FC = ({ refreshStepUpStatus, verifyStepUpWithPasskey, verifyStepUpWithPasskeyPrf, + verifyStepUpWithTotp, }} > {children} diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index 5496354..3fbe576 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -147,7 +147,24 @@ export interface RegisterPasskeyOptions { requirePrf?: boolean; } -export type StepUpMethod = 'webauthn'; +export type StepUpMethod = 'webauthn' | 'totp'; + +export interface TotpStatus { + enabled: boolean; + verifiedAt: string | null; + lastUsedAt: string | null; +} + +export interface TotpEnrollmentStartResult { + message: string; + secret: string; + otpauthUrl: string; + issuer: string; + accountName: string; + algorithm: string; + digits: number; + period: number; +} export interface StepUpStatus { fresh: boolean; @@ -212,6 +229,11 @@ export interface SeamlessAuthClient { verifyStepUpWithPasskeyPrf: ( input: PasskeyPrfInput ) => Promise; + getTotpStatus: () => Promise; + startTotpEnrollment: () => Promise; + verifyTotpEnrollment: (code: string) => Promise; + disableTotp: (code: string) => Promise; + verifyStepUpWithTotp: (code: string) => Promise; updateCredential: (input: { id: string; friendlyName: string | null; @@ -700,6 +722,64 @@ export const createSeamlessAuthClient = ( } }, + getTotpStatus: () => + fetchWithAuth(`/totp/status`, { + method: 'GET', + }), + + startTotpEnrollment: () => + fetchWithAuth(`/totp/enroll/start`, { + method: 'POST', + }), + + verifyTotpEnrollment: code => + fetchWithAuth(`/totp/enroll/verify`, { + method: 'POST', + body: JSON.stringify({ code }), + }), + + disableTotp: code => + fetchWithAuth(`/totp/disable`, { + method: 'POST', + body: JSON.stringify({ code }), + }), + + verifyStepUpWithTotp: async code => { + const response = await fetchWithAuth(`/totp/verify-mfa`, { + method: 'POST', + body: JSON.stringify({ code }), + }); + + if (!response.ok) { + return staleStepUpResult('Failed to verify step-up authentication.'); + } + + try { + const verificationResult = await response.json(); + const method = + verificationResult.method === 'totp' ? verificationResult.method : null; + const success = + verificationResult.message === 'Success' && + verificationResult.fresh === true && + method === 'totp'; + + return { + success, + fresh: Boolean(verificationResult.fresh), + method, + verifiedAt: verificationResult.verifiedAt ?? null, + expiresAt: verificationResult.expiresAt ?? null, + maxAgeSeconds: verificationResult.maxAgeSeconds ?? 0, + message: success + ? 'Step-up authentication succeeded.' + : (verificationResult.message ?? 'Step-up authentication failed.'), + }; + } catch { + console.error('Step-up authentication error.'); + return staleStepUpResult('Step-up authentication failed.'); + } + }, + updateCredential: input => fetchWithAuth(`users/credentials`, { method: 'POST', diff --git a/src/index.ts b/src/index.ts index eb11892..7875064 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,6 +35,8 @@ import { StepUpStatus, StepUpWithPasskeyPrfResult, StepUpVerificationResult, + TotpEnrollmentStartResult, + TotpStatus, UpdateOrganizationInput, } from '@/client/createSeamlessAuthClient'; import { @@ -96,6 +98,8 @@ export type { StepUpStatus, StepUpWithPasskeyPrfResult, StepUpVerificationResult, + TotpEnrollmentStartResult, + TotpStatus, UpdateOrganizationInput, User, }; diff --git a/tests/authProvider.test.tsx b/tests/authProvider.test.tsx index 4bb3a70..26d51f4 100644 --- a/tests/authProvider.test.tsx +++ b/tests/authProvider.test.tsx @@ -32,6 +32,9 @@ const Consumer = () => { {auth.credentials.map(credential => credential.friendlyName).join(',')} +