Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/totp-support.md
Original file line number Diff line number Diff line change
@@ -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+).
55 changes: 52 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ You are still responsible for your app’s route protection and redirects.
refreshStepUpStatus(): Promise<StepUpStatus | null>;
verifyStepUpWithPasskey(): Promise<StepUpVerificationResult>;
verifyStepUpWithPasskeyPrf(input: PasskeyPrfInput): Promise<StepUpWithPasskeyPrfResult>;
verifyStepUpWithTotp(code: string): Promise<StepUpVerificationResult>;
logout(): Promise<void>;
logoutAllSessions(): Promise<void>;
deleteUser(): Promise<void>;
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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<PasskeyLoginWithPrfResult>`
- `registerPasskey(metadata | { metadata, requestPrf?, requirePrf? }): Promise<PasskeyRegistrationResult>`
- `isPasskeyPrfSupported(): Promise<boolean>`
- `verifyStepUpWithPasskey(): Promise<StepUpVerificationResult>`
- `verifyStepUpWithTotp(code): Promise<StepUpVerificationResult>`
- `verifyStepUpWithPasskeyPrf(input): Promise<StepUpWithPasskeyPrfResult>`
- `listOAuthProviders(): Promise<OAuthProvidersResult>`
- `startOAuthLogin(input): Promise<StartOAuthLoginResult>`
Expand Down Expand Up @@ -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`
Expand Down
21 changes: 21 additions & 0 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export interface AuthContextType {
verifyStepUpWithPasskeyPrf: (
input: PasskeyPrfInput
) => Promise<StepUpWithPasskeyPrfResult>;
verifyStepUpWithTotp: (code: string) => Promise<StepUpVerificationResult>;
loading: boolean;
}

Expand Down Expand Up @@ -318,6 +319,25 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
[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]);
Expand Down Expand Up @@ -358,6 +378,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
refreshStepUpStatus,
verifyStepUpWithPasskey,
verifyStepUpWithPasskeyPrf,
verifyStepUpWithTotp,
}}
>
{children}
Expand Down
82 changes: 81 additions & 1 deletion src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -212,6 +229,11 @@ export interface SeamlessAuthClient {
verifyStepUpWithPasskeyPrf: (
input: PasskeyPrfInput
) => Promise<StepUpWithPasskeyPrfResult>;
getTotpStatus: () => Promise<Response>;
startTotpEnrollment: () => Promise<Response>;
verifyTotpEnrollment: (code: string) => Promise<Response>;
disableTotp: (code: string) => Promise<Response>;
verifyStepUpWithTotp: (code: string) => Promise<StepUpVerificationResult>;
updateCredential: (input: {
id: string;
friendlyName: string | null;
Expand Down Expand Up @@ -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',
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import {
StepUpStatus,
StepUpWithPasskeyPrfResult,
StepUpVerificationResult,
TotpEnrollmentStartResult,
TotpStatus,
UpdateOrganizationInput,
} from '@/client/createSeamlessAuthClient';
import {
Expand Down Expand Up @@ -96,6 +98,8 @@ export type {
StepUpStatus,
StepUpWithPasskeyPrfResult,
StepUpVerificationResult,
TotpEnrollmentStartResult,
TotpStatus,
UpdateOrganizationInput,
User,
};
53 changes: 53 additions & 0 deletions tests/authProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ const Consumer = () => {
{auth.credentials.map(credential => credential.friendlyName).join(',')}
</span>
<button onClick={() => void auth.refreshStepUpStatus()}>Refresh step-up</button>
<button onClick={() => void auth.verifyStepUpWithTotp('123456')}>
Verify TOTP step-up
</button>
<button
onClick={() => {
if (firstCredential) {
Expand Down Expand Up @@ -209,6 +212,56 @@ describe('AuthProvider', () => {
});
});

it('records a fresh step-up after a successful TOTP verification', 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 () => ({
message: 'Success',
fresh: true,
method: 'totp',
verifiedAt: '2026-05-15T12:00:00.000Z',
expiresAt: '2026-05-15T12:05:00.000Z',
maxAgeSeconds: 300,
}),
} as any);

await act(async () => {
render(
<AuthProvider apiHost={apiHost}>
<Consumer />
</AuthProvider>
);
});

await waitFor(() => {
expect(screen.getByTestId('user')).toHaveTextContent('test@example.com');
});

fireEvent.click(screen.getByRole('button', { name: /verify totp step-up/i }));

await waitFor(() => {
expect(screen.getByTestId('stepUpFresh')).toHaveTextContent('true');
});

expect(mockFetchWithAuthImpl).toHaveBeenCalledWith('/totp/verify-mfa', {
method: 'POST',
body: JSON.stringify({ code: '123456' }),
});
});

it('updates credential state after a successful credential update', async () => {
const credential = buildCredential();
const updatedCredential = buildCredential({ friendlyName: 'Renamed passkey' });
Expand Down
Loading
Loading