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
15 changes: 10 additions & 5 deletions .changeset/olive-pears-shout.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@
'@seamless-auth/react': minor
---

Add `getPasskeyPolicyErrorCode()`, which reads the code the auth API refuses a
passkey registration with (`synced_passkey_not_allowed`,
`authenticator_not_allowed`, or `prf_required`) so an app can explain the refusal
instead of rendering the raw code from `error.message`. Unrecognized codes return
`undefined`, so a refusal from a newer API keeps your generic messaging.
Add `getPasskeyPolicyErrorCode()`, which reads the code a refused passkey
registration carries (`attachment_not_allowed`, `synced_passkey_not_allowed`,
`authenticator_not_allowed`, or `prf_required`) so an app can explain the
refusal instead of rendering the raw code from `error.message`. Unrecognized
codes return `undefined`, so a refusal from a newer API keeps your generic
messaging.

The `PasskeyPolicyErrorCode` union is derived from `WebAuthnErrorCode` in
`@seamless-auth/types`, so the codes this recognizes cannot drift from the ones
the API sends.

This matters on a default deployment: the API's
`authenticator_policy.syncedPasskeys` defaults to `block`, and passkeys created
Expand Down
19 changes: 19 additions & 0 deletions .changeset/tidy-moons-repeat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@seamless-auth/react': minor
---

`registerPasskey()` accepts an `attachment`, so a caller can ask for a roaming
authenticator (`cross-platform`, a USB or NFC security key) or the one built
into the device (`platform`) instead of leaving the choice to the browser's
picker. The bundled enrolment view offers a "Use a security key instead" control
that takes this path, and explains a policy refusal rather than showing a
generic failure.

Omitting the option sends no query parameter, so the deployment's
`authenticator_policy.attachment` stays in charge and current behaviour is
unchanged. It is a request rather than an override: a deployment that pins the
other kind refuses the registration with `attachment_not_allowed`, which
`getPasskeyPolicyErrorCode()` reads.

`PasskeyAttachment` is exported, and is derived from the deployment policy type
in `@seamless-auth/types` rather than restating its members.
54 changes: 45 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -554,19 +554,51 @@ switch (detail?.name) {
`getWebAuthnErrorDetail()` returns `undefined` for any error that did not come from a ceremony, so an
HTTP failure keeps flowing through `error.message` and `error.body` as usual.

### Choosing the authenticator

By default the browser offers every kind of authenticator the deployment enrols, which is what
`authenticator_policy.attachment: 'any'` means on the API. Pass `attachment` to narrow the picker to
one kind, for example to send someone straight to an issued security key rather than leaving them to
find it in a browser dialog:

```ts
import { getPasskeyPolicyErrorCode } from '@seamless-auth/react';

const { error } = await authClient.registerPasskey({
metadata,
attachment: 'cross-platform',
});

if (getPasskeyPolicyErrorCode(error) === 'attachment_not_allowed') {
// This deployment pins the other kind. Fall back to the default path.
}
```

`'cross-platform'` is a roaming authenticator such as a USB or NFC security key. `'platform'` is the
one built into the device, such as Touch ID or Windows Hello. Omit the option to leave the choice to
the deployment.

This is a request, not an override. A deployment that has pinned
`authenticator_policy.attachment` to the other kind refuses the registration with
`attachment_not_allowed`, covered below. The bundled enrolment view offers a "Use a security key
instead" control that takes this path.

### Passkey policy refusals

A credential can also be refused after a successful ceremony, by the policy the API is configured
with. `registerPasskey()` then fails with status `403` and a body whose `error` is a stable code
rather than a sentence, so rendering `error.message` would put that code in front of a user. Use
`getPasskeyPolicyErrorCode()` to branch on it:
A registration can also be refused by the policy the API is configured with. `registerPasskey()`
then fails with a body whose `error` is a stable code rather than a sentence, so rendering
`error.message` would put that code in front of a user. Use `getPasskeyPolicyErrorCode()` to branch
on it:

```ts
import { getPasskeyPolicyErrorCode } from '@seamless-auth/react';

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

switch (getPasskeyPolicyErrorCode(error)) {
case 'attachment_not_allowed':
// The requested `attachment` is not the kind this deployment enrols.
break;
case 'synced_passkey_not_allowed':
// This passkey syncs to iCloud Keychain or Google Password Manager, and
// this deployment requires a device-bound one such as a security key.
Expand All @@ -583,11 +615,15 @@ switch (getPasskeyPolicyErrorCode(error)) {
}
```

| Code | When the API sends it |
| ---------------------------- | -------------------------------------------------------------------------------------------- |
| `synced_passkey_not_allowed` | `authenticator_policy.syncedPasskeys` is `block` and the credential is backup eligible |
| `authenticator_not_allowed` | the credential's AAGUID is on `aaguidDenyList`, or absent from a non-empty `aaguidAllowList` |
| `prf_required` | registration required PRF and the credential did not report support for it |
| Code | Stage | Status | When the API sends it |
| ---------------------------- | --------------- | ------ | -------------------------------------------------------------------------------------------- |
| `attachment_not_allowed` | register/start | 400 | the requested `attachment` is not the kind `authenticator_policy.attachment` pins |
| `synced_passkey_not_allowed` | register/finish | 403 | `authenticator_policy.syncedPasskeys` is `block` and the credential is backup eligible |
| `authenticator_not_allowed` | register/finish | 403 | the credential's AAGUID is on `aaguidDenyList`, or absent from a non-empty `aaguidAllowList` |
| `prf_required` | register/finish | 403 | registration required PRF and the credential did not report support for it |

`attachment_not_allowed` is refused before any ceremony runs, so the browser never prompts. The rest
are refused after a credential exists and can be inspected.

`syncedPasskeys` defaults to `block` on the Seamless Auth API. Passkeys created by iCloud Keychain
and Google Password Manager are backup eligible, so on a default deployment the most common consumer
Expand Down
23 changes: 23 additions & 0 deletions src/client/createSeamlessAuthClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {

import type {
AddOrganizationMemberRequest,
AuthenticatorAttachmentPolicy,
CreateOrganizationRequest,
CredentialUpdateResponse,
LoginMethod as LoginMethodShape,
Expand Down Expand Up @@ -160,10 +161,28 @@ export interface PasskeyRegistrationData {
/** Response body returned when credential metadata is updated. */
export type CredentialUpdateResult = CredentialUpdateResponse;

/**
* Which kind of authenticator to offer at registration. Omitting it leaves the
* choice to the deployment's `authenticator_policy.attachment`, which offers
* both kinds by default.
*
* Derived from the deployment policy type rather than restating its members:
* `any` is a standing default a deployment sets, not something a single request
* can ask for, so the request type is that policy minus that one member.
*/
export type PasskeyAttachment = Exclude<AuthenticatorAttachmentPolicy, 'any'>;

export interface RegisterPasskeyOptions {
metadata: PasskeyMetadata;
requestPrf?: boolean;
requirePrf?: boolean;
/**
* Narrows the browser picker to one kind of authenticator, for example
* `cross-platform` to send a user straight to an issued security key. A
* deployment that has pinned a different kind refuses this, so it is a
* request rather than an override.
*/
attachment?: PasskeyAttachment;
}

export type StepUpMethod = StepUpMethodShape;
Expand Down Expand Up @@ -333,6 +352,10 @@ function buildRegisterStartPath(input: RegisterPasskeyOptions) {
query.set('requestPrf', 'true');
}

if (input.attachment) {
query.set('attachment', input.attachment);
}

const queryString = query.toString();

return `/webAuthn/register/start${queryString ? `?${queryString}` : ''}`;
Expand Down
39 changes: 24 additions & 15 deletions src/client/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,28 +86,36 @@ export function getOAuthErrorCode(error: unknown): OAuthErrorCode | undefined {
}

/**
* Machine-readable codes `POST /webAuthn/register/finish` answers `403` with
* when a deployment refuses an otherwise valid credential on policy grounds.
* Machine-readable codes registration is refused with when a deployment will not
* enrol the authenticator on policy grounds.
*
* `attachment_not_allowed` comes from register/start with a `400`, before any
* ceremony runs. The rest come from register/finish with a `403`, once the
* credential exists and can be inspected.
*/
export type PasskeyPolicyErrorCode = Extract<
WebAuthnErrorCodeShape,
'synced_passkey_not_allowed' | 'authenticator_not_allowed' | 'prf_required'
| 'attachment_not_allowed'
| 'synced_passkey_not_allowed'
| 'authenticator_not_allowed'
| 'prf_required'
>;

/*
* `WebAuthnErrorCode` covers every WebAuthn code the API sends, across all of
* its operations, so it is deliberately narrowed rather than used whole. The
* two it leaves out belong to other calls and other statuses:
* `attachment_not_allowed` is a `400` from register/start, and
* `prf_output_not_allowed` a `400` from login and step-up finish. Reporting
* either as a registration policy refusal would be wrong.
* its operations, so it is narrowed rather than used whole. The one it leaves
* out, `prf_output_not_allowed`, is a `400` from login and step-up finish, and
* it reports a client that failed to strip PRF output rather than a deployment
* refusing an authenticator. Reporting it as a policy refusal would point an
* integrator at their configuration for what is a bug in the caller.
*
* `Extract` still ties the three names to the upstream union: if one is renamed
* or dropped there, it resolves to `never` and the `Record` below stops
* compiling. As with the OAuth codes, the runtime list stays out of the browser
* bundle so Zod does not come with it.
* `Extract` ties these names to the upstream union: if one is renamed or dropped
* there, it resolves to `never` and the `Record` below stops compiling. As with
* the OAuth codes, the runtime list stays out of the browser bundle so Zod does
* not come with it.
*/
const PASSKEY_POLICY_ERROR_CODES: Record<PasskeyPolicyErrorCode, true> = {
attachment_not_allowed: true,
synced_passkey_not_allowed: true,
authenticator_not_allowed: true,
prf_required: true,
Expand All @@ -127,9 +135,10 @@ function readPolicyCode(body: unknown): PasskeyPolicyErrorCode | undefined {
}

/**
* Read the passkey policy refusal off a registration error. Returns `undefined`
* for anything unrecognized, including codes added by a newer API, so callers
* keep their generic messaging instead of showing a raw code.
* Read the passkey policy refusal off a registration error, from either stage of
* the ceremony. Returns `undefined` for anything unrecognized, including codes
* added by a newer API, so callers keep their generic messaging instead of
* showing a raw code.
*
* The auth API sends the code as the whole of `error`, which is also what
* becomes `error.message`. A proxy in front of it may instead derive a
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
OrganizationSwitchResult,
OrganizationsResult,
PasskeyLoginData,
PasskeyAttachment,
PasskeyMetadata,
PasskeyRegistrationData,
RegisterInput,
Expand Down Expand Up @@ -108,6 +109,7 @@ export type {
OrganizationSwitchResult,
OrganizationsResult,
PasskeyLoginData,
PasskeyAttachment,
PasskeyMetadata,
PasskeyPolicyErrorCode,
PasskeyPrfInput,
Expand Down
22 changes: 22 additions & 0 deletions src/styles/registerPasskey.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,25 @@
opacity: 0.6;
cursor: default;
}

.secondary {
margin-top: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: none;
color: var(--seamless-accent, #059669);
border: 1px solid var(--seamless-accent, #059669);
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.2s ease;
}

.secondary:hover:not(:disabled) {
background-color: var(--seamless-accent-muted, rgba(5, 150, 105, 0.1));
}

.secondary:disabled {
opacity: 0.6;
cursor: default;
}
52 changes: 46 additions & 6 deletions src/views/PassKeyRegistration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
*/

import { useAuth } from '@/AuthProvider';
import { PasskeyMetadata } from '@/client/createSeamlessAuthClient';
import { PasskeyAttachment, PasskeyMetadata } from '@/client/createSeamlessAuthClient';
import { getPasskeyPolicyErrorCode, type PasskeyPolicyErrorCode } from '@/client/errors';
import React, { useState } from 'react';
import { useAuthClient } from '@/hooks/useAuthClient';
import { hasNonPasskeyLoginMethod, useLoginMethods } from '@/hooks/useLoginMethods';
Expand All @@ -16,6 +17,22 @@ import styles from '@/styles/registerPasskey.module.css';
import { parseUserAgent } from '@/utils';
import DeviceNameModal from '@/components/DeviceNameModal';

const POLICY_REFUSAL_MESSAGES: Record<PasskeyPolicyErrorCode, string> = {
attachment_not_allowed:
'This application does not accept that kind of authenticator. Try the other option.',
synced_passkey_not_allowed:
'This passkey syncs to a password manager, and this application requires one that stays on a single device, such as a security key.',
authenticator_not_allowed: 'This application does not accept this authenticator.',
prf_required:
'This authenticator does not support a feature this application requires.',
};

function policyRefusalMessage(error: unknown): string | undefined {
const code = getPasskeyPolicyErrorCode(error);

return code ? POLICY_REFUSAL_MESSAGES[code] : undefined;
}

const PasskeyRegistration: React.FC = () => {
const { refreshSession } = useAuth();
const authClient = useAuthClient();
Expand All @@ -32,6 +49,7 @@ const PasskeyRegistration: React.FC = () => {
browser: string;
deviceInfo: string;
} | null>(null);
const [pendingAttachment, setPendingAttachment] = useState<PasskeyAttachment>();

// The session already exists by the time this screen renders: the OTP step
// that led here established it. A passkey is an addition to that session
Expand All @@ -47,10 +65,11 @@ const PasskeyRegistration: React.FC = () => {
navigate('/');
};

const openDeviceModal = () => {
const openDeviceModal = (attachment?: PasskeyAttachment) => {
const { platform, browser, deviceInfo } = parseUserAgent();

setPendingMetadata({ platform, browser, deviceInfo });
setPendingAttachment(attachment);
setShowDeviceModal(true);
};

Expand All @@ -65,7 +84,10 @@ const PasskeyRegistration: React.FC = () => {
setStatus('loading');

try {
const { error } = await authClient.registerPasskey(metadata);
const { error } = await authClient.registerPasskey({
metadata,
attachment: pendingAttachment,
});

if (error) {
throw error;
Expand All @@ -75,13 +97,16 @@ const PasskeyRegistration: React.FC = () => {
setStatus('success');
setMessage('Passkey registered successfully.');
navigate('/');
} catch {
} catch (error) {
console.error('Passkey registration failed.');
setStatus('error');
setMessage('Error registering passkey.');
// 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.');
} finally {
setShowDeviceModal(false);
setPendingMetadata(null);
setPendingAttachment(undefined);
}
};

Expand Down Expand Up @@ -124,13 +149,28 @@ const PasskeyRegistration: React.FC = () => {
</p>

<button
onClick={openDeviceModal}
onClick={() => openDeviceModal()}
disabled={status === 'loading'}
className={styles.button}
>
{status === 'loading' ? 'Registering...' : 'Register Passkey'}
</button>

{/*
The default above leaves the choice to the deployment policy,
which offers both kinds. This is the deliberate path for someone
who has been handed an issued key and should not have to find it
in the browser's picker.
*/}
<button
type="button"
onClick={() => openDeviceModal('cross-platform')}
disabled={status === 'loading'}
className={styles.secondary}
>
Use a security key instead
</button>

{message && (
<p
className={`${styles.message} ${
Expand Down
Loading
Loading