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
38 changes: 38 additions & 0 deletions .changeset/quiet-passkeys-enroll.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 9 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` 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 <token>` 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 <token>` 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.
Expand Down
10 changes: 5 additions & 5 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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>`.

| 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

Expand Down
27 changes: 23 additions & 4 deletions docs/ecosystem.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.**

Expand All @@ -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`.
Expand All @@ -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.
Expand Down Expand Up @@ -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.
22 changes: 10 additions & 12 deletions docs/security-posture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
98 changes: 78 additions & 20 deletions openapi.json
Original file line number Diff line number Diff line change
@@ -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": {},
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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"]
}
}
}
Expand Down Expand Up @@ -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": {
Expand Down
Loading
Loading