diff --git a/README.md b/README.md index f2ca712..bf9bd17 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ - `createSeamlessAuthClient()` - `useAuthClient()` - `usePasskeySupport()` -- types including `AuthMode`, `AuthContextType`, `Credential`, `User`, and the headless client input/result types +- types including `AuthMode`, `AuthContextType`, `Credential`, `User`, `StepUpStatus`, and the headless client input/result types ## Installation @@ -96,6 +96,7 @@ You are still responsible for your app’s route protection and redirects. { user: User | null; credentials: Credential[]; + stepUpStatus: StepUpStatus | null; isAuthenticated: boolean; loading: boolean; apiHost: string; @@ -104,6 +105,8 @@ You are still responsible for your app’s route protection and redirects. markSignedIn(): void; hasRole(role: string): boolean | undefined; refreshSession(): Promise; + refreshStepUpStatus(): Promise; + verifyStepUpWithPasskey(): Promise; logout(): Promise; deleteUser(): Promise; login(identifier: string, passkeyAvailable: boolean): Promise; @@ -155,6 +158,33 @@ async function completeLogin() { To disable this auto-detection entirely, pass `autoDetectPreviousSignin={false}` to `AuthProvider`. +### Step-up authentication + +Use step-up authentication before sensitive actions that should require a fresh user verification, such as deleting an account, changing MFA settings, or viewing recovery material. + +```tsx +import { useAuth } from '@seamless-auth/react'; + +function DeleteAccountButton() { + const { refreshStepUpStatus, verifyStepUpWithPasskey } = useAuth(); + + async function handleDeleteAccount() { + const status = await refreshStepUpStatus(); + const fresh = status?.fresh ? true : (await verifyStepUpWithPasskey()).success; + + if (!fresh) { + return; + } + + await deleteAccount(); + } + + return ; +} +``` + +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. + ## Headless Client For custom auth UIs, use the exported client directly: @@ -185,6 +215,7 @@ The headless client exposes helpers for: - phone OTP and email OTP - magic-link request, verify, and polling - passkey registration +- step-up status and passkey verification - logout and delete-user - credential update and deletion @@ -192,6 +223,7 @@ Client methods return raw `Response` objects except for the passkey convenience - `loginWithPasskey(): Promise` - `registerPasskey(metadata): Promise` +- `verifyStepUpWithPasskey(): Promise` ## React Hooks For Custom UI @@ -263,6 +295,9 @@ The built-in flows assume compatible endpoints for: - `/magic-link` - `/magic-link/check` - `/magic-link/verify/:token` +- `/step-up/status` +- `/step-up/webauthn/start` +- `/step-up/webauthn/finish` - `/users/me` - `/users/credentials` - `/users/delete` diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx index 7577a92..992bb85 100644 --- a/src/AuthProvider.tsx +++ b/src/AuthProvider.tsx @@ -4,7 +4,11 @@ * See LICENSE file in the project root for full license information */ -import { createSeamlessAuthClient } from '@/client/createSeamlessAuthClient'; +import { + createSeamlessAuthClient, + StepUpStatus, + StepUpVerificationResult, +} from '@/client/createSeamlessAuthClient'; import { Credential, User } from '@/types'; import React, { createContext, @@ -31,10 +35,13 @@ export interface AuthContextType { hasSignedInBefore: boolean; mode: AuthMode; credentials: Credential[]; + stepUpStatus: StepUpStatus | null; updateCredential: (credential: Credential) => Promise; deleteCredential: (credentialId: string) => Promise; login: (identifier: string, passkeyAvailable: boolean) => Promise; handlePasskeyLogin: () => Promise; + refreshStepUpStatus: () => Promise; + verifyStepUpWithPasskey: () => Promise; loading: boolean; } @@ -67,6 +74,7 @@ export const AuthProvider: React.FC = ({ }) => { const [user, setUser] = useState(null); const [credentials, setCredentials] = useState([]); + const [stepUpStatus, setStepUpStatus] = useState(null); const [isAuthenticated, setIsAuthenticated] = useState(false); const [loading, setLoading] = useState(true); const { hasSignedInBefore, markSignedIn } = usePreviousSignIn(); @@ -116,6 +124,7 @@ export const AuthProvider: React.FC = ({ setIsAuthenticated(false); setUser(null); setCredentials([]); + setStepUpStatus(null); } }, [authClient]); @@ -127,6 +136,7 @@ export const AuthProvider: React.FC = ({ setUser(null); setIsAuthenticated(false); setCredentials([]); + setStepUpStatus(null); return; } else { throw new Error('Could not delete user.'); @@ -183,6 +193,35 @@ export const AuthProvider: React.FC = ({ throw new Error('Failed to update credential'); }; + const refreshStepUpStatus = useCallback(async () => { + const response = await authClient.getStepUpStatus(); + + if (!response.ok) { + setStepUpStatus(null); + return null; + } + + const status = (await response.json()) as StepUpStatus; + setStepUpStatus(status); + return status; + }, [authClient]); + + const verifyStepUpWithPasskey = useCallback(async () => { + const result = await authClient.verifyStepUpWithPasskey(); + + 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]); @@ -208,10 +247,13 @@ export const AuthProvider: React.FC = ({ hasSignedInBefore: autoDetectPreviousSignin ? hasSignedInBefore : false, mode: authMode, credentials, + stepUpStatus, updateCredential, deleteCredential, login, handlePasskeyLogin, + refreshStepUpStatus, + verifyStepUpWithPasskey, }} > {children} diff --git a/src/client/createSeamlessAuthClient.ts b/src/client/createSeamlessAuthClient.ts index 4239b53..bc555f0 100644 --- a/src/client/createSeamlessAuthClient.ts +++ b/src/client/createSeamlessAuthClient.ts @@ -53,6 +53,21 @@ export interface PasskeyRegistrationResult { message: string; } +export type StepUpMethod = 'webauthn'; + +export interface StepUpStatus { + fresh: boolean; + method: StepUpMethod | null; + verifiedAt: string | null; + expiresAt: string | null; + maxAgeSeconds: number; +} + +export interface StepUpVerificationResult extends StepUpStatus { + success: boolean; + message: string; +} + export interface SeamlessAuthClient { getCurrentUser: () => Promise; login: (input: LoginInput) => Promise; @@ -68,10 +83,25 @@ export interface SeamlessAuthClient { checkMagicLink: () => Promise; verifyMagicLink: (token: string) => Promise; registerPasskey: (metadata: PasskeyMetadata) => Promise; - updateCredential: (input: { id: string; friendlyName: string | null }) => Promise; + getStepUpStatus: () => Promise; + verifyStepUpWithPasskey: () => Promise; + updateCredential: (input: { + id: string; + friendlyName: string | null; + }) => Promise; deleteCredential: (id: string) => Promise; } +const staleStepUpResult = (message: string): StepUpVerificationResult => ({ + success: false, + fresh: false, + method: null, + verifiedAt: null, + expiresAt: null, + maxAgeSeconds: 0, + message, +}); + export const createSeamlessAuthClient = ( opts: SeamlessAuthClientOptions ): SeamlessAuthClient => { @@ -281,6 +311,58 @@ export const createSeamlessAuthClient = ( }; }, + getStepUpStatus: () => + fetchWithAuth(`/step-up/status`, { + method: 'GET', + }), + + verifyStepUpWithPasskey: async () => { + const response = await fetchWithAuth(`/step-up/webauthn/start`, { + method: 'POST', + }); + + if (!response.ok) { + return staleStepUpResult('Failed to start step-up authentication.'); + } + + try { + const options = await response.json(); + const credential = await startAuthentication({ optionsJSON: options }); + + const verificationResponse = await fetchWithAuth(`/step-up/webauthn/finish`, { + method: 'POST', + body: JSON.stringify({ assertionResponse: credential }), + }); + + if (!verificationResponse.ok) { + return staleStepUpResult('Failed to verify step-up authentication.'); + } + + const verificationResult = await verificationResponse.json(); + const method = + verificationResult.method === 'webauthn' ? verificationResult.method : null; + const success = + verificationResult.message === 'Success' && + verificationResult.fresh === true && + method === 'webauthn'; + + 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 (error) { + console.error('Step-up authentication error:', 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 945d5d2..406a55c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,9 @@ import { RegisterInput, SeamlessAuthClient, SeamlessAuthClientOptions, + StepUpMethod, + StepUpStatus, + StepUpVerificationResult, } from '@/client/createSeamlessAuthClient'; import { AuthMode } from '@/fetchWithAuth'; import { useAuthClient } from '@/hooks/useAuthClient'; @@ -42,5 +45,8 @@ export type { RegisterInput, SeamlessAuthClient, SeamlessAuthClientOptions, + StepUpMethod, + StepUpStatus, + StepUpVerificationResult, User, }; diff --git a/tests/authProvider.test.tsx b/tests/authProvider.test.tsx index c2d3345..4ce758f 100644 --- a/tests/authProvider.test.tsx +++ b/tests/authProvider.test.tsx @@ -4,7 +4,7 @@ * See LICENSE file in the project root for full license information */ -import { act, render, screen, waitFor } from '@testing-library/react'; +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; import { AuthProvider, useAuth } from '../src/AuthProvider'; import { createFetchWithAuth } from '../src/fetchWithAuth'; @@ -22,6 +22,8 @@ const Consumer = () => { {auth.user ? auth.user.email : 'none'} {String(auth.isAuthenticated)} {String(auth.hasRole('admin'))} + {String(auth.stepUpStatus?.fresh ?? false)} + ); }; @@ -88,4 +90,48 @@ describe('AuthProvider', () => { expect(screen.getByTestId('isAuthenticated')).toHaveTextContent('false'); }); }); + + it('refreshes step-up status on demand', async () => { + mockFetchWithAuthImpl + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + user: { + id: '1', + email: 'test@example.com', + phone: '555-1234', + roles: ['admin'], + }, + credentials: [], + }), + } as any) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + fresh: true, + method: 'webauthn', + verifiedAt: '2026-05-15T12:00:00.000Z', + expiresAt: '2026-05-15T12:05:00.000Z', + maxAgeSeconds: 300, + }), + } as any); + + await act(async () => { + render( + + + + ); + }); + + await waitFor(() => { + expect(screen.getByTestId('user')).toHaveTextContent('test@example.com'); + }); + + fireEvent.click(screen.getByRole('button', { name: /refresh step-up/i })); + + await waitFor(() => { + expect(screen.getByTestId('stepUpFresh')).toHaveTextContent('true'); + }); + }); }); diff --git a/tests/createSeamlessAuthClient.test.ts b/tests/createSeamlessAuthClient.test.ts index 8f5582e..192061b 100644 --- a/tests/createSeamlessAuthClient.test.ts +++ b/tests/createSeamlessAuthClient.test.ts @@ -6,10 +6,7 @@ import { createSeamlessAuthClient } from '../src/client/createSeamlessAuthClient'; import { createFetchWithAuth } from '../src/fetchWithAuth'; -import { - startAuthentication, - startRegistration, -} from '@simplewebauthn/browser'; +import { startAuthentication, startRegistration } from '@simplewebauthn/browser'; jest.mock('../src/fetchWithAuth'); jest.mock('@simplewebauthn/browser', () => ({ @@ -103,4 +100,82 @@ describe('createSeamlessAuthClient', () => { message: 'Passkey registered successfully.', }); }); + + it('forwards step-up status requests through the shared auth fetch helper', async () => { + const response = { ok: true }; + mockFetchWithAuth.mockResolvedValueOnce(response); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + mode: 'server', + }); + + await expect(client.getStepUpStatus()).resolves.toBe(response); + + expect(mockFetchWithAuth).toHaveBeenCalledWith('/step-up/status', { + method: 'GET', + }); + }); + + it('returns a successful step-up result when WebAuthn verification completes', async () => { + mockFetchWithAuth + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ challenge: 'challenge' }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ + message: 'Success', + fresh: true, + method: 'webauthn', + verifiedAt: '2026-05-15T12:00:00.000Z', + expiresAt: '2026-05-15T12:05:00.000Z', + maxAgeSeconds: 300, + }), + }); + (startAuthentication as jest.Mock).mockResolvedValueOnce({ credential: 'assertion' }); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + mode: 'web', + }); + + await expect(client.verifyStepUpWithPasskey()).resolves.toEqual({ + success: true, + fresh: true, + method: 'webauthn', + verifiedAt: '2026-05-15T12:00:00.000Z', + expiresAt: '2026-05-15T12:05:00.000Z', + maxAgeSeconds: 300, + message: 'Step-up authentication succeeded.', + }); + + expect(mockFetchWithAuth).toHaveBeenNthCalledWith(1, '/step-up/webauthn/start', { + method: 'POST', + }); + expect(mockFetchWithAuth).toHaveBeenNthCalledWith(2, '/step-up/webauthn/finish', { + method: 'POST', + body: JSON.stringify({ assertionResponse: { credential: 'assertion' } }), + }); + }); + + it('returns a stale step-up result when challenge creation fails', async () => { + mockFetchWithAuth.mockResolvedValueOnce({ ok: false }); + + const client = createSeamlessAuthClient({ + apiHost: 'https://api.example.com', + mode: 'web', + }); + + await expect(client.verifyStepUpWithPasskey()).resolves.toEqual({ + success: false, + fresh: false, + method: null, + verifiedAt: null, + expiresAt: null, + maxAgeSeconds: 0, + message: 'Failed to start step-up authentication.', + }); + }); });