From 8c37b4b0cc50e8869847dbc1a6023d5663fa2e24 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 8 Sep 2026 09:43:18 -0400 Subject: [PATCH 1/2] fix(webauthn)!: require an access session to enroll a passkey /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 sets verified and 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. An earlier attempt at this refused enrollment when the account was already verified, which caught the primary signup path rather than the attack and was reverted. The signal is the token type, not the account state. /webauthn/register/finish no longer issues a session. 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 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. The two registration decoy responders go with the ephemeral gate. A decoy subject cannot reach enrollment any more, so there is nothing left for them to answer for, which takes the count of endpoints accepting a pre-auth token from fifteen to thirteen. Adapters must forward the access identity for these two routes. Coordinated releases of @seamless-auth/express, @seamless-auth/fastify and @seamless-auth/react follow. --- .changeset/quiet-passkeys-enroll.md | 33 +++++ docs/security-posture.md | 22 ++- openapi.json | 98 ++++++++++--- resources/coverage-badge.svg | 14 +- src/controllers/decoyResponders.ts | 69 +-------- src/controllers/webauthn.ts | 27 ++-- src/generated/api.ts | 95 +++++++++--- src/routes/webauthn.routes.ts | 19 ++- .../authentication/decoyContinuation.spec.ts | 69 +-------- .../webauthn/enrollmentAuth.spec.ts | 138 ++++++++++++++++++ tests/integration/webauthn/webauthn.spec.ts | 71 +++++++++ 11 files changed, 439 insertions(+), 216 deletions(-) create mode 100644 .changeset/quiet-passkeys-enroll.md create mode 100644 tests/integration/webauthn/enrollmentAuth.spec.ts diff --git a/.changeset/quiet-passkeys-enroll.md b/.changeset/quiet-passkeys-enroll.md new file mode 100644 index 0000000..6db36a1 --- /dev/null +++ b/.changeset/quiet-passkeys-enroll.md @@ -0,0 +1,33 @@ +--- +'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.** 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. + +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/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(); + }); +}); From 3f5d55dc0ea664852f48aaa61b8d0e77f82f3928 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Tue, 8 Sep 2026 10:15:17 -0400 Subject: [PATCH 2/2] docs: record that enrollment takes an access session, and how it ships The auth model said an ephemeral token continues "registration/login flows", which is what made accepting one at passkey enrollment look reasonable. It now says what the token actually proves, possession of an address rather than of the account, and names the rule that follows: anything changing how an account signs in takes an access session. The bearer token contract in the README said the same thing in the same misleading way. It listed WebAuthn continuation among the ephemeral routes, which is now login only, and passkey among the flows returning an access token, which enrollment no longer does. The ecosystem map gets the lockstep rule for auth-mode changes. There is no safe release order between this API and an adapter: ship either side first and the route answers 401 until the other lands, so an auth-mode change is one coordinated release rather than a sequence. The changeset says the same thing where an adopter will actually read it. Also refreshes the map's stale version markers against each repo's main, and records that the Fastify adapter now carries the same route table as Express, with the parity test as what keeps the two from drifting. --- .changeset/quiet-passkeys-enroll.md | 13 +++++++++---- AGENTS.md | 10 +++++++++- README.md | 10 +++++++--- docs/api-contract.md | 10 +++++----- docs/ecosystem.md | 27 +++++++++++++++++++++++---- 5 files changed, 53 insertions(+), 17 deletions(-) diff --git a/.changeset/quiet-passkeys-enroll.md b/.changeset/quiet-passkeys-enroll.md index 6db36a1..e8c703d 100644 --- a/.changeset/quiet-passkeys-enroll.md +++ b/.changeset/quiet-passkeys-enroll.md @@ -24,10 +24,15 @@ can evict the user's other devices. It now answers `200` with the credential it in the shape `/users/credentials` already uses, and leaves `verified` and `lastLogin` alone since the session that authorised the request proved both. -**Upgrading.** 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. +**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.