diff --git a/.changeset/enroll-a-passkey-while-signed-in.md b/.changeset/enroll-a-passkey-while-signed-in.md
new file mode 100644
index 0000000..955f46e
--- /dev/null
+++ b/.changeset/enroll-a-passkey-while-signed-in.md
@@ -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`.
diff --git a/README.md b/README.md
index 3bb914d..2a06f95 100644
--- a/README.md
+++ b/README.md
@@ -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':
@@ -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
@@ -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 (
+ <>
+
{credentials.length} passkeys
+
+ >
+ );
+}
+```
+
### OTP and magic-link continuation
> **The request helpers take no identifier.** `requestMagicLink()`, `requestLoginEmailOtp()`, and
diff --git a/src/AuthProvider.tsx b/src/AuthProvider.tsx
index a98bda9..75357c2 100644
--- a/src/AuthProvider.tsx
+++ b/src/AuthProvider.tsx
@@ -13,6 +13,9 @@ import {
OAuthProvidersResult,
OrganizationSwitchResult,
PasskeyLoginData,
+ PasskeyMetadata,
+ PasskeyRegistrationData,
+ RegisterPasskeyOptions,
StartOAuthLoginInput,
StartOAuthLoginResult,
StepUpPrfData,
@@ -65,6 +68,9 @@ export interface AuthContextType {
passkeyAvailable: boolean
) => Promise>;
handlePasskeyLogin: () => Promise>;
+ registerPasskey: (
+ input: PasskeyMetadata | RegisterPasskeyOptions
+ ) => Promise>;
refreshStepUpStatus: () => Promise>;
verifyStepUpWithPasskey: () => Promise>;
verifyStepUpWithPasskeyPrf: (
diff --git a/src/client/errors.ts b/src/client/errors.ts
index 4398856..1a48058 100644
--- a/src/client/errors.ts
+++ b/src/client/errors.ts
@@ -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.
*
diff --git a/src/index.ts b/src/index.ts
index 0c02e75..35c6bdd 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -48,6 +48,7 @@ import {
getOAuthErrorCode,
getPasskeyPolicyErrorCode,
getWebAuthnErrorDetail,
+ isUnauthenticated,
OAuthErrorCode,
PasskeyPolicyErrorCode,
SeamlessAuthError,
@@ -79,6 +80,7 @@ export {
hasNonPasskeyLoginMethod,
hasScopedRole,
isPasskeyPrfSupported,
+ isUnauthenticated,
roleGrantsAccess,
SeamlessAuthError,
useAuth,
diff --git a/src/session/createAuthSession.ts b/src/session/createAuthSession.ts
index c2c0cf8..8f0a1e0 100644
--- a/src/session/createAuthSession.ts
+++ b/src/session/createAuthSession.ts
@@ -14,6 +14,9 @@ import {
OAuthProvidersResult,
OrganizationSwitchResult,
PasskeyLoginData,
+ PasskeyMetadata,
+ PasskeyRegistrationData,
+ RegisterPasskeyOptions,
StartOAuthLoginInput,
StartOAuthLoginResult,
StepUpPrfData,
@@ -45,6 +48,9 @@ export interface AuthSessionActions {
passkeyAvailable: boolean
) => Promise>;
handlePasskeyLogin: () => Promise>;
+ registerPasskey: (
+ input: PasskeyMetadata | RegisterPasskeyOptions
+ ) => Promise>;
refreshSession: () => Promise>;
logout: () => Promise>;
logoutAllSessions: () => Promise>;
@@ -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,
diff --git a/src/views/PassKeyRegistration.tsx b/src/views/PassKeyRegistration.tsx
index f828708..1541fba 100644
--- a/src/views/PassKeyRegistration.tsx
+++ b/src/views/PassKeyRegistration.tsx
@@ -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';
@@ -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.')
+ );
}
};
diff --git a/tests/RegisterPassKey.test.tsx b/tests/RegisterPassKey.test.tsx
index 0a1a843..4c3e970 100644
--- a/tests/RegisterPassKey.test.tsx
+++ b/tests/RegisterPassKey.test.tsx
@@ -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();
+
+ 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,
diff --git a/tests/authSession.test.ts b/tests/authSession.test.ts
index a077d57..f9c7134 100644
--- a/tests/authSession.test.ts
+++ b/tests/authSession.test.ts
@@ -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);
@@ -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;
@@ -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', () => {