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
27 changes: 27 additions & 0 deletions .changeset/enroll-a-passkey-while-signed-in.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
'@seamless-auth/react': minor
---

Add a passkey while signed in, and say what a 401 at enrollment means.

`registerPasskey` is now on `useAuth()` and on the framework-agnostic session actions,
not only on the client. The bundled UI already told users they could "add a passkey later
from a device that does", and nothing in the package implemented that: `credentials` could
be listed and deleted but never added. The context version refreshes the session
afterwards, so a settings screen renders the new passkey without a reload.

Enrollment now requires a signed-in session, which is a coordinated change with
`seamless-auth-api` and the server adapters. Upgrade all three together: the auth API and
the adapter have no safe release order between them, and enrollment answers `401` until
both land. The signup flow already satisfies it, because
verifying the email OTP issues a session before the passkey screen is reached, so nothing
in the bundled views moves. An application that called `registerPasskey()` before
verifying an address has to move that call after it.

A 401 from enrollment now reads "Your session expired before the passkey was saved" rather
than the generic "Error registering passkey." It is the session rather than anything about
the authenticator, and the generic wording invited the user to retry with the same expired
one. `isUnauthenticated(error)` is exported for callers rendering their own screens.

Corrects a stale README example that still passed a `token` to `registerPasskey`, a field
removed when the wire contract moved to `@seamless-auth/types`.
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -612,7 +612,7 @@ on it:
```ts
import { getPasskeyPolicyErrorCode } from '@seamless-auth/react';

const { error } = await authClient.registerPasskey({ token, metadata });
const { error } = await authClient.registerPasskey({ metadata });

switch (getPasskeyPolicyErrorCode(error)) {
case 'attachment_not_allowed':
Expand Down Expand Up @@ -760,6 +760,10 @@ function CustomRegistration() {
}
```

Enrolment takes the signed-in session, so it comes after the step that establishes one.
Verifying the email OTP signs the user in, which is why the bundled flow offers a passkey
on the screen after it rather than before.

To offer a passkey right after registering, call `registerPasskey()` before `refreshSession()`:

```ts
Expand All @@ -775,6 +779,40 @@ if (!error) {
}
```

### Adding a passkey from a settings screen

The same call adds a passkey to an account that already has one, or gives one to a user
who declined at signup. Use it from `useAuth()` rather than the client directly: that
version refreshes the session afterwards, so `credentials` includes the new passkey
without a reload.

```tsx
function AddPasskey() {
const { registerPasskey, credentials } = useAuth();

const add = async () => {
const { error } = await registerPasskey({
friendlyName: 'My laptop',
platform: 'macOS',
browser: 'Chrome',
deviceInfo: navigator.userAgent,
});

if (error) {
// A 401 means the session expired rather than anything about the
// authenticator. `isUnauthenticated(error)` tells the two apart.
}
};

return (
<>
<p>{credentials.length} passkeys</p>
<button onClick={add}>Add a passkey</button>
</>
);
}
```

### OTP and magic-link continuation

> **The request helpers take no identifier.** `requestMagicLink()`, `requestLoginEmailOtp()`, and
Expand Down
6 changes: 6 additions & 0 deletions src/AuthProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import {
OAuthProvidersResult,
OrganizationSwitchResult,
PasskeyLoginData,
PasskeyMetadata,
PasskeyRegistrationData,
RegisterPasskeyOptions,
StartOAuthLoginInput,
StartOAuthLoginResult,
StepUpPrfData,
Expand Down Expand Up @@ -65,6 +68,9 @@ export interface AuthContextType {
passkeyAvailable: boolean
) => Promise<SeamlessAuthResult<LoginStartResult>>;
handlePasskeyLogin: () => Promise<SeamlessAuthResult<PasskeyLoginData>>;
registerPasskey: (
input: PasskeyMetadata | RegisterPasskeyOptions
) => Promise<SeamlessAuthResult<PasskeyRegistrationData>>;
refreshStepUpStatus: () => Promise<SeamlessAuthResult<StepUpStatus>>;
verifyStepUpWithPasskey: () => Promise<SeamlessAuthResult<StepUpStatus>>;
verifyStepUpWithPasskeyPrf: (
Expand Down
13 changes: 13 additions & 0 deletions src/client/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,19 @@ export function getPasskeyPolicyErrorCode(
);
}

/**
* Whether a failure means nobody is signed in.
*
* Enrollment takes the signed-in session, so a `401` there is an expired or missing
* one rather than anything about the authenticator, and the two need different
* messages: one asks the user to sign in again, the other to reach for a different
* key. Raised locally, a ceremony failure carries `NETWORK_ERROR_STATUS`, so this
* matches on the response status alone.
*/
export function isUnauthenticated(error: unknown): boolean {
return error instanceof SeamlessAuthError && error.status === 401;
}

/**
* Detail recovered from a failed WebAuthn ceremony.
*
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import {
getOAuthErrorCode,
getPasskeyPolicyErrorCode,
getWebAuthnErrorDetail,
isUnauthenticated,
OAuthErrorCode,
PasskeyPolicyErrorCode,
SeamlessAuthError,
Expand Down Expand Up @@ -79,6 +80,7 @@ export {
hasNonPasskeyLoginMethod,
hasScopedRole,
isPasskeyPrfSupported,
isUnauthenticated,
roleGrantsAccess,
SeamlessAuthError,
useAuth,
Expand Down
12 changes: 12 additions & 0 deletions src/session/createAuthSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ import {
OAuthProvidersResult,
OrganizationSwitchResult,
PasskeyLoginData,
PasskeyMetadata,
PasskeyRegistrationData,
RegisterPasskeyOptions,
StartOAuthLoginInput,
StartOAuthLoginResult,
StepUpPrfData,
Expand Down Expand Up @@ -45,6 +48,9 @@ export interface AuthSessionActions {
passkeyAvailable: boolean
) => Promise<SeamlessAuthResult<LoginStartResult>>;
handlePasskeyLogin: () => Promise<SeamlessAuthResult<PasskeyLoginData>>;
registerPasskey: (
input: PasskeyMetadata | RegisterPasskeyOptions
) => Promise<SeamlessAuthResult<PasskeyRegistrationData>>;
refreshSession: () => Promise<SeamlessAuthResult<CurrentUserResult>>;
logout: () => Promise<SeamlessAuthResult<MessageResult>>;
logoutAllSessions: () => Promise<SeamlessAuthResult<MessageResult>>;
Expand Down Expand Up @@ -229,6 +235,12 @@ export function createAuthSession(options: AuthSessionOptions): AuthSession {

handlePasskeyLogin: () => refreshAfter(() => client.loginWithPasskey()),

// Enrollment takes the signed-in session, so this belongs beside the other
// credential actions rather than only on the client. Refreshing afterwards is what
// puts the new passkey in `credentials`, which is how a settings screen renders it
// without a reload.
registerPasskey: input => refreshAfter(() => client.registerPasskey(input)),

refreshSession,
logout,
logoutAllSessions,
Expand Down
18 changes: 15 additions & 3 deletions src/views/PassKeyRegistration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

import { useAuth } from '@/AuthProvider';
import { PasskeyAttachment, PasskeyMetadata } from '@/client/createSeamlessAuthClient';
import { getPasskeyPolicyErrorCode, type PasskeyPolicyErrorCode } from '@/client/errors';
import {
getPasskeyPolicyErrorCode,
isUnauthenticated,
type PasskeyPolicyErrorCode,
} from '@/client/errors';
import React, { useState } from 'react';
import { useAuthClient } from '@/hooks/useAuthClient';
import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods';
Expand Down Expand Up @@ -86,8 +90,16 @@ const PasskeyRegistration: React.FC = () => {
console.error('Passkey registration failed.');
setStatus('error');
// A policy refusal names something the user can act on, for example
// reaching for a security key instead. Anything else stays generic.
setMessage(policyRefusalMessage(error) ?? 'Error registering passkey.');
// reaching for a security key instead. A 401 is the session, not the
// authenticator: enrollment takes the signed-in one, so the answer is to
// sign in again rather than to try a different key. Anything else stays
// generic.
setMessage(
policyRefusalMessage(error) ??
(isUnauthenticated(error)
? 'Your session expired before the passkey was saved. Sign in again to add one.'
: 'Error registering passkey.')
);
}
};

Expand Down
20 changes: 20 additions & 0 deletions tests/RegisterPassKey.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,26 @@ describe('RegisterPasskey', () => {
});
});

// Enrollment takes the signed-in session, so a 401 here is the session rather
// than anything about the authenticator, and it used to read as the generic
// failure that tells the user to try again with the same expired session.
it('tells the user to sign in again on a 401', async () => {
mockRegisterPasskey.mockResolvedValueOnce({
data: null,
error: new SeamlessAuthError('unauthorized', 401, { error: 'unauthorized' }),
});

render(<RegisterPasskey />);

fireEvent.click(await screen.findByText(/Register Passkey/i));

await waitFor(() => {
expect(screen.getByText(/Your session expired/i)).toBeInTheDocument();
});

expect(screen.queryByText(/Error registering passkey/i)).not.toBeInTheDocument();
});

it('handles WebAuthnError', async () => {
mockRegisterPasskey.mockResolvedValueOnce({
data: null,
Expand Down
49 changes: 49 additions & 0 deletions tests/authSession.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,15 @@
import { createAuthSession } from '../src/session/createAuthSession';
import { createMemoryStorage, SessionStoragePort } from '../src/session/storage';
import { createFetchWithAuth } from '../src/fetchWithAuth';
import { startRegistration } from '@simplewebauthn/browser';

jest.mock('../src/fetchWithAuth');

jest.mock('@simplewebauthn/browser', () => ({
...jest.requireActual('@simplewebauthn/browser'),
startRegistration: jest.fn(),
}));

const mockFetchWithAuth = jest.fn();

(createFetchWithAuth as jest.Mock).mockReturnValue(mockFetchWithAuth);
Expand All @@ -18,6 +24,13 @@ const apiHost = 'https://api.example.com';

const user = { id: '1', email: 'test@example.com', phone: '', roles: ['admin'] };

const metadata = {
friendlyName: 'Second device',
platform: 'macOS',
browser: 'Chrome',
deviceInfo: 'MacBook Pro',
};

const okResponse = (body: unknown = {}) =>
({ ok: true, json: async () => body }) as unknown as Response;

Expand Down Expand Up @@ -284,6 +297,42 @@ describe('createAuthSession', () => {

expect(session.getState().credentials).toEqual([]);
});

// Enrollment takes the signed-in session, so a settings screen can add a passkey
// without leaving the account. Refreshing is what puts it in `credentials`.
it('adds an enrolled passkey to state', async () => {
const session = await loadWithCredential();

(startRegistration as jest.Mock).mockResolvedValueOnce({ id: 'cred-2' });
mockFetchWithAuth
.mockResolvedValueOnce(okResponse({ challenge: 'challenge' }))
.mockResolvedValueOnce(okResponse({ message: 'Credential registered' }))
.mockResolvedValueOnce(
okResponse({
user,
credentials: [{ id: 'cred-1' }, { id: 'cred-2' }],
})
);

const { data, error } = await session.actions.registerPasskey(metadata);

expect(error).toBeNull();
expect(data).toMatchObject({ credentialId: 'cred-2' });
expect(session.getState().credentials).toHaveLength(2);
});

it('leaves state alone when enrollment fails', async () => {
const session = await loadWithCredential();

mockFetchWithAuth.mockResolvedValueOnce(
failedResponse(401, { error: 'unauthorized' })
);

const { error } = await session.actions.registerPasskey(metadata);

expect(error).toMatchObject({ status: 401 });
expect(session.getState().credentials).toHaveLength(1);
});
});

describe('role checks', () => {
Expand Down
Loading