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
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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;
Expand All @@ -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<void>;
refreshStepUpStatus(): Promise<StepUpStatus | null>;
verifyStepUpWithPasskey(): Promise<StepUpVerificationResult>;
logout(): Promise<void>;
deleteUser(): Promise<void>;
login(identifier: string, passkeyAvailable: boolean): Promise<Response>;
Expand Down Expand Up @@ -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 <button onClick={() => void handleDeleteAccount()}>Delete account</button>;
}
```

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:
Expand Down Expand Up @@ -185,13 +215,15 @@ 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

Client methods return raw `Response` objects except for the passkey convenience helpers:

- `loginWithPasskey(): Promise<PasskeyLoginResult>`
- `registerPasskey(metadata): Promise<PasskeyRegistrationResult>`
- `verifyStepUpWithPasskey(): Promise<StepUpVerificationResult>`

## React Hooks For Custom UI

Expand Down Expand Up @@ -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`
Expand Down
44 changes: 43 additions & 1 deletion src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -31,10 +35,13 @@ export interface AuthContextType {
hasSignedInBefore: boolean;
mode: AuthMode;
credentials: Credential[];
stepUpStatus: StepUpStatus | null;
updateCredential: (credential: Credential) => Promise<Credential>;
deleteCredential: (credentialId: string) => Promise<void>;
login: (identifier: string, passkeyAvailable: boolean) => Promise<Response | null>;
handlePasskeyLogin: () => Promise<boolean>;
refreshStepUpStatus: () => Promise<StepUpStatus | null>;
verifyStepUpWithPasskey: () => Promise<StepUpVerificationResult>;
loading: boolean;
}

Expand Down Expand Up @@ -67,6 +74,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
}) => {
const [user, setUser] = useState<User | null>(null);
const [credentials, setCredentials] = useState<Credential[]>([]);
const [stepUpStatus, setStepUpStatus] = useState<StepUpStatus | null>(null);
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [loading, setLoading] = useState<boolean>(true);
const { hasSignedInBefore, markSignedIn } = usePreviousSignIn();
Expand Down Expand Up @@ -116,6 +124,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
setIsAuthenticated(false);
setUser(null);
setCredentials([]);
setStepUpStatus(null);
}
}, [authClient]);

Expand All @@ -127,6 +136,7 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
setUser(null);
setIsAuthenticated(false);
setCredentials([]);
setStepUpStatus(null);
return;
} else {
throw new Error('Could not delete user.');
Expand Down Expand Up @@ -183,6 +193,35 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
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]);
Expand All @@ -208,10 +247,13 @@ export const AuthProvider: React.FC<AuthProviderProps> = ({
hasSignedInBefore: autoDetectPreviousSignin ? hasSignedInBefore : false,
mode: authMode,
credentials,
stepUpStatus,
updateCredential,
deleteCredential,
login,
handlePasskeyLogin,
refreshStepUpStatus,
verifyStepUpWithPasskey,
}}
>
{children}
Expand Down
84 changes: 83 additions & 1 deletion src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Response>;
login: (input: LoginInput) => Promise<Response>;
Expand All @@ -68,10 +83,25 @@ export interface SeamlessAuthClient {
checkMagicLink: () => Promise<Response>;
verifyMagicLink: (token: string) => Promise<Response>;
registerPasskey: (metadata: PasskeyMetadata) => Promise<PasskeyRegistrationResult>;
updateCredential: (input: { id: string; friendlyName: string | null }) => Promise<Response>;
getStepUpStatus: () => Promise<Response>;
verifyStepUpWithPasskey: () => Promise<StepUpVerificationResult>;
updateCredential: (input: {
id: string;
friendlyName: string | null;
}) => Promise<Response>;
deleteCredential: (id: string) => Promise<Response>;
}

const staleStepUpResult = (message: string): StepUpVerificationResult => ({
success: false,
fresh: false,
method: null,
verifiedAt: null,
expiresAt: null,
maxAgeSeconds: 0,
message,
});

export const createSeamlessAuthClient = (
opts: SeamlessAuthClientOptions
): SeamlessAuthClient => {
Expand Down Expand Up @@ -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',
Expand Down
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ import {
RegisterInput,
SeamlessAuthClient,
SeamlessAuthClientOptions,
StepUpMethod,
StepUpStatus,
StepUpVerificationResult,
} from '@/client/createSeamlessAuthClient';
import { AuthMode } from '@/fetchWithAuth';
import { useAuthClient } from '@/hooks/useAuthClient';
Expand All @@ -42,5 +45,8 @@ export type {
RegisterInput,
SeamlessAuthClient,
SeamlessAuthClientOptions,
StepUpMethod,
StepUpStatus,
StepUpVerificationResult,
User,
};
48 changes: 47 additions & 1 deletion tests/authProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -22,6 +22,8 @@ const Consumer = () => {
<span data-testid="user">{auth.user ? auth.user.email : 'none'}</span>
<span data-testid="isAuthenticated">{String(auth.isAuthenticated)}</span>
<span data-testid="hasRoleAdmin">{String(auth.hasRole('admin'))}</span>
<span data-testid="stepUpFresh">{String(auth.stepUpStatus?.fresh ?? false)}</span>
<button onClick={() => void auth.refreshStepUpStatus()}>Refresh step-up</button>
</div>
);
};
Expand Down Expand Up @@ -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(
<AuthProvider apiHost={apiHost}>
<Consumer />
</AuthProvider>
);
});

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');
});
});
});
Loading
Loading