Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c8459ae
test(oidc): cover the sessionExchangeEnabled client flag
Bccorb Nov 5, 2026
52d5eb6
feat(oidc): add the sessionExchangeEnabled client flag
Bccorb Nov 5, 2026
32afa3b
test(oidc): cover signing and recording session:exchange tokens
Bccorb Nov 5, 2026
5627739
feat(oidc): sign and record session:exchange tokens
Bccorb Nov 5, 2026
7a81fac
test(sessions): cover POST /sessions/exchange
Bccorb Nov 5, 2026
5e92c68
feat(sessions): add POST /sessions/exchange
Bccorb Nov 5, 2026
5a3ccdb
test(sessions): cover the child session lifecycle
Bccorb Nov 5, 2026
03184aa
feat(sessions): tie an exchanged session to its parent
Bccorb Nov 5, 2026
4bc9281
test(sessions): cover device child step-up and session origin in lists
Bccorb Nov 5, 2026
da2771d
feat(sessions): require a step-up on admin routes for device children
Bccorb Nov 5, 2026
957be2f
test(sessions): add mock database coverage for session exchange
Bccorb Nov 5, 2026
cb36ea1
docs(sessions): document the session exchange and amend ADR 0001 D11
Bccorb Nov 5, 2026
6c89170
test(sessions): cover factor changes on a device child
Bccorb Nov 5, 2026
a434ca1
feat(sessions): gate factor changes on a device child
Bccorb Nov 5, 2026
8f2ff8f
test(oidc): seed system_config in the applications spec
Bccorb Nov 5, 2026
ab78cef
test(organizations): cover organization routes on a device child
Bccorb Nov 5, 2026
a4723f4
feat(organizations): gate organization routes on a device child
Bccorb Nov 5, 2026
cdcaa1c
test(sessions): pin a passkey step-up on a device child
Bccorb Nov 5, 2026
2346e1e
docs(sessions): record the edges of X6 and X7 in ADR 0003
Bccorb Nov 5, 2026
2d0e788
chore: depend on @seamless-auth/types 0.36.0
Bccorb Nov 5, 2026
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
7 changes: 7 additions & 0 deletions .changeset/session-exchange.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'seamless-auth-api': minor
---

A first-party native client signed in through the OpenID Provider can now reach the admin API, by exchanging the sign-in for a first-party session of its own (ADR 0003). An admin turns `sessionExchangeEnabled` on for a first-party public native application, with step-up, audited as `admin_oidc_application_session_exchange_updated`. Such a client can ask for a 60 second access token for `<oidc_issuer>/seamless` with the single scope `session:exchange`, and `/oidc/sign` records each one. `POST /sessions/exchange` (`adapter: false`, RFC 8693 parameter names) consumes that token once and returns the `/refresh` body plus `issued_token_type`, for a new session linked to the provider sign-in. A second presentation answers `400 invalid_request` with `token_already_used` and revokes the session the first one created, audited as `session_exchange_replayed`. A successful exchange is audited as `session_exchanged`.

The new session has its own refresh chain and step-up state, ends whenever its parent ends, never outlives it, and refreshing it counts as activity on the parent. It copies the parent's auth context and step-up for an authorization code grant, and nothing for a device grant. A device-grant session answers `403 step_up_required` until it has a fresh step-up, on every route behind `requireAdmin` and every `/organizations` route except `POST /organizations/:id/switch`, reads included, and on `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, `POST /recovery-codes`, `DELETE /users/credentials`, `/registration/phone`, `/registration/phone/verify` and `DELETE /recovery/requests/:id`, so a device sign-in with no TOTP cannot enroll one and the user does it from a browser. It can step up with TOTP or a real passkey assertion on the relying party's domain. OpenAPI now documents this 403 on those routes and on admin reads. It does not count against `max_concurrent_sessions`. Disabling or deleting the application, or turning the flag off, revokes the sessions it exchanged for. Session responses gain `parentSessionId`, `exchangeClientId` and `exchangeGrantType` on such a session, and application responses gain `sessionExchangeEnabled`. A new migration adds the columns. ADR 0001 D11 is amended to match.
2 changes: 2 additions & 0 deletions .github/workflows/postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ on:
- 'src/services/signInMetrics.ts'
# Conditional updates whose single winner only a real database can show.
- 'src/services/totpService.ts'
- 'src/services/oidcPayloadStore.ts'
- 'src/services/sessionExchangeService.ts'
- 'src/utils/otp.ts'
- 'tests/postgres/**'
- 'scripts/upgrade-check/**'
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0001-openid-provider.md
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,8 @@ A passkey resists phishing because it is bound to an origin. Two flows can still

The first-party verifier rejects all of these.

**Amendment, 2026-11-04: one exchange route ([ADR 0003](0003-native-admin-access.md)).** `POST /sessions/exchange` is the one place the API accepts a provider token, and only to mint a first-party session. It takes a single-use access token for the built-in resource with the `session:exchange` scope alone, which `/oidc/sign` recorded when it signed it. Admin routes and every other first-party route still accept first-party tokens only, and the first-party verifier still rejects every provider token.

- **Claims.**
- `sub` is the user id.
- Standard `email`, `email_verified`, `phone_number` and `phone_number_verified` follow the requested scopes.
Expand Down
45 changes: 28 additions & 17 deletions docs/adr/0003-native-admin-access.md

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions docs/api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ All tokens are returned in the JSON body (the API never sets cookies). Present t

> `POST /refresh` reads the refresh token from the `Authorization` header, not the request body.

`POST /sessions/exchange` also issues an access and refresh token pair, from a provider sign-in;
see [Native admin tools](#native-admin-tools).

## Status codes to branch on

Most endpoints use conventional codes (`200` success, `400` invalid input, `401`/`403` auth
Expand All @@ -56,6 +59,8 @@ failures, `500` server error). A few carry branch-significant meaning that clien
| `POST /login` | `423` | The account is locked out. Carries `retryAfterSeconds`. |
| `POST /refresh` | `401` | Missing, invalid, expired, or already-rotated refresh token. |
| `POST /refresh` | `405` | Method other than POST. |
| `POST /sessions/exchange` | `400` | `invalid_request`. With `token_already_used`, a replay: see below. |
| Admin, org and factor routes | `403` | `step_up_required`, even on a read, for a device-grant child session. |

The SDK adapters branch on these exact codes (for example, the magic-link poll treats `204` as
"pending"). Changing a branch-significant status code is a contract change; see the ripple
Expand Down Expand Up @@ -123,6 +128,70 @@ IP forwarding in the path while the app holds the tokens. What a deployment has
admit iCloud Keychain and Google Password Manager passkeys. A tenant on
`syncedPasskeys: "block"` refuses every iOS passkey.

### Native admin tools

A first-party public native client signed in through the OpenID Provider reaches `/admin/*` and
`/users/me` by exchanging the sign-in for a first-party session of its own
([ADR 0003](./adr/0003-native-admin-access.md)). An admin first turns `sessionExchangeEnabled` on
for the client, with `PATCH /admin/applications/:id`. It is off by default, and accepted only on a
first-party public client with a loopback or private-use redirect URI.

The client runs authorization code with PKCE, or the device grant, with
`resource=<oidc_issuer>/seamless` and `scope=openid session:exchange`, and without
`offline_access`. It then has 60 seconds to send the access token here, directly rather than
through an adapter:

```http
POST /sessions/exchange
Content-Type: application/json

{
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"subject_token": "<the at+jwt>",
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:refresh_token"
}
```

`requested_token_type` is optional. A `200` is the body `POST /refresh` returns, plus
`issued_token_type`. From there the client uses `/refresh`, `/logout`, `/users/me`,
`/totp/verify-mfa` and `/admin/*` as any first-party client does. `/logout` ends this session and
leaves the browser's alone.

Every failure is a `400` whose `error` is `invalid_request`. A request that fails schema validation
also carries `message` and `details`. Every subject token failure is the bare
`{ "error": "invalid_request" }`, whatever the reason, so the route says nothing about sessions. A
token presented a second time adds `"error_description": "token_already_used"` and revokes the
session the first presentation created, so a client seeing it should sign in again and tell the
user their sign-in may have been intercepted.

**Step-up.** A session from an authorization code grant starts with the parent's step-up as it
stood at the exchange, with the five minute window still running from the original step-up, so a
stale one stays stale. A session from a device grant starts with none. It steps up with the user's own factor:
`POST /totp/verify-mfa`, which is what a CLI uses, or a real passkey assertion on the relying
party's domain through `/step-up/webauthn/*`. Until then it answers `403 step_up_required`, with the
same body as `requireStepUp`, on every route behind `requireAdmin`, on every `/organizations` route
except `POST /organizations/:id/switch`, reads included, whether it acts through a global admin
role or as an ordinary member or owner, and on every route that changes how the account signs in or
recovers: `/totp/enroll/*`, `/totp/disable`, `/webauthn/register/*`, `POST /recovery-codes`,
`DELETE /users/credentials`, `/registration/phone`, `/registration/phone/verify` and
`DELETE /recovery/requests/:id`. `DELETE /sessions/:id`, `DELETE /sessions` and `DELETE /logout/all`
stay open: they gain no access, though they could slow the owner's response. As for any session, a
refresh does not carry a step-up to the new row.

**A device sign-in with no TOTP cannot enroll one.** The user enrolls from a browser, then signs in
on the device again. Without this, someone who phished a device approval could enroll their own
authenticator and step up with it. A session from an authorization code grant, and every other
first-party session, can enroll as before.

**Lifetime.** The session is capped at its parent's expiry, and `refreshTtl` says what is left. It
ends when its parent does: sign-out at the provider, revocation, expiry or idling out. Refreshing
it keeps the parent from idling out. An expired parent shows up as
`401 invalid_refresh_token` on `POST /refresh`.

**Session lists.** `GET /sessions` and the admin session views add `parentSessionId`,
`exchangeClientId` and `exchangeGrantType` to a child, and leave them out otherwise.

### Error body

Every `4xx` and `5xx` response uses one shape, with one additive extension for schema
Expand Down
26 changes: 26 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,32 @@ flowchart LR
Provider tokens carry `iss` equal to `oidc_issuer`, never `ISSUER`, and are signed with provider
keys, so the first-party verifier rejects them.

A first-party native client signed in through the edge reaches the admin API by exchanging, not
by presenting a provider token to it ([ADR 0003](./adr/0003-native-admin-access.md)). An admin
turns `sessionExchangeEnabled` on for the client, which adds the built-in resource with the
`session:exchange` scope and a 60 second token lifetime to its resource list. `POST /oidc/sign`
writes a `SessionExchangeToken` record in `oidc_payloads` for each such token, and
`POST /sessions/exchange` consumes it once, in the transaction that creates the session. A
second presentation is refused and revokes the session the first one created.

The result is a child session: an ordinary `sessions` row with its own refresh chain and step-up
state, plus `parentSessionId`, `exchangeClientId` and `exchangeGrantType`, carried across
rotation. Its parent is the session the edge holds for the provider sign-in.

- It ends with its parent. Validation and refresh follow `parentSessionId` forward through the
parent's rotations and refuse a child with no live parent, which covers a parent that expired
or idled out, and every revoke path also revokes the children, which keeps session lists
accurate. Ending a child leaves the parent alone.
- It never outlives its parent, and refreshing it counts as activity on the parent.
- An authorization code grant copies the parent's auth methods, auth time and step-up at the
exchange. A device grant copies nothing (ADR 0001, D10).
- `requireAdmin` refuses a session exchanged from a device grant until it has a fresh step-up of
its own, reads included.
- It is not counted against `max_concurrent_sessions`, since evicting a parent to make room for
its own child would end both.
- Disabling or deleting the client, or turning `sessionExchangeEnabled` off, revokes every
session it exchanged for.

For the full dependency and contract-coupling map across sibling repositories, see
[ecosystem.md](./ecosystem.md).

Expand Down
Loading
Loading