diff --git a/.changeset/quiet-passkeys-enroll.md b/.changeset/quiet-passkeys-enroll.md new file mode 100644 index 0000000..e8c703d --- /dev/null +++ b/.changeset/quiet-passkeys-enroll.md @@ -0,0 +1,38 @@ +--- +'seamless-auth-api': minor +--- + +Passkey enrollment now requires an access session. This is a breaking change to the +WebAuthn contract. + +**Why.** `/login` and `/registration/register` both mint an ephemeral token for an +account that already exists, from an email address alone, and `/webauthn/register/start` +and `/webauthn/register/finish` accepted that token. Anyone who knew an address could +enroll a credential against the account and sign in as its owner, including an account +holding `OWNER_EMAIL` admin roles, without ever seeing the OTP that went to the real +owner. Both routes now take `auth: 'access'`. + +Nothing legitimate loses a path. Registration proves an address with an email OTP, and +verifying that OTP issues a session, so every shipped signup flow already holds one by +the time it offers a passkey. `/webauthn/login/start` and `/webauthn/login/finish` are +unchanged and still take a pre-auth token, because authenticating is what they are for. + +**Enrollment no longer issues a session.** `/webauthn/register/finish` answered with a +new access and refresh token pair. Under an access session that would leave the caller's +existing session live and unrevoked, and count against `max_concurrent_sessions`, which +can evict the user's other devices. It now answers `200` with the credential it enrolled, +in the shape `/users/credentials` already uses, and leaves `verified` and `lastLogin` +alone since the session that authorised the request proved both. + +**Upgrading, and it is lockstep.** A caller that enrolled a passkey with an ephemeral +token has to verify a factor first and enroll with the resulting session. Callers reaching +these routes through `@seamless-auth/express`, `@seamless-auth/fastify` or +`@seamless-auth/react` need the matching adapter release, which forwards the access +identity for these two routes. + +There is no safe release order between the two. An older adapter sends the token this +release refuses, and a newer adapter sends one an older API refuses, so enrollment answers +`401` until both sides land. Upgrade the API and the adapter together. + +The registration decoy responders are removed with the ephemeral gate. A decoy subject +can no longer reach enrollment, so there is nothing left for them to answer for. diff --git a/AGENTS.md b/AGENTS.md index 7bd68f4..cd6ae9a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -84,10 +84,18 @@ When tracing behavior, start at the route file, then the controller, then the se There are three token states worth keeping straight: -- Ephemeral token: short-lived pre-auth token used to continue registration/login flows. +- Ephemeral token: short-lived pre-auth token used to continue a registration or login + flow up to the point a factor is proven. It is issued from an identifier alone, so it + proves possession of an address, not of the account. - Access token: signed JWT used for authenticated application access. - Refresh token: opaque random token stored hashed in the `sessions` table. +That first distinction is load bearing. `/login` and `/registration/register` both mint an +ephemeral token for an account that already exists, from an email address alone, so an +ephemeral token authorises continuing a flow and nothing else. Anything that changes what +an account can sign in with takes an access session instead. Passkey enrollment is on +`auth: 'access'` for that reason, alongside TOTP enrollment and credential deletion. + The API exposes a single bearer/JSON auth contract: - Ephemeral, access, and refresh tokens are returned in JSON response payloads. diff --git a/README.md b/README.md index 1976c95..c821eb4 100644 --- a/README.md +++ b/README.md @@ -118,9 +118,13 @@ plane. Seamless Auth API returns JSON tokens instead of browser auth cookies. - Pre-auth flows return an ephemeral `token`; send it as `Authorization: Bearer ` to routes - marked as ephemeral-authenticated, such as OTP, magic-link, and WebAuthn continuation routes. -- Completed login, registration, OAuth, TOTP, passkey, and refresh flows return an access `token`; - send it as `Authorization: Bearer ` to access-authenticated routes. + marked as ephemeral-authenticated, such as OTP, magic-link, and WebAuthn login routes. It is + issued from an identifier alone, so it continues a flow and never authorises a change to how + the account signs in. Passkey enrollment takes an access token for that reason. +- Completed login, registration, OAuth, TOTP, passkey login, and refresh flows return an access + `token`; send it as `Authorization: Bearer ` to access-authenticated routes. Passkey + enrollment is not among them: it already requires a session, and answers with the credential + it enrolled rather than a new one. - Refresh uses the opaque `refreshToken` value, not the access token. - Internal service tokens remain separate. They are used only by explicitly service-token-protected paths or headers such as external delivery support, not as user access or ephemeral bearer tokens. diff --git a/docs/api-contract.md b/docs/api-contract.md index 9f523b5..3892003 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -21,11 +21,11 @@ design rationale, see [architecture.md](./architecture.md#token-model). All tokens are returned in the JSON body (the API never sets cookies). Present them as `Authorization: Bearer `. -| Token | Issued by | Presented to | Purpose | Lifetime | -| ------------- | --------------------------------------------------- | -------------------------------------------------------------------- | ------------------------------------------------------ | ----------------------------------- | -| **Ephemeral** | `POST /login` (and registration start) | the continuation step (OTP generate/verify, magic-link request/poll) | carry a pre-authenticated identity between login steps | short (about 5 minutes) | -| **Access** | OTP/WebAuthn/magic-link completion, `POST /refresh` | protected routes (e.g. `GET /users/me`) | authenticated application access | `access_token_ttl` (system config) | -| **Refresh** | the same completion steps and `POST /refresh` | `POST /refresh` only | obtain a new access token | `refresh_token_ttl` (system config) | +| Token | Issued by | Presented to | Purpose | Lifetime | +| ------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------ | ------------------------------------------------------ | ----------------------------------- | +| **Ephemeral** | `POST /login` (and registration start) | the continuation step (OTP generate/verify, magic-link request/poll, WebAuthn login) | carry a pre-authenticated identity between login steps | short (about 5 minutes) | +| **Access** | OTP/WebAuthn/magic-link completion, `POST /refresh` | protected routes (e.g. `GET /users/me`) | authenticated application access | `access_token_ttl` (system config) | +| **Refresh** | the same completion steps and `POST /refresh` | `POST /refresh` only | obtain a new access token | `refresh_token_ttl` (system config) | ### Token shapes diff --git a/docs/ecosystem.md b/docs/ecosystem.md index 4cfbab6..74eb22c 100644 --- a/docs/ecosystem.md +++ b/docs/ecosystem.md @@ -6,7 +6,7 @@ what depends on this API, how, and what changes here ripple outward. `CLAUDE.md` the short version + the ripple protocol; this file is the detail to read before/while making a contract-affecting change. -> Last surveyed: 2026-06-27. Versions and line numbers drift — treat specifics as leads to +> Last surveyed: 2026-09-08. Versions and line numbers drift — treat specifics as leads to > re-verify, not gospel. Re-run the survey when the dependency graph changes. ## Topology @@ -30,7 +30,7 @@ path goes through the adapter. ## Tier 1 — direct contract dependents -### `seamless-auth-server` — `@seamless-auth/core` + `@seamless-auth/express` (v0.5.x) +### `seamless-auth-server` — `@seamless-auth/core` + `@seamless-auth/express` (v0.13.0), `@seamless-auth/fastify` (v0.4.0) The server-side adapter SDK; a thin stateless proxy + cookie manager. **Highest coupling.** @@ -44,12 +44,15 @@ The server-side adapter SDK; a thin stateless proxy + cookie manager. **Highest `sub, token, refreshToken, ttl, refreshTtl, roles?, email?, phone?, organizationId?`. - **Status-code coupling:** branches on exact codes, e.g. magic-link poll treats `204` as "not yet verified". +- The Fastify adapter carries the same route table and identity choices, in + `packages/fastify/src/routes/proxyRoutes.ts`. A route change has to land in both or the + two adapters diverge; `packages/fastify/tests/parity.test.js` is what catches that. - No shared types package — coupling is 100% string-literal route paths + response shapes. - **Breaks if this API changes:** any route path/method, JWKS path or key format/alg, token claim/field names (`sub`/`sid`/...), the `/refresh` response shape, or branch-significant status codes. -### `seamless-auth-react` — `@seamless-auth/react` (v0.2.0) +### `seamless-auth-react` — `@seamless-auth/react` (v0.11.0) Drop-in React auth UI (email/phone OTP, magic link, WebAuthn/passkeys, OAuth, step-up, organizations). Hardcodes ~38 endpoint paths in `src/createSeamlessAuthClient.ts`. @@ -62,7 +65,7 @@ organizations). Hardcodes ~38 endpoint paths in `src/createSeamlessAuthClient.ts switching an endpoint's auth mode (ephemeral ↔ access), or response-shape changes to `/users/me`, OTP, or organization endpoints. -### `seamless-auth-types` — `@seamless-auth/types` (v0.1.3) ⇠ this API depends on it +### `seamless-auth-types` — `@seamless-auth/types` (consumed at ^0.20.0) ⇠ this API depends on it Shared Zod schemas / TS types — the contract's source of truth. **Reverse coupling:** changes here propagate _into_ this API and the SDKs. @@ -115,3 +118,19 @@ Provider-agnostic email/SMS adapter contract. **Reverse coupling.** 5. **Shared schemas** in `@seamless-auth/types` and the **messaging adapter contract**. 6. **Auth mode** of a route (ephemeral vs access vs service) — see `src/middleware/attachAuthMiddleware.ts`. + +### Auth-mode changes upgrade in lockstep + +An auth-mode change has no safe release order, because the adapter decides which cookie to +read from its own table and the API decides which token type to accept. Ship the API first +and an older adapter still sends the old token; ship the adapter first and it sends a token +the older API refuses. Either way the route answers `401` until both sides land. + +So an auth-mode change is a single coordinated release, called out in every changeset +involved, and adopters upgrade the API and the adapter together. Ordering only becomes a +free choice if the API accepts both identities for a deprecation window first, which is a +deliberate extra step, not the default. + +Worked example: moving `/webauthn/register/*` from `ephemeral` to `access` (2026-09-08) +touched this API, `@seamless-auth/core`, `@seamless-auth/express`, `@seamless-auth/fastify` +and `@seamless-auth/react` in one coordinated minor across all three repos. diff --git a/docs/security-posture.md b/docs/security-posture.md index 53256eb..37da833 100644 --- a/docs/security-posture.md +++ b/docs/security-posture.md @@ -27,20 +27,18 @@ The reason is still recorded in the `login_failed` auth event metadata, now with Returning `200` for an unknown identifier is worth nothing on its own. If the next request distinguished the decoy, the oracle would simply have moved one step later. All -fifteen endpoints that accept an ephemeral token therefore answer for a decoy the way +thirteen endpoints that accept an ephemeral token therefore answer for a decoy the way they answer for a real account: -| Endpoint group | A decoy gets | -| ------------------------ | --------------------------------------------------------------- | -| OTP send (4) | `200 { message: 'success', token }`, with nothing sent | -| OTP verify (4) | `401 { error: 'Not allowed' }`, the body a wrong code gets | -| Magic link request | `200`, the same "if an account exists" body a real request gets | -| Magic link poll | `204`, the state a real account sits in until someone clicks | -| WebAuthn register start | A registration challenge, with no challenge record stored | -| WebAuthn register finish | `403 { error: 'Missing challenge' }` | -| WebAuthn login start | An assertion challenge over a fabricated credential id | -| WebAuthn login finish | `401 { error: 'Authentication failed.' }` | -| TOTP login verify | `401 { error: 'totp_verification_failed' }` | +| Endpoint group | A decoy gets | +| --------------------- | --------------------------------------------------------------- | +| OTP send (4) | `200 { message: 'success', token }`, with nothing sent | +| OTP verify (4) | `401 { error: 'Not allowed' }`, the body a wrong code gets | +| Magic link request | `200`, the same "if an account exists" body a real request gets | +| Magic link poll | `204`, the state a real account sits in until someone clicks | +| WebAuthn login start | An assertion challenge over a fabricated credential id | +| WebAuthn login finish | `401 { error: 'Authentication failed.' }` | +| TOTP login verify | `401 { error: 'totp_verification_failed' }` | Policy-dependent branches are reproduced rather than skipped. A deployment with `email_otp` disabled answers `403 login_method_disabled` for every identifier, so a decoy diff --git a/openapi.json b/openapi.json index 1af69e2..1b64d4e 100644 --- a/openapi.json +++ b/openapi.json @@ -1,6 +1,6 @@ { "openapi": "3.0.3", - "info": { "title": "Seamless Auth API", "version": "0.8.0" }, + "info": { "title": "Seamless Auth API", "version": "0.10.0" }, "components": { "schemas": {}, "parameters": {}, @@ -9928,6 +9928,19 @@ } } }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, "403": { "description": "HTTP 403", "content": { @@ -9995,31 +10008,63 @@ "application/json": { "example": { "message": "string", - "token": "string", - "refreshToken": "string", - "refreshTokenHash": "string", - "sub": "string", - "roles": [null], - "email": "string", - "phone": "string", - "ttl": 0, - "refreshTtl": 0 + "credential": { + "id": "string", + "aaguid": "string", + "transports": [null], + "deviceType": null, + "backedUp": true, + "counter": 0, + "friendlyName": "string", + "lastUsedAt": null, + "platform": "string", + "browser": "string", + "deviceInfo": "string", + "createdAt": null, + "backedup": true, + "prfCapable": true + } }, "schema": { "type": "object", "properties": { "message": { "type": "string" }, - "token": { "type": "string" }, - "refreshToken": { "type": "string" }, - "refreshTokenHash": { "type": "string" }, - "sub": { "type": "string" }, - "roles": { "type": "array", "items": { "type": "string" } }, - "email": { "type": "string" }, - "phone": { "type": "string", "nullable": true }, - "ttl": { "type": "number" }, - "refreshTtl": { "type": "number" } + "credential": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "aaguid": { "type": "string", "nullable": true }, + "transports": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "ble", + "cable", + "hybrid", + "internal", + "nfc", + "smart-card", + "usb" + ] + } + }, + "deviceType": { "type": "string", "enum": ["singleDevice", "multiDevice"] }, + "backedUp": { "type": "boolean" }, + "counter": { "type": "number" }, + "friendlyName": { "type": "string", "nullable": true }, + "lastUsedAt": { "type": "string", "nullable": true, "format": "date-time" }, + "platform": { "type": "string", "nullable": true }, + "browser": { "type": "string", "nullable": true }, + "deviceInfo": { "type": "string", "nullable": true }, + "createdAt": { "type": "string", "nullable": true, "format": "date-time" }, + "backedup": { "type": "boolean" }, + "prfCapable": { "type": "boolean" } + }, + "required": ["id", "backedUp", "counter", "createdAt", "backedup"] + } }, - "required": ["message"] + "required": ["message", "credential"] } } } @@ -10065,6 +10110,19 @@ } } }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, "403": { "description": "HTTP 403", "content": { diff --git a/resources/coverage-badge.svg b/resources/coverage-badge.svg index 8bd6c88..bfd39a2 100644 --- a/resources/coverage-badge.svg +++ b/resources/coverage-badge.svg @@ -1,5 +1,5 @@ - - coverage: 99% + + coverage: 98.9% @@ -7,17 +7,17 @@ - + - - + + coverage coverage - 99% - 99% + 98.9% + 98.9% diff --git a/src/controllers/decoyResponders.ts b/src/controllers/decoyResponders.ts index 27db8b3..9f99d67 100644 --- a/src/controllers/decoyResponders.ts +++ b/src/controllers/decoyResponders.ts @@ -4,18 +4,13 @@ * See LICENSE file in the project root for full license information */ -import { generateAuthenticationOptions, generateRegistrationOptions } from '@simplewebauthn/server'; +import { generateAuthenticationOptions } from '@simplewebauthn/server'; import { Request, Response } from 'express'; import { getSystemConfig } from '../config/getSystemConfig.js'; import { canReturnExternalDelivery } from '../lib/externalDelivery.js'; import { signEphemeralToken } from '../lib/token.js'; -import { SUPPORTED_ALGORITHM_IDS } from '../lib/webauthnAlgorithms.js'; -import { - buildPrfAuthenticationExtensions, - buildPrfRegistrationExtensions, -} from '../lib/webauthnPrf.js'; -import type { WebAuthnAuthenticatorAttachment } from '../schemas/webauthn.requests.js'; +import { buildPrfAuthenticationExtensions } from '../lib/webauthnPrf.js'; import { AuthEventService } from '../services/authEventService.js'; import { decoyCredentialIdFor, @@ -35,7 +30,7 @@ import { AuthenticatedRequest } from '../types/types.js'; import { hashDeviceFingerprint } from '../utils/utils.js'; /** - * How the fifteen ephemeral endpoints answer when the pre-auth subject is a decoy. + * How the thirteen ephemeral endpoints answer when the pre-auth subject is a decoy. * * The rule each one follows: answer the way the endpoint answers for a real account in * the most ordinary state it could be in. A decoy's OTP send succeeds without sending, @@ -252,64 +247,6 @@ export const decoyPollMagicLink = async (req: Request, res: Response) => { return res.status(204).end(); }; -/** - * Real registration options, minus the challenge record. Skipping `issueChallenge` keeps - * the responder free of writes and costs nothing observable: the ceremony this returns - * can never be completed anyway, and `/register/finish` answers with the same - * "missing challenge" a real expired ceremony gets. - * - * The branches the real handler takes before it gets there are reproduced, because each - * one a decoy skipped would be a request a caller could craft to tell the two apart. - */ -export const decoyStartWebAuthnRegistration = async (req: Request, res: Response) => { - const authReq = req as AuthenticatedRequest; - const principal = decoyPrincipal(req); - const subject = principal.id; - const { requestPrf = false, attachment } = req.query as { - requestPrf?: boolean; - attachment?: WebAuthnAuthenticatorAttachment; - }; - const { app_name, rpid, authenticator_policy } = await getSystemConfig(); - const pinnedAttachment = - authenticator_policy.attachment === 'any' ? null : authenticator_policy.attachment; - - await logDecoy(req, 'webauthn:register_start'); - - if (pinnedAttachment && attachment && attachment !== pinnedAttachment) { - return res.status(400).json({ error: 'attachment_not_allowed' }); - } - - const options = await generateRegistrationOptions({ - rpName: app_name, - rpID: rpid, - userName: authReq.user.email, - timeout: 60000, - attestationType: authenticator_policy.attestation, - supportedAlgorithmIDs: SUPPORTED_ALGORITHM_IDS, - // A real account's enrolled credentials go here, so an always-empty list would say - // "this subject has no passkey" to anyone who looked. - excludeCredentials: principal.hasPasskey - ? [{ id: decoyCredentialIdFor(subject), transports: principal.transports }] - : [], - authenticatorSelection: { - userVerification: authenticator_policy.userVerification, - residentKey: 'preferred', - ...((pinnedAttachment ?? attachment) - ? { authenticatorAttachment: pinnedAttachment ?? attachment } - : {}), - }, - extensions: buildPrfRegistrationExtensions(requestPrf), - }); - - return res.json(options); -}; - -export const decoyFinishWebAuthnRegistration = async (req: Request, res: Response) => { - await logDecoy(req, 'webauthn:register_finish'); - - return res.status(403).json({ error: 'Missing challenge' }); -}; - /** * A plausible assertion challenge for a subject with no credentials. * diff --git a/src/controllers/webauthn.ts b/src/controllers/webauthn.ts index adad272..34942d1 100644 --- a/src/controllers/webauthn.ts +++ b/src/controllers/webauthn.ts @@ -27,6 +27,7 @@ import { import { Credential } from '../models/credentials.js'; import { User } from '../models/users.js'; import type { WebAuthnAuthenticatorAttachment } from '../schemas/webauthn.requests.js'; +import { serializeCredential } from '../services/apiResponseSerializers.js'; import { evaluateAuthenticatorPolicy } from '../services/authenticatorPolicyService.js'; import { AuthEventService } from '../services/authEventService.js'; import { rejectIfUserLocked } from '../services/lockoutPolicyService.js'; @@ -340,7 +341,7 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { // @ts-expect-error Ignoring for testing. const publicKey = base64url.encode(credential.publicKey); - await Credential.create({ + const created = await Credential.create({ id: credential.id, userId: user.id, publicKey: publicKey, @@ -372,11 +373,6 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { lastUsedAt: new Date(), }); - await user.update({ - lastLogin: new Date(), - verified: true, - }); - await AuthEventService.log({ userId: user.id, type: 'registration_success', @@ -384,18 +380,15 @@ const verifyWebAuthnRegistration = async (req: Request, res: Response) => { metadata: {}, }); - await issueSessionAndRespond({ - user: { - id: user.id, - email: user.email, - phone: user.phone, - roles: user.roles ?? [], - }, - req, - res, + // Enrollment is not a sign-in. The caller reached here with an access session, so + // issuing another one would leave the first live and unrevoked, and count against + // max_concurrent_sessions, which can evict the user's other devices. `verified` and + // `lastLogin` are left alone for the same reason: the session that authorised this + // request already proved both. + return res.json({ + message: 'Credential registered', + credential: serializeCredential(created), }); - - return; } catch (err) { logger.error(`Error in verifyWebAuthnRegistration: ${err}`); return res.status(500).json({ error: 'Unknown error verifying passkey' }); diff --git a/src/generated/api.ts b/src/generated/api.ts index 8ce212e..80204f6 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -9755,6 +9755,24 @@ export interface paths { }; }; }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; /** @description HTTP 403 */ 403: { headers: { @@ -9844,30 +9862,49 @@ export interface paths { /** * @example { * "message": "string", - * "token": "string", - * "refreshToken": "string", - * "refreshTokenHash": "string", - * "sub": "string", - * "roles": [ - * null - * ], - * "email": "string", - * "phone": "string", - * "ttl": 0, - * "refreshTtl": 0 + * "credential": { + * "id": "string", + * "aaguid": "string", + * "transports": [ + * null + * ], + * "deviceType": null, + * "backedUp": true, + * "counter": 0, + * "friendlyName": "string", + * "lastUsedAt": null, + * "platform": "string", + * "browser": "string", + * "deviceInfo": "string", + * "createdAt": null, + * "backedup": true, + * "prfCapable": true + * } * } */ 'application/json': { message: string; - token?: string; - refreshToken?: string; - refreshTokenHash?: string; - sub?: string; - roles?: string[]; - email?: string; - phone?: string | null; - ttl?: number; - refreshTtl?: number; + credential: { + id: string; + aaguid?: string | null; + transports?: ( + 'ble' | 'cable' | 'hybrid' | 'internal' | 'nfc' | 'smart-card' | 'usb' + )[]; + /** @enum {string} */ + deviceType?: 'singleDevice' | 'multiDevice'; + backedUp: boolean; + counter: number; + friendlyName?: string | null; + /** Format: date-time */ + lastUsedAt?: string | null; + platform?: string | null; + browser?: string | null; + deviceInfo?: string | null; + /** Format: date-time */ + createdAt: string | null; + backedup: boolean; + prfCapable?: boolean; + }; }; }; }; @@ -9901,6 +9938,24 @@ export interface paths { }; }; }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; /** @description HTTP 403 */ 403: { headers: { diff --git a/src/routes/webauthn.routes.ts b/src/routes/webauthn.routes.ts index ee69dfc..66e4f96 100644 --- a/src/routes/webauthn.routes.ts +++ b/src/routes/webauthn.routes.ts @@ -6,9 +6,7 @@ import { decoyFinishWebAuthnLogin, - decoyFinishWebAuthnRegistration, decoyStartWebAuthnLogin, - decoyStartWebAuthnRegistration, } from '../controllers/decoyResponders.js'; import { generateWebAuthn, @@ -17,6 +15,7 @@ import { verifyWebAuthnRegistration, } from '../controllers/webauthn.js'; import { createRouter } from '../lib/createRouter.js'; +import { CredentialUpdateResponseSchema } from '../schemas/credential.responses.js'; import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; import { WebAuthnAssertionStartSchema, @@ -31,12 +30,17 @@ import { const webauthnRouter = createRouter('/webauthn'); +// Enrollment takes an access session, not a pre-auth one. `/login` and +// `/registration/register` both mint an ephemeral token for an account that already +// exists from an email address alone, so accepting one here let anyone who knew an +// address enroll a credential and take the account over. Every shipped signup flow +// verifies an email OTP before it offers a passkey, and that step issues a session, so +// nothing legitimate reaches enrollment without one. webauthnRouter.get( '/register/start', { - auth: 'ephemeral', + auth: 'access', summary: 'Start WebAuthn registration', - decoy: decoyStartWebAuthnRegistration, tags: ['WebAuthn'], schemas: { @@ -45,6 +49,7 @@ webauthnRouter.get( response: { 200: WebAuthnChallengeSchema, 400: ErrorSchema, + 401: ErrorSchema, 403: ErrorSchema, 500: ErrorSchema, }, @@ -56,16 +61,16 @@ webauthnRouter.get( webauthnRouter.post( '/register/finish', { - auth: 'ephemeral', + auth: 'access', summary: 'Finish WebAuthn registration', - decoy: decoyFinishWebAuthnRegistration, tags: ['WebAuthn'], schemas: { body: WebAuthnRegisterFinishSchema, response: { - 200: WebAuthnTokenSuccessSchema, + 200: CredentialUpdateResponseSchema, + 401: ErrorSchema, 403: ErrorSchema, 500: ErrorSchema, }, diff --git a/tests/integration/authentication/decoyContinuation.spec.ts b/tests/integration/authentication/decoyContinuation.spec.ts index e2d82f1..2ea95e8 100644 --- a/tests/integration/authentication/decoyContinuation.spec.ts +++ b/tests/integration/authentication/decoyContinuation.spec.ts @@ -7,7 +7,7 @@ import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; import { decoyPrincipalForSubject, decoySubjectFor } from '../../../src/services/decoyPrincipal.js'; /** - * A decoy is only worth issuing if the fifteen endpoints that accept a pre-auth token + * A decoy is only worth issuing if the thirteen endpoints that accept a pre-auth token * answer for it the way they answer for a real account. Otherwise `/login` stops * disclosing which identifiers exist and the very next request starts. * @@ -51,7 +51,7 @@ vi.mock('../../../src/models/authEvents.js', () => ({ AuthEvent: { create: vi.fn() }, })); -import { generateAuthenticationOptions, generateRegistrationOptions } from '@simplewebauthn/server'; +import { generateAuthenticationOptions } from '@simplewebauthn/server'; import { signEphemeralToken } from '../../../src/lib/token.js'; import { WebAuthnChallenge } from '../../../src/models/webauthnChallenges.js'; @@ -68,10 +68,6 @@ beforeEach(() => { vi.clearAllMocks(); principal = DECOY; (signEphemeralToken as any).mockResolvedValue('decoy-token'); - (generateRegistrationOptions as any).mockResolvedValue({ - challenge: 'challenge', - rp: { id: 'localhost', name: 'Seamless' }, - }); (generateAuthenticationOptions as any).mockResolvedValue({ challenge: 'challenge', allowCredentials: [{ id: 'decoy-credential' }], @@ -202,16 +198,6 @@ describe('decoy continuation: magic link', () => { }); describe('decoy continuation: WebAuthn', () => { - it('returns a plausible registration challenge', async () => { - const res = await request(app).get('/webauthn/register/start'); - - expect(res.status).toBe(200); - expect(res.body.challenge).toBe('challenge'); - expect(generateRegistrationOptions).toHaveBeenCalledWith( - expect.objectContaining({ rpID: 'localhost', userName: DECOY.email }), - ); - }); - it('returns a plausible login challenge with a credential to offer', async () => { const res = await request(app).post('/webauthn/login/start').send({}); @@ -251,49 +237,7 @@ describe('decoy continuation: WebAuthn', () => { expect(res.text).toBe('Credentials not found'); }); - it('offers a passkeyless decoy nothing to exclude at registration', async () => { - principal = PASSKEYLESS_DECOY; - - await request(app).get('/webauthn/register/start'); - - expect(generateRegistrationOptions).toHaveBeenCalledWith( - expect.objectContaining({ excludeCredentials: [] }), - ); - }); - - it('excludes the decoy credential at registration when it has one', async () => { - await request(app).get('/webauthn/register/start'); - - // A real account's enrolled credentials go here, so an always-empty list would say - // "this subject has no passkey" to anyone who looked. - expect(generateRegistrationOptions).toHaveBeenCalledWith( - expect.objectContaining({ - excludeCredentials: [{ id: expect.any(String), transports: expect.any(Array) }], - }), - ); - }); - - it('refuses a disallowed attachment the way a real account does', async () => { - (getSystemConfig as any).mockResolvedValue({ - app_name: 'Seamless', - rpid: 'localhost', - authenticator_policy: { - userVerification: 'preferred', - attachment: 'platform', - attestation: 'none', - }, - }); - - const res = await request(app).get('/webauthn/register/start').query({ - attachment: 'cross-platform', - }); - - expect(res.status).toBe(400); - expect(res.body).toEqual({ error: 'attachment_not_allowed' }); - }); - it('stores no challenge for a decoy', async () => { - await request(app).get('/webauthn/register/start'); await request(app).post('/webauthn/login/start').send({}); // A decoy is issued for any identifier a stranger can type. If probing one wrote a @@ -301,15 +245,6 @@ describe('decoy continuation: WebAuthn', () => { expect(WebAuthnChallenge.create).not.toHaveBeenCalled(); }); - it('fails registration the way an expired ceremony fails', async () => { - const res = await request(app) - .post('/webauthn/register/finish') - .send({ attestationResponse: {}, metadata: {} }); - - expect(res.status).toBe(403); - expect(res.body).toEqual({ error: 'Missing challenge' }); - }); - it('fails login the way a bad assertion fails', async () => { const res = await request(app) .post('/webauthn/login/finish') diff --git a/tests/integration/webauthn/enrollmentAuth.spec.ts b/tests/integration/webauthn/enrollmentAuth.spec.ts new file mode 100644 index 0000000..c71410f --- /dev/null +++ b/tests/integration/webauthn/enrollmentAuth.spec.ts @@ -0,0 +1,138 @@ +import { Application } from 'express'; +import request from 'supertest'; +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; +import { Credential } from '../../../src/models/credentials.js'; + +/** + * The enrollment gate itself, driven through the real auth middleware. + * + * Every other WebAuthn spec replaces `attachAuthMiddleware` with one that injects a user + * whatever the route asked for, so none of them can tell an access-gated route from a + * pre-auth one. These can, which is the point: `/login` and `/registration/register` both + * mint an ephemeral token for an existing account from an email address alone, so an + * enrollment route that accepts one hands the account to anyone who knows the address. + */ +vi.unmock('../../../src/middleware/attachAuthMiddleware.js'); + +const ENROLLED_USER = { + id: 'user-1', + email: 'test@example.com', + phone: null, + roles: ['user'], +}; + +let app: Application; + +beforeAll(async () => { + const { createApp } = await import('../../../src/app.js'); + + app = await createApp(); +}); + +beforeEach(async () => { + const { validateBearerToken } = await import('../../../src/services/sessionService.js'); + + vi.clearAllMocks(); + (validateBearerToken as any).mockResolvedValue(null); + (Credential.findAll as any).mockResolvedValue([]); + (getSystemConfig as any).mockResolvedValue({ + app_name: 'SeamlessAuth', + rpid: 'localhost', + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], + }, + }); +}); + +describe('passkey enrollment requires an access session', () => { + it.each([ + ['get', '/webauthn/register/start'], + ['post', '/webauthn/register/finish'], + ])('validates the bearer on %s %s as an access token', async (method, path) => { + const { validateBearerToken } = await import('../../../src/services/sessionService.js'); + + await (request(app) as any)[method](path).set('Authorization', 'Bearer a-token'); + + expect(validateBearerToken).toHaveBeenCalledWith('a-token', 'access'); + }); + + it.each([ + ['get', '/webauthn/register/start'], + ['post', '/webauthn/register/finish'], + ])('refuses %s %s when the bearer is not an access token', async (method, path) => { + const { generateRegistrationOptions } = await import('@simplewebauthn/server'); + + // What `validateBearerToken` returns for an ephemeral token under an access + // expectation: `verifyJwtWithKid` refuses the typ mismatch and yields null. + const res = await (request(app) as any) + [method](path) + .set('Authorization', 'Bearer an-ephemeral-token'); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: 'unauthorized' }); + expect(generateRegistrationOptions).not.toHaveBeenCalled(); + }); + + it.each([ + ['get', '/webauthn/register/start'], + ['post', '/webauthn/register/finish'], + ])('refuses %s %s with no bearer at all', async (method, path) => { + const res = await (request(app) as any)[method](path); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: 'missing bearer token' }); + }); + + it('issues a challenge for a caller holding an access session', async () => { + const { validateBearerToken } = await import('../../../src/services/sessionService.js'); + const { generateRegistrationOptions } = await import('@simplewebauthn/server'); + + (validateBearerToken as any).mockResolvedValue({ + user: ENROLLED_USER, + sessionId: 'session-1', + }); + (generateRegistrationOptions as any).mockResolvedValue({ challenge: 'challenge' }); + + const res = await request(app) + .get('/webauthn/register/start') + .set('Authorization', 'Bearer an-access-token'); + + expect(res.status).toBe(200); + expect(res.body.challenge).toBe('challenge'); + }); + + // The takeover the gate exists to stop, end to end: `/registration/register` answers an + // address that already has an account with an ephemeral token for that account, and + // enrollment used to accept it. + it('refuses the ephemeral token a registration attempt hands back', async () => { + const { validateBearerToken } = await import('../../../src/services/sessionService.js'); + + const res = await request(app) + .get('/webauthn/register/start') + .set('Authorization', 'Bearer token-from-registration-register'); + + expect(validateBearerToken).toHaveBeenCalledWith('token-from-registration-register', 'access'); + expect(res.status).toBe(401); + }); +}); + +describe('passkey login still takes a pre-auth session', () => { + it.each([['/webauthn/login/start'], ['/webauthn/login/finish']])( + 'validates the bearer on post %s as an ephemeral token', + async (path) => { + const { validateBearerToken } = await import('../../../src/services/sessionService.js'); + + await request(app).post(path).set('Authorization', 'Bearer a-token').send({}); + + expect(validateBearerToken).toHaveBeenCalledWith('a-token', 'ephemeral'); + }, + ); +}); diff --git a/tests/integration/webauthn/webauthn.spec.ts b/tests/integration/webauthn/webauthn.spec.ts index cb9b4b0..e5adbab 100644 --- a/tests/integration/webauthn/webauthn.spec.ts +++ b/tests/integration/webauthn/webauthn.spec.ts @@ -1393,3 +1393,74 @@ describe('POST /webauthn/login/finish', () => { expect(res.status).toHaveBeenCalledWith(200); }); }); + +describe('POST /webauthn/register/finish response', () => { + async function enroll() { + const user = buildUser(); + + (User.findOne as any).mockResolvedValue(user); + (Credential.findAll as any).mockResolvedValue([]); + (Credential.create as any).mockResolvedValue(buildCredential()); + (WebAuthnChallenge.findOne as any).mockResolvedValue(buildWebAuthnChallenge()); + (getSystemConfig as any).mockResolvedValue({ + app_name: 'SeamlessAuth', + rpid: 'localhost', + origins: ['http://localhost:5137'], + access_token_ttl: '15m', + refresh_token_ttl: '1h', + session_idle_ttl: '8h', + authenticator_policy: { + attachment: 'any', + userVerification: 'required', + attestation: 'none', + requireKnownAuthenticator: false, + syncedPasskeys: 'allow', + aaguidAllowList: [], + aaguidDenyList: [], + }, + }); + + const { verifyRegistrationResponse } = await import('@simplewebauthn/server'); + (verifyRegistrationResponse as any).mockResolvedValue({ + verified: true, + registrationInfo: { + fmt: 'none', + credential: { id: 'cred-1', publicKey: Buffer.from('key'), counter: 0, transports: [] }, + credentialBackedUp: false, + credentialDeviceType: 'singleDevice', + }, + }); + + const res = await request(app) + .post('/webauthn/register/finish') + .send({ attestationResponse: {}, metadata: {} }); + + return { res, user }; + } + + it('answers with the credential it enrolled', async () => { + const { res } = await enroll(); + + expect(res.status).toBe(200); + expect(res.body.message).toBe('Credential registered'); + expect(res.body.credential).toEqual(expect.objectContaining({ id: 'cred-1' })); + }); + + // Enrollment is not a sign-in. Issuing a session here would leave the one the caller + // arrived with live and unrevoked, and count against max_concurrent_sessions, which + // can evict the user's other devices. + it('issues no session and returns no tokens', async () => { + const { res } = await enroll(); + + expect(Session.create).not.toHaveBeenCalled(); + expect(res.body.token).toBeUndefined(); + expect(res.body.refreshToken).toBeUndefined(); + }); + + // The access session that authorised the request already proved both. + it('leaves verified and lastLogin alone', async () => { + const { user } = await enroll(); + + expect(user.update).not.toHaveBeenCalled(); + }); +});