diff --git a/.changeset/session-exchange.md b/.changeset/session-exchange.md new file mode 100644 index 00000000..18a5bec9 --- /dev/null +++ b/.changeset/session-exchange.md @@ -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 `/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. diff --git a/.github/workflows/postgres.yml b/.github/workflows/postgres.yml index 10b702ed..f8ca4f1b 100644 --- a/.github/workflows/postgres.yml +++ b/.github/workflows/postgres.yml @@ -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/**' diff --git a/docs/adr/0001-openid-provider.md b/docs/adr/0001-openid-provider.md index 6f29598f..f5a7bb7c 100644 --- a/docs/adr/0001-openid-provider.md +++ b/docs/adr/0001-openid-provider.md @@ -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. diff --git a/docs/adr/0003-native-admin-access.md b/docs/adr/0003-native-admin-access.md index 468ad5e2..3182361b 100644 --- a/docs/adr/0003-native-admin-access.md +++ b/docs/adr/0003-native-admin-access.md @@ -77,8 +77,8 @@ Restricting the subject token to the built-in resource and a dedicated scope mat - **At the exchange, for an authorization code grant,** the child copies the parent's `authMethods`, `authTime`, `stepUpVerifiedAt` and `stepUpMethod` as they are at that instant. The API reads them from the parent row, the same source `/oidc/sign` already treats as the truth (D4, D9), so it does not depend on oidc#34. The five minute window still runs from the original step-up time, so a stale step-up stays stale. Later step-ups on either session never flow to the other. - **For a device grant,** the child copies nothing. It starts with no auth methods and no step-up, as D10 requires: the passkey proved who approved, not who holds the token (RFC 8628 section 5.4). - **A loopback CLI steps up with a passkey** by running a new loopback authorization with `acr_values=phr` and `max_age=300`. The hosted page runs `/step-up/*` on the parent (D3), the CLI exchanges the fresh subject token for a new child session with a fresh step-up, and logs out the old child. It can also use TOTP. -- **A device sign-in steps up with the authenticator app only,** through the existing `/totp/verify-mfa` on its own child session. A user with no TOTP enrolled cannot do admin writes from a device sign-in. That is a deliberate limit, not a gap: any passkey path for a headless machine would hand a browser's assurance to whoever holds the device code, which is the attack D10 exists to stop. -- **A device sign-in also needs step-up before admin reads** (decision X7). RFC 8628 section 5.4 describes an attacker who starts a device flow and gets a real user to approve it. Write step-up already stops that attacker from changing anything, because they cannot produce the victim's TOTP code. Without X7 they could still list every user on the instance. +- **A device sign-in steps up with the user's own factor,** on its own child session: TOTP through the existing `/totp/verify-mfa`, or a real passkey assertion on the relying party's domain through `/step-up/webauthn/*`. A phisher can produce neither. The CLI itself uses TOTP, since a terminal cannot run a WebAuthn ceremony. So from the CLI, a user with no TOTP enrolled cannot do admin writes from a device sign-in, and cannot enroll TOTP from one either: they enroll from a browser, then sign in on the device again. That is a deliberate limit, not a gap: copying the approving browser's step-up to a headless machine would hand its assurance to whoever holds the device code, which is the attack D10 exists to stop. +- **A device sign-in also needs step-up before admin reads** (decision X7). RFC 8628 section 5.4 describes an attacker who starts a device flow and gets a real user to approve it. Write step-up stops that attacker from changing anything only while they cannot produce a TOTP code, which is why X7 also covers every route that changes how the account signs in or recovers: an attacker able to enroll their own authenticator could then step up with it. Without X7 they could also list every user on the instance. ### Revocation @@ -105,17 +105,17 @@ B has no usable revocation story: every event above that should end the CLI's ac ## Decision summary -| # | Decision | -| --- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| X1 | Native clients reach the admin API through a token exchange into a linked child first-party session (Option C). Admin routes keep `auth: 'access'` only, and D11 stands. | -| X2 | The subject token is an RFC 9068 access token for `/seamless` carrying only `session:exchange`, at most 120 seconds long, for a first-party public native client with `sessionExchangeEnabled`. | -| X3 | `/oidc/sign` records every `session:exchange` token it signs, and `POST /sessions/exchange` consumes that record once. A replay is refused and revokes the session the first use created. | -| X4 | The child session has its own refresh chain and step-up state, is bounded by its parent's lifetime, and ends whenever its parent ends. | -| X5 | For an authorization code grant, the child copies the parent's auth context and step-up state at the exchange. For a device grant, it copies nothing. | -| X6 | A device-grant child steps up with TOTP only. A loopback child steps up with a passkey by signing in again with `acr_values` and `max_age` and exchanging again, or with TOTP. | -| X7 | A device-grant child needs a fresh step-up before any `/admin/*` route, reads included. | -| X8 | The edge never completes an authorization for an exchange-enabled client without user interaction, even with a live browser session. | -| X9 | When #512 ships, the exchange verifies a DPoP proof against the subject token's `cnf.jkt`, and exchange-enabled clients can be set to require it. | +| # | Decision | +| --- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| X1 | Native clients reach the admin API through a token exchange into a linked child first-party session (Option C). Admin routes keep `auth: 'access'` only, and D11 stands. | +| X2 | The subject token is an RFC 9068 access token for `/seamless` carrying only `session:exchange`, at most 120 seconds long, for a first-party public native client with `sessionExchangeEnabled`. | +| X3 | `/oidc/sign` records every `session:exchange` token it signs, and `POST /sessions/exchange` consumes that record once. A replay is refused and revokes the session the first use created. | +| X4 | The child session has its own refresh chain and step-up state, is bounded by its parent's lifetime, and ends whenever its parent ends. | +| X5 | For an authorization code grant, the child copies the parent's auth context and step-up state at the exchange. For a device grant, it copies nothing. | +| X6 | A device-grant child steps up with the user's own factor, TOTP or a real passkey assertion on the relying party's domain, and the CLI uses TOTP. A loopback child steps up with a passkey by signing in again with `acr_values` and `max_age` and exchanging again, or with TOTP. | +| X7 | A device-grant child needs a fresh step-up before any `/admin/*` route and any organization route except switching, reads included, and before any route that changes how the account signs in or recovers. From the CLI it cannot enroll a first factor. | +| X8 | The edge never completes an authorization for an exchange-enabled client without user interaction, even with a live browser session. | +| X9 | When #512 ships, the exchange verifies a DPoP proof against the subject token's `cnf.jkt`, and exchange-enabled clients can be set to require it. | ## X2. The subject token @@ -161,7 +161,7 @@ The child session is an ordinary `sessions` row with three new columns, carried - **Validity.** `validateSessionRecord` and the refresh path refuse a child whose parent no longer resolves to a live session. The cascade on revoke keeps session lists accurate, and the read-time check makes a missed cascade harmless. - **Activity.** A child refresh counts as activity on the parent, as a provider token signed for the parent already does (`recordProviderActivity`, D3). Without that, an active CLI would be signed out when the browser session idles out. - **Assurance (X5).** Copied from the parent at the exchange for `authorization_code`, never for `urn:ietf:params:oauth:grant-type:device_code`. -- **Device children (X7).** `requireAdmin` refuses a child with `exchangeGrantType` set to the device grant unless its step-up is fresh, with the existing `403 step_up_required` body, so the CLI's handling is one code path. +- **Device children (X7).** `requireAdmin` refuses a child with `exchangeGrantType` set to the device grant unless its step-up is fresh, with the existing `403 step_up_required` body, so the CLI's handling is one code path. `requireDeviceSessionStepUp` gives the same answer, and is listed first in the middleware of 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`. Every `/organizations` route has it too, reads included, except `POST /organizations/:id/switch`, which keeps the same session and grant. That is one rule for a child acting through a global admin role or as an ordinary member or owner. A new route of either kind opts in the same way. A loopback child and every first-party session are unaffected. - **Audit.** `session_exchanged` (client, grant, parent), `session_exchange_replayed`, and the existing session events for the child. ## X8. No silent authorization for exchange-enabled clients @@ -179,7 +179,7 @@ So for an exchange-enabled client, the edge always shows a confirmation that nam 5. **Use the API as today.** `/refresh`, `/logout`, `/users/me`, `/admin/*` and `/totp/verify-mfa` are unchanged. 6. **Step up on `403 step_up_required`.** - Loopback session: offer a passkey (repeat step 2 with `acr_values=phr` and `max_age=300`, exchange, swap the keychain tokens, `/logout` the old child) or the authenticator app. - - Device session: offer the authenticator app only, and say why when TOTP is not enrolled. + - Device session: offer the authenticator app. When TOTP is not enrolled, say so and send the user to a browser to enroll it, since a device session cannot, then sign in on the device again. `seamless org` commands need this step-up too, except switching. 7. **On `invalid_request` from the exchange,** sign in again. If the description is `token_already_used`, tell the user their sign-in may have been intercepted. The CLI's e2e covers both paths against a v0.23 API and edge. @@ -191,7 +191,7 @@ The CLI's e2e covers both paths against a v0.23 API and edge. 3. **`/oidc/sign`.** The X2 checks, and the X3 signing record. 4. **`POST /sessions/exchange`.** `defineRoute` with `adapter: false` (native clients call it directly, adapters never proxy it), request and response schemas, a per-IP rate limit, the checks above, child session issuance through `sessionService`, the X5 copy, audit events. Regenerate `openapi.json` and `adapter-manifest.json`. 5. **Session lifecycle.** The parent check in `validateSessionRecord` and the refresh path, the lifetime cap, parent activity on child refresh, and the cascade from every revoke path (logout, admin revoke, sign out everywhere, chain revocation, concurrent session eviction). -6. **Device children.** The X7 check in `requireAdmin`. +6. **Device children.** The X7 check in `requireAdmin`, and `requireDeviceSessionStepUp` on the routes that change how the account signs in or recovers. 7. **Session lists.** `/sessions` and the admin session views show a child's client and grant. A types change. 8. **Docs.** An amendment to ADR 0001 D11 naming `/sessions/exchange` as the one place a provider token is accepted, and only to mint a first-party session. `docs/architecture.md`, `docs/api-contract.md` and the native apps guide. 9. **Tests.** Replay (sequential and concurrent), wrong `aud`, extra scope, expired record, disabled client, recovery parent, device child step-up, and each revoke path ending the child. @@ -202,6 +202,14 @@ Edge work: X8, and DPoP on the exchange hop with #512 (X9). The maintainer accepted option C on 2026-11-04, with the settings this record recommends: bearer first-party tokens on admin calls until first-party DPoP exists, X4 (the child's lifetime is tied to its parent and child refreshes count as parent activity), X5 (a loopback exchange inherits the parent's step-up from the last five minutes), X7 (device grant sessions need a fresh step-up before any admin call), X8 (no silent authorization for exchange-enabled clients), the names `session:exchange`, `POST /sessions/exchange` and `sessionExchangeEnabled`, and the narrow amendment to ADR 0001 D11. +**Confirmed 2026-11-04: X7 covers factor changes.** As first accepted, X7 guarded only `/admin/*`, and its reasoning was wrong. Every factor route took any access session, so a device child with a phished approval could enroll a TOTP secret of its own, step up with it, and pass both X7 and `requireStepUp`. For a user who already had TOTP, the same enrollment disabled theirs. Both were run against real Postgres. The maintainer chose to refuse factor changes on a device child without a fresh step-up, through one shared middleware. A device sign-in with no TOTP therefore cannot enroll one, and the user does it from a browser. + +**Confirmed 2026-11-04: the edges of X6 and X7.** + +- **Organization routes are gated.** They check roles without `requireAdmin`, so a device child read member emails and changed a member to owner with no step-up, while `/admin/users` answered 403. Every `/organizations` route now needs the fresh step-up, reads included, whether the child acts through a global admin role or as an ordinary member or owner. `POST /organizations/:id/switch` stays open: it keeps the same session and grant, and X7 still applies afterwards. +- **Signing out other sessions stays open.** `DELETE /sessions/:id`, `DELETE /sessions` and `DELETE /logout/all` take no step-up on a device child. They gain no access, and revoking the parent or everything also ends the child. The known cost is that someone holding a phished device session could sign the owner out of other sessions, which could slow the owner's response. +- **A passkey step-up is allowed.** `/step-up/webauthn/*` accepts a device child, because a real assertion on the relying party's domain is the user's own factor, which a phisher cannot produce. An assertion from another origin or another key is refused. The CLI itself uses TOTP. + ## Security properties | Property | Provided by | @@ -211,6 +219,8 @@ The maintainer accepted option C on 2026-11-04, with the settings this record re | A subject token works once, for at most two minutes, and a replay is detected | X2, X3 | | Device sign-ins never borrow the approving browser's assurance | X5, ADR 0001 D10 | | A phished device approval cannot read or change admin data without the user's own factor | X6, X7 | +| A phished device approval cannot add, replace or remove a factor, or the account's phone | X7 | +| A phished device approval cannot read or change organizations or their members | X7 | | Ending the provider sign-in ends the CLI's admin access at once | X4 | | Ending the CLI's session does not end the user's browser session | X4 | | A local program cannot silently obtain an admin session through the CLI's client id | X8 | @@ -228,7 +238,8 @@ The maintainer accepted option C on 2026-11-04, with the settings this record re - Three `sessions` columns, one client flag, one route and a signing record, plus a read-time parent lookup on every request from an exchanged session. - A narrow, documented exception to D11: one route accepts a provider token, to mint a first-party session. - Until first-party DPoP exists, the CLI's refresh token is a bearer credential, as it is today. -- Device sign-ins without TOTP can read nothing on `/admin/*` (X7) and must use the loopback flow or email code sign-in. +- Device sign-ins without TOTP can read nothing on `/admin/*` or `/organizations`, except switching, and cannot enroll TOTP (X7). The user enrolls from a browser, or uses the loopback flow or email code sign-in. +- A phished device session can sign the owner out of other sessions, which could slow their response. It gains no access by doing so. ## Issue map diff --git a/docs/api-contract.md b/docs/api-contract.md index 988df976..554acf94 100644 --- a/docs/api-contract.md +++ b/docs/api-contract.md @@ -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 @@ -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 @@ -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=/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": "", + "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 diff --git a/docs/architecture.md b/docs/architecture.md index e68b26ba..eb46dc53 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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). diff --git a/openapi.json b/openapi.json index eac67b88..2efa795a 100644 --- a/openapi.json +++ b/openapi.json @@ -1222,10 +1222,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -1547,6 +1567,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -1977,6 +2030,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -2632,6 +2718,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -4004,6 +4123,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -4241,6 +4393,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "409": { "description": "HTTP 409", "content": { @@ -4555,6 +4740,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -4694,6 +4912,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -4842,7 +5093,40 @@ } } }, - "429": { + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, + "429": { "description": "HTTP 429", "content": { "application/json": { @@ -5287,6 +5571,7 @@ "admin_oidc_application_pkce_updated", "admin_oidc_application_resources_updated", "admin_oidc_application_secret_rotated", + "admin_oidc_application_session_exchange_updated", "admin_oidc_application_updated", "admin_oidc_resource_created", "admin_oidc_resource_deleted", @@ -5358,6 +5643,8 @@ "service_token_rotated", "service_token_success", "session_evicted", + "session_exchange_replayed", + "session_exchanged", "step_up_challenge", "step_up_failed", "step_up_success", @@ -5412,6 +5699,7 @@ "admin_oidc_application_pkce_updated", "admin_oidc_application_resources_updated", "admin_oidc_application_secret_rotated", + "admin_oidc_application_session_exchange_updated", "admin_oidc_application_updated", "admin_oidc_resource_created", "admin_oidc_resource_deleted", @@ -5483,6 +5771,8 @@ "service_token_rotated", "service_token_success", "session_evicted", + "session_exchange_replayed", + "session_exchanged", "step_up_challenge", "step_up_failed", "step_up_success", @@ -5615,6 +5905,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -5834,6 +6157,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -5882,6 +6238,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -6407,7 +6796,10 @@ "userAgent": { "type": "string", "nullable": true }, "lastUsedAt": { "type": "string" }, "expiresAt": { "type": "string" }, - "current": { "type": "boolean" } + "current": { "type": "boolean" }, + "parentSessionId": { "type": "string", "nullable": true }, + "exchangeClientId": { "type": "string", "nullable": true }, + "exchangeGrantType": { "type": "string", "nullable": true } }, "required": ["id", "lastUsedAt", "expiresAt", "current"] } @@ -6543,6 +6935,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -6882,6 +7307,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -6949,7 +7407,10 @@ "userAgent": { "type": "string", "nullable": true }, "lastUsedAt": { "type": "string" }, "expiresAt": { "type": "string" }, - "current": { "type": "boolean" } + "current": { "type": "boolean" }, + "parentSessionId": { "type": "string", "nullable": true }, + "exchangeClientId": { "type": "string", "nullable": true }, + "exchangeGrantType": { "type": "string", "nullable": true } }, "required": ["id", "lastUsedAt", "expiresAt", "current"] } @@ -7002,6 +7463,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -7058,7 +7552,10 @@ "userAgent": { "type": "string", "nullable": true }, "lastUsedAt": { "type": "string" }, "expiresAt": { "type": "string" }, - "current": { "type": "boolean" } + "current": { "type": "boolean" }, + "parentSessionId": { "type": "string", "nullable": true }, + "exchangeClientId": { "type": "string", "nullable": true }, + "exchangeGrantType": { "type": "string", "nullable": true } }, "required": ["id", "lastUsedAt", "expiresAt", "current"] } @@ -7111,6 +7608,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -8702,6 +9232,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -8819,6 +9382,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -8922,6 +9518,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9078,6 +9707,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9220,6 +9882,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9343,6 +10038,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9484,6 +10212,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -9660,6 +10421,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -14053,6 +14847,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -14103,6 +14898,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -14127,6 +14923,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -14157,7 +14986,7 @@ }, "post": { "summary": "Create an OpenID Provider application", - "description": "Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins.", + "description": "Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins. `sessionExchangeEnabled` is accepted only on a first-party public native client (ADR 0003).", "tags": ["Admin", "OpenID Provider applications"], "security": [{ "bearerAuth": [] }], "requestBody": { @@ -14196,6 +15025,7 @@ }, "deviceFlowEnabled": { "type": "boolean", "default": false }, "noticesEnabled": { "type": "boolean", "default": false }, + "sessionExchangeEnabled": { "type": "boolean", "default": false }, "pkceRequired": { "type": "boolean", "default": true }, "nonceRequired": { "type": "boolean", "default": true }, "backchannelLogoutUri": { @@ -14276,6 +15106,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, + "sessionExchangeEnabled": true, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -14328,6 +15159,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -14378,6 +15210,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -14536,6 +15369,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, + "sessionExchangeEnabled": true, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -14587,6 +15421,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -14637,6 +15472,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -14701,6 +15537,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -14744,7 +15613,7 @@ }, "patch": { "summary": "Update an OpenID Provider application", - "description": "Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens.", + "description": "Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens. Disabling an application, or turning `sessionExchangeEnabled` off, revokes every session it exchanged for.", "tags": ["Admin", "OpenID Provider applications"], "security": [{ "bearerAuth": [] }], "parameters": [ @@ -14787,6 +15656,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "backchannelLogoutUri": { @@ -14843,6 +15713,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, + "sessionExchangeEnabled": true, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -14894,6 +15765,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -14944,6 +15816,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -15084,7 +15957,7 @@ }, "delete": { "summary": "Delete an OpenID Provider application", - "description": "Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it.", + "description": "Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it, and revokes every session it exchanged for.", "tags": ["Admin", "OpenID Provider applications"], "security": [{ "bearerAuth": [] }], "parameters": [ @@ -15360,6 +16233,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, + "sessionExchangeEnabled": true, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -15412,6 +16286,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -15462,6 +16337,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -15681,6 +16557,7 @@ "grantTypes": [null], "deviceFlowEnabled": true, "noticesEnabled": true, + "sessionExchangeEnabled": true, "pkceRequired": true, "nonceRequired": true, "jwks": { "keys": [null] }, @@ -15732,6 +16609,7 @@ }, "deviceFlowEnabled": { "type": "boolean" }, "noticesEnabled": { "type": "boolean" }, + "sessionExchangeEnabled": { "type": "boolean" }, "pkceRequired": { "type": "boolean" }, "nonceRequired": { "type": "boolean" }, "jwks": { @@ -15782,6 +16660,7 @@ "grantTypes", "deviceFlowEnabled", "noticesEnabled", + "sessionExchangeEnabled", "pkceRequired", "nonceRequired", "jwks", @@ -16003,6 +16882,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -16330,6 +17242,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -16829,7 +17774,40 @@ } } }, - "required": ["resources"] + "required": ["resources"] + } + } + } + }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] } } } @@ -17159,6 +18137,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -17748,6 +18759,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -17951,6 +18995,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -18145,6 +19222,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -18368,6 +19478,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -18749,6 +19892,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -19156,6 +20332,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -19373,6 +20582,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -21445,10 +22687,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -22051,6 +23313,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "409": { "description": "HTTP 409", "content": { @@ -22203,6 +23498,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -22257,7 +23585,10 @@ "userAgent": { "type": "string", "nullable": true }, "lastUsedAt": { "type": "string" }, "expiresAt": { "type": "string" }, - "current": { "type": "boolean" } + "current": { "type": "boolean" }, + "parentSessionId": { "type": "string", "nullable": true }, + "exchangeClientId": { "type": "string", "nullable": true }, + "exchangeGrantType": { "type": "string", "nullable": true } }, "required": ["id", "lastUsedAt", "expiresAt", "current"] } @@ -22423,22 +23754,170 @@ }, "required": ["kind", "notice", "to", "text"] } - ] + ] + } + }, + "required": ["message"] + } + } + } + }, + "401": { + "description": "HTTP 401", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "429": { + "description": "HTTP 429", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + }, + "500": { + "description": "HTTP 500", + "content": { + "application/json": { + "example": { "message": "string", "error": "string" }, + "schema": { + "type": "object", + "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "required": ["error"] + } + } + } + } + } + } + }, + "/sessions/exchange": { + "post": { + "summary": "Exchange a provider sign-in for a first-party session", + "description": "For first-party public native clients signed in through the OpenID Provider (ADR 0003). Takes the provider access token for `/seamless` with the single `session:exchange` scope, once, and returns the same session `/refresh` returns, for a new session linked to the provider sign-in. Every subject token failure answers the same `400 invalid_request`. A second presentation adds `error_description: token_already_used` and revokes the session the first one created.", + "tags": ["Sessions"], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "grant_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:grant-type:token-exchange"] + }, + "subject_token": { "type": "string", "minLength": 1, "maxLength": 8192 }, + "subject_token_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:token-type:access_token"] + }, + "requested_token_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:token-type:refresh_token"] + } + }, + "required": ["grant_type", "subject_token", "subject_token_type"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "HTTP 200", + "content": { + "application/json": { + "example": { + "message": "string", + "token": "string", + "refreshToken": "string", + "sub": "string", + "sessionId": "string", + "organizationId": "string", + "roles": [null], + "email": "string", + "phone": "string", + "ttl": 0, + "refreshTtl": 0, + "recovery": true, + "issued_token_type": null + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "token": { "type": "string" }, + "refreshToken": { "type": "string" }, + "sub": { "type": "string" }, + "sessionId": { "type": "string" }, + "organizationId": { "type": "string", "nullable": true }, + "roles": { "type": "array", "items": { "type": "string" } }, + "email": { "type": "string" }, + "phone": { "type": "string", "nullable": true }, + "ttl": { "type": "number" }, + "refreshTtl": { "type": "number" }, + "recovery": { "type": "boolean" }, + "issued_token_type": { + "type": "string", + "enum": ["urn:ietf:params:oauth:token-type:refresh_token"] + } + }, + "required": ["message", "issued_token_type"] + } + } + } + }, + "400": { + "description": "HTTP 400", + "content": { + "application/json": { + "example": { + "error": null, + "error_description": null, + "message": "string", + "details": { "issues": [null] } + }, + "schema": { + "type": "object", + "properties": { + "error": { "type": "string", "enum": ["invalid_request"] }, + "error_description": { "type": "string", "enum": ["token_already_used"] }, + "message": { "type": "string" }, + "details": { + "type": "object", + "properties": { + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "path": { + "type": "array", + "items": { "anyOf": [{ "type": "string" }, { "type": "number" }] } + }, + "code": { "type": "string" }, + "message": { "type": "string" } + }, + "required": ["path", "code", "message"] + } + } + }, + "required": ["issues"] } }, - "required": ["message"] - } - } - } - }, - "401": { - "description": "HTTP 401", - "content": { - "application/json": { - "example": { "message": "string", "error": "string" }, - "schema": { - "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, "required": ["error"] } } @@ -23110,6 +24589,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -23550,6 +25062,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -24100,6 +25645,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -25342,6 +26920,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -25696,6 +27307,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -25755,6 +27399,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "404": { "description": "HTTP 404", "content": { @@ -26026,10 +27703,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -26320,10 +28017,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -26563,6 +28280,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -26844,6 +28594,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -26997,6 +28780,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "409": { "description": "HTTP 409", "content": { @@ -27123,6 +28939,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -27339,6 +29188,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -28284,6 +30166,39 @@ } } }, + "403": { + "description": "HTTP 403", + "content": { + "application/json": { + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, + "schema": { + "type": "object", + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, + "required": ["error"] + } + } + } + }, "429": { "description": "HTTP 429", "content": { @@ -28617,10 +30532,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } @@ -28812,10 +30747,30 @@ "description": "HTTP 403", "content": { "application/json": { - "example": { "message": "string", "error": "string" }, + "example": { + "message": "string", + "error": "string", + "fresh": true, + "method": null, + "verifiedAt": "string", + "expiresAt": "string", + "maxAgeSeconds": 0 + }, "schema": { "type": "object", - "properties": { "message": { "type": "string" }, "error": { "type": "string" } }, + "properties": { + "message": { "type": "string" }, + "error": { "type": "string" }, + "fresh": { "type": "boolean" }, + "method": { + "type": "string", + "nullable": true, + "enum": ["webauthn", "totp", null] + }, + "verifiedAt": { "type": "string", "nullable": true }, + "expiresAt": { "type": "string", "nullable": true }, + "maxAgeSeconds": { "type": "number" } + }, "required": ["error"] } } diff --git a/package-lock.json b/package-lock.json index 96d5880a..cc0c52f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@seamless-auth/messaging": "^0.3.1", "@seamless-auth/messaging-aws": "^0.3.1", "@seamless-auth/messaging-twilio": "^0.3.1", - "@seamless-auth/types": "^0.35.0", + "@seamless-auth/types": "^0.36.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", @@ -2927,9 +2927,9 @@ } }, "node_modules/@seamless-auth/types": { - "version": "0.35.0", - "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.35.0.tgz", - "integrity": "sha512-T/1E72d43M+Bzivgo9k8yxaSBRpaXO9XJYlsenCwYVPb71Qnis+w10nXS7X/Qh/zKp7otOjRys2T3jMgP1L6Eg==", + "version": "0.36.0", + "resolved": "https://registry.npmjs.org/@seamless-auth/types/-/types-0.36.0.tgz", + "integrity": "sha512-AxIPhwRegsKEtqi92BnOFv2TVpLsv/xGaJcQx4BUV7JlQSBMa0UUa8xyjJyTQP9m8YlI5O1gM09N2DRo9Ck/sQ==", "license": "Apache-2.0", "dependencies": { "zod": "^4.5.0" diff --git a/package.json b/package.json index d8c80600..7977dd59 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@seamless-auth/messaging": "^0.3.1", "@seamless-auth/messaging-aws": "^0.3.1", "@seamless-auth/messaging-twilio": "^0.3.1", - "@seamless-auth/types": "^0.35.0", + "@seamless-auth/types": "^0.36.0", "@simplewebauthn/server": "^14.0.3", "base64url": "^3.0.1", "bcrypt-ts": "^9.0.2", diff --git a/src/controllers/authentication.ts b/src/controllers/authentication.ts index 1bce6246..2b014af6 100644 --- a/src/controllers/authentication.ts +++ b/src/controllers/authentication.ts @@ -33,9 +33,15 @@ import { LoginMethod, resolveAvailableLoginMethods, } from '../services/loginPolicyService.js'; +import { resolveCurrentSession } from '../services/oidcAuthContext.js'; import { sweepBackchannelLogoutInBackground } from '../services/oidcBackchannelLogout.js'; +import { recordProviderActivity } from '../services/oidcSigning.js'; import { hasUsableRecoveryCode } from '../services/recoveryCodeService.js'; -import { isRevokedByCutoff, revokeAllSessionsForUsers } from '../services/sessionRevocation.js'; +import { + isRevokedByCutoff, + PARENT_SESSION_ENDED, + revokeAllSessionsForUsers, +} from '../services/sessionRevocation.js'; import { claimSessionRotation, classifyExpiredRefreshToken, @@ -618,13 +624,34 @@ export const refreshSession = async (req: Request, res: Response) => { return refuseRevokedRefresh(req, res, user.id, session); } + // An exchanged session ends with its parent (ADR 0003, X4), and is never refreshed past it. + const parent = session.parentSessionId + ? await resolveCurrentSession(session.parentSessionId) + : null; + + if (session.parentSessionId && !parent) { + await hardRevokeSession(session, PARENT_SESSION_ENDED); + await AuthEventService.log({ + userId: user.id, + sessionId: session.id, + type: 'refresh_token_failed', + req, + metadata: { reason: 'Parent session ended', refusal: PARENT_SESSION_ENDED }, + }); + return res.status(401).json({ error: 'invalid_refresh_token' }); + } + const { access_token_ttl, refresh_token_ttl, session_idle_ttl } = await getSystemConfig(); const chainStartedAt = session.chainStartedAt ?? session.createdAt ?? now; - const { expiresAt, idleExpiresAt } = computeRotatedSessionTimes( + const times = computeRotatedSessionTimes( { absoluteTtl: refresh_token_ttl || '1d', idleTtl: session_idle_ttl || '8h' }, chainStartedAt, now, ); + const expiresAt = parent + ? new Date(Math.min(times.expiresAt.getTime(), new Date(parent.expiresAt).getTime())) + : times.expiresAt; + const idleExpiresAt = new Date(Math.min(times.idleExpiresAt.getTime(), expiresAt.getTime())); // Only reachable when the absolute lifetime was shortened after this chain began, since // the lookup already refuses a session past its own expiry. @@ -663,6 +690,9 @@ export const refreshSession = async (req: Request, res: Response) => { // the sign-in. authMethods: session.authMethods ?? [], authTime: session.authTime ?? null, + parentSessionId: session.parentSessionId ?? null, + exchangeClientId: session.exchangeClientId ?? null, + exchangeGrantType: session.exchangeGrantType ?? null, }); // The read, the reuse check and this link are separate statements, so two refreshes @@ -714,6 +744,13 @@ export const refreshSession = async (req: Request, res: Response) => { return refuseRevokedRefresh(req, res, user.id, newSession); } + if (parent) { + // After the rotation, so a failure here cannot cost the client the tokens it just rotated. + await recordProviderActivity(parent.id).catch((error: unknown) => { + logger.error(`Could not record activity on a parent session: ${error}`); + }); + } + const token = await signAccessToken(newSession.id, user.id, user.roles, session.organizationId, { recovery: session.recovery === true, }); diff --git a/src/controllers/oidcApplications.ts b/src/controllers/oidcApplications.ts index 0fdd7a08..0cd17129 100644 --- a/src/controllers/oidcApplications.ts +++ b/src/controllers/oidcApplications.ts @@ -82,6 +82,7 @@ export async function createApplication(req: Request, res: Response) { tokenEndpointAuthMethod: application.tokenEndpointAuthMethod, grantTypes: application.grantTypes, deviceFlowEnabled: application.deviceFlowEnabled, + sessionExchangeEnabled: application.sessionExchangeEnabled, pkceRequired: application.pkceRequired, nonceRequired: application.nonceRequired, redirectUris: application.redirectUris, @@ -89,6 +90,12 @@ export async function createApplication(req: Request, res: Response) { }, }); + if (application.sessionExchangeEnabled) { + await audit(req, { + type: 'admin_oidc_application_session_exchange_updated', + metadata: { clientId: client.id, sessionExchangeEnabled: true }, + }); + } if (!application.pkceRequired) { await audit(req, { type: 'admin_oidc_application_pkce_updated', @@ -110,11 +117,19 @@ export async function createApplication(req: Request, res: Response) { export async function updateApplication(req: Request, res: Response) { try { - const { pkceRequired: pkceBefore, nonceRequired: nonceBefore } = await findOidcApplication( - req.params.id as string, - ); + const { + pkceRequired: pkceBefore, + nonceRequired: nonceBefore, + sessionExchangeEnabled: sessionExchangeBefore, + } = await findOidcApplication(req.params.id as string); const client = await updateOidcApplication(req.params.id as string, req.body); + if (client.sessionExchangeEnabled !== sessionExchangeBefore) { + await audit(req, { + type: 'admin_oidc_application_session_exchange_updated', + metadata: { clientId: client.id, sessionExchangeEnabled: client.sessionExchangeEnabled }, + }); + } if (client.pkceRequired !== pkceBefore) { await audit(req, { type: 'admin_oidc_application_pkce_updated', diff --git a/src/controllers/sessions.ts b/src/controllers/sessions.ts index a15257ea..a21d0458 100644 --- a/src/controllers/sessions.ts +++ b/src/controllers/sessions.ts @@ -9,6 +9,7 @@ import { Op } from 'sequelize'; import { Session } from '../models/sessions.js'; import { serializeSession } from '../services/apiResponseSerializers.js'; +import { exchangeSubjectToken } from '../services/sessionExchangeService.js'; import { revokeAllSessionsForUsers } from '../services/sessionRevocation.js'; import { hardRevokeSession } from '../services/sessionService.js'; import { AuthenticatedRequest } from '../types/types.js'; @@ -84,3 +85,18 @@ export const revokeAllSessions = async (req: Request, res: Response) => { return res.json({ message: 'Success' }); }; + +export const exchangeSession = async (req: Request, res: Response) => { + res.set('Cache-Control', 'no-store'); + + const outcome = await exchangeSubjectToken(req.body.subject_token, req); + + if (outcome.ok) { + return res.status(200).json(outcome.response); + } + + return res.status(400).json({ + error: 'invalid_request', + ...(outcome.replayed ? { error_description: 'token_already_used' } : {}), + }); +}; diff --git a/src/generated/api.ts b/src/generated/api.ts index c8fad787..dbb551df 100644 --- a/src/generated/api.ts +++ b/src/generated/api.ts @@ -1316,12 +1316,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -1616,6 +1627,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -1989,6 +2029,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -2571,6 +2640,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -3797,6 +3895,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -4007,6 +4134,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 409 */ 409: { headers: { @@ -4256,6 +4412,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -4407,6 +4592,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -4544,6 +4758,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -4976,6 +5219,7 @@ export interface paths { | 'admin_oidc_application_pkce_updated' | 'admin_oidc_application_resources_updated' | 'admin_oidc_application_secret_rotated' + | 'admin_oidc_application_session_exchange_updated' | 'admin_oidc_application_updated' | 'admin_oidc_resource_created' | 'admin_oidc_resource_deleted' @@ -5047,6 +5291,8 @@ export interface paths { | 'service_token_rotated' | 'service_token_success' | 'session_evicted' + | 'session_exchange_replayed' + | 'session_exchanged' | 'step_up_challenge' | 'step_up_failed' | 'step_up_success' @@ -5095,6 +5341,7 @@ export interface paths { | 'admin_oidc_application_pkce_updated' | 'admin_oidc_application_resources_updated' | 'admin_oidc_application_secret_rotated' + | 'admin_oidc_application_session_exchange_updated' | 'admin_oidc_application_updated' | 'admin_oidc_resource_created' | 'admin_oidc_resource_deleted' @@ -5166,6 +5413,8 @@ export interface paths { | 'service_token_rotated' | 'service_token_success' | 'session_evicted' + | 'session_exchange_replayed' + | 'session_exchanged' | 'step_up_challenge' | 'step_up_failed' | 'step_up_success' @@ -5272,8 +5521,8 @@ export interface paths { }; }; }; - /** @description HTTP 429 */ - 429: { + /** @description HTTP 403 */ + 403: { headers: { [name: string]: unknown; }; @@ -5281,7 +5530,36 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" * } */ 'application/json': { @@ -5508,6 +5786,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -5587,6 +5894,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -5911,6 +6247,9 @@ export interface paths { lastUsedAt: string; expiresAt: string; current: boolean; + parentSessionId?: string | null; + exchangeClientId?: string | null; + exchangeGrantType?: string | null; }[]; credentials: { id: string; @@ -5987,6 +6326,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -6537,6 +6905,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -6625,6 +7022,9 @@ export interface paths { lastUsedAt: string; expiresAt: string; current: boolean; + parentSessionId?: string | null; + exchangeClientId?: string | null; + exchangeGrantType?: string | null; }[]; total: number; }; @@ -6660,6 +7060,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -6747,6 +7176,9 @@ export interface paths { lastUsedAt: string; expiresAt: string; current: boolean; + parentSessionId?: string | null; + exchangeClientId?: string | null; + exchangeGrantType?: string | null; }[]; total: number; }; @@ -6782,6 +7214,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -8407,6 +8868,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -8530,6 +9020,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -8647,6 +9166,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -8800,6 +9348,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -8951,6 +9528,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -9077,6 +9683,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -9230,6 +9865,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -9402,6 +10066,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -14009,6 +14702,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -14038,6 +14732,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -14079,7 +14802,7 @@ export interface paths { put?: never; /** * Create an OpenID Provider application - * @description Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins. + * @description Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins. `sessionExchangeEnabled` is accepted only on a first-party public native client (ADR 0003). */ post: { parameters: { @@ -14114,6 +14837,8 @@ export interface paths { deviceFlowEnabled?: boolean; /** @default false */ noticesEnabled?: boolean; + /** @default false */ + sessionExchangeEnabled?: boolean; /** @default true */ pkceRequired?: boolean; /** @default true */ @@ -14168,6 +14893,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, + * "sessionExchangeEnabled": true, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -14214,6 +14940,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -14392,6 +15119,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, + * "sessionExchangeEnabled": true, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -14437,6 +15165,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -14496,6 +15225,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -14556,7 +15314,7 @@ export interface paths { post?: never; /** * Delete an OpenID Provider application - * @description Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it. + * @description Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it, and revokes every session it exchanged for. */ delete: { parameters: { @@ -14774,7 +15532,7 @@ export interface paths { head?: never; /** * Update an OpenID Provider application - * @description Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens. + * @description Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens. Disabling an application, or turning `sessionExchangeEnabled` off, revokes every session it exchanged for. */ patch: { parameters: { @@ -14798,6 +15556,7 @@ export interface paths { grantTypes?: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled?: boolean; noticesEnabled?: boolean; + sessionExchangeEnabled?: boolean; pkceRequired?: boolean; nonceRequired?: boolean; /** Format: uri */ @@ -14837,6 +15596,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, + * "sessionExchangeEnabled": true, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -14882,6 +15642,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -15078,6 +15839,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, + * "sessionExchangeEnabled": true, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -15124,6 +15886,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -15345,6 +16108,7 @@ export interface paths { * ], * "deviceFlowEnabled": true, * "noticesEnabled": true, + * "sessionExchangeEnabled": true, * "pkceRequired": true, * "nonceRequired": true, * "jwks": { @@ -15390,6 +16154,7 @@ export interface paths { grantTypes: ('authorization_code' | 'refresh_token' | 'client_credentials')[]; deviceFlowEnabled: boolean; noticesEnabled: boolean; + sessionExchangeEnabled: boolean; pkceRequired: boolean; nonceRequired: boolean; jwks: { @@ -15614,8 +16379,8 @@ export interface paths { }; }; }; - /** @description HTTP 404 */ - 404: { + /** @description HTTP 403 */ + 403: { headers: { [name: string]: unknown; }; @@ -15623,17 +16388,28 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; - /** @description HTTP 429 */ - 429: { + /** @description HTTP 404 */ + 404: { headers: { [name: string]: unknown; }; @@ -15650,8 +16426,26 @@ export interface paths { }; }; }; - /** @description HTTP 500 */ - 500: { + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { headers: { [name: string]: unknown; }; @@ -15925,6 +16719,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -16418,6 +17241,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -16720,6 +17572,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -17240,6 +18121,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -17410,6 +18320,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -17584,6 +18523,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -17778,6 +18746,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -18128,6 +19125,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -18542,6 +19568,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -18703,6 +19758,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -20916,12 +22000,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -21517,6 +22612,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 409 */ 409: { headers: { @@ -21693,6 +22817,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -21777,6 +22930,9 @@ export interface paths { lastUsedAt: string; expiresAt: string; current: boolean; + parentSessionId?: string | null; + exchangeClientId?: string | null; + exchangeGrantType?: string | null; }[]; total: number; }; @@ -21866,91 +23022,245 @@ export interface paths { 'application/json': { message: string; token?: string; - delivery?: - | { - /** @enum {string} */ - kind: 'otp_email'; - to: string; - token: string; - /** @enum {string} */ - purpose?: 'account_recovery'; - } - | { - /** @enum {string} */ - kind: 'otp_sms'; - to: string; - token: string | number; - /** @enum {string} */ - purpose?: 'account_recovery'; - } - | { - /** @enum {string} */ - kind: 'magic_link_email'; - to: string; - token?: string; - magicLinkUrl: string; - } - | { - /** @enum {string} */ - kind: 'enrollment_invite_email'; - to: string; - signInUrl: string; - } - | { - /** @enum {string} */ - kind: 'account_notice_email'; - /** @enum {string} */ - notice: - | 'account_recovery_opened' - | 'account_recovery_completed' - | 'recovery_code_used' - | 'identity_changed' - | 'application_notice'; - to: string; - subject: string; - text: string; - cancelUrl?: string; - application?: { - clientId: string; - name: string; - }; - } - | { - /** @enum {string} */ - kind: 'account_notice_sms'; - /** @enum {string} */ - notice: - | 'account_recovery_opened' - | 'account_recovery_completed' - | 'recovery_code_used' - | 'identity_changed' - | 'application_notice'; - to: string; - text: string; - cancelUrl?: string; - application?: { - clientId: string; - name: string; - }; - }; + delivery?: + | { + /** @enum {string} */ + kind: 'otp_email'; + to: string; + token: string; + /** @enum {string} */ + purpose?: 'account_recovery'; + } + | { + /** @enum {string} */ + kind: 'otp_sms'; + to: string; + token: string | number; + /** @enum {string} */ + purpose?: 'account_recovery'; + } + | { + /** @enum {string} */ + kind: 'magic_link_email'; + to: string; + token?: string; + magicLinkUrl: string; + } + | { + /** @enum {string} */ + kind: 'enrollment_invite_email'; + to: string; + signInUrl: string; + } + | { + /** @enum {string} */ + kind: 'account_notice_email'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed' + | 'application_notice'; + to: string; + subject: string; + text: string; + cancelUrl?: string; + application?: { + clientId: string; + name: string; + }; + } + | { + /** @enum {string} */ + kind: 'account_notice_sms'; + /** @enum {string} */ + notice: + | 'account_recovery_opened' + | 'account_recovery_completed' + | 'recovery_code_used' + | 'identity_changed' + | 'application_notice'; + to: string; + text: string; + cancelUrl?: string; + application?: { + clientId: string; + name: string; + }; + }; + }; + }; + }; + /** @description HTTP 401 */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 429 */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + /** @description HTTP 500 */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string" + * } + */ + 'application/json': { + message?: string; + error: string; + }; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/sessions/exchange': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Exchange a provider sign-in for a first-party session + * @description For first-party public native clients signed in through the OpenID Provider (ADR 0003). Takes the provider access token for `/seamless` with the single `session:exchange` scope, once, and returns the same session `/refresh` returns, for a new session linked to the provider sign-in. Every subject token failure answers the same `400 invalid_request`. A second presentation adds `error_description: token_already_used` and revokes the session the first one created. + */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': { + /** @enum {string} */ + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange'; + subject_token: string; + /** @enum {string} */ + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token'; + /** @enum {string} */ + requested_token_type?: 'urn:ietf:params:oauth:token-type:refresh_token'; + }; + }; + }; + responses: { + /** @description HTTP 200 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "token": "string", + * "refreshToken": "string", + * "sub": "string", + * "sessionId": "string", + * "organizationId": "string", + * "roles": [ + * null + * ], + * "email": "string", + * "phone": "string", + * "ttl": 0, + * "refreshTtl": 0, + * "recovery": true, + * "issued_token_type": null + * } + */ + 'application/json': { + message: string; + token?: string; + refreshToken?: string; + sub?: string; + sessionId?: string; + organizationId?: string | null; + roles?: string[]; + email?: string; + phone?: string | null; + ttl?: number; + refreshTtl?: number; + recovery?: boolean; + /** @enum {string} */ + issued_token_type: 'urn:ietf:params:oauth:token-type:refresh_token'; }; }; }; - /** @description HTTP 401 */ - 401: { + /** @description HTTP 400 */ + 400: { headers: { [name: string]: unknown; }; content: { /** * @example { + * "error": null, + * "error_description": null, * "message": "string", - * "error": "string" + * "details": { + * "issues": [ + * null + * ] + * } * } */ 'application/json': { + /** @enum {string} */ + error: 'invalid_request'; + /** @enum {string} */ + error_description?: 'token_already_used'; message?: string; - error: string; + details?: { + issues: { + path: (string | number)[]; + code: string; + message: string; + }[]; + }; }; }; }; @@ -21992,6 +23302,7 @@ export interface paths { }; }; }; + delete?: never; options?: never; head?: never; patch?: never; @@ -22719,6 +24030,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -23082,6 +24422,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -23549,6 +24918,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -24821,6 +26219,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -25152,6 +26579,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -25235,6 +26691,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 404 */ 404: { headers: { @@ -25508,12 +26993,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -25810,12 +27306,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -26049,6 +27556,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -26312,6 +27848,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -26525,6 +28090,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 409 */ 409: { headers: { @@ -26676,6 +28270,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -26879,6 +28502,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -27746,6 +29398,35 @@ export interface paths { }; }; }; + /** @description HTTP 403 */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + /** + * @example { + * "message": "string", + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 + * } + */ + 'application/json': { + message?: string; + error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; + }; + }; + }; /** @description HTTP 429 */ 429: { headers: { @@ -28096,12 +29777,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; @@ -28297,12 +29989,23 @@ export interface paths { /** * @example { * "message": "string", - * "error": "string" + * "error": "string", + * "fresh": true, + * "method": null, + * "verifiedAt": "string", + * "expiresAt": "string", + * "maxAgeSeconds": 0 * } */ 'application/json': { message?: string; error: string; + fresh?: boolean; + /** @enum {string|null} */ + method?: 'webauthn' | 'totp' | null; + verifiedAt?: string | null; + expiresAt?: string | null; + maxAgeSeconds?: number; }; }; }; diff --git a/src/lib/authEventCategories.ts b/src/lib/authEventCategories.ts index 5ef27b97..8e26f656 100644 --- a/src/lib/authEventCategories.ts +++ b/src/lib/authEventCategories.ts @@ -64,6 +64,8 @@ const EXACT_CATEGORIES: Readonly> = { // Session lifecycle sits with login, which is where logout_ already lands, and // an eviction is a direct consequence of a sign-in rather than an admin action. session_evicted: 'login', + session_exchange_replayed: 'login', + session_exchanged: 'login', user_created: 'registration', user_deleted: 'system', }; diff --git a/src/lib/defineRoute.ts b/src/lib/defineRoute.ts index 9d91904c..e0acf164 100644 --- a/src/lib/defineRoute.ts +++ b/src/lib/defineRoute.ts @@ -12,9 +12,11 @@ import { AuthAwareRequestHandler, getSecuritySchemeName, } from '../middleware/attachAuthMiddleware.js'; +import type { DeviceStepUpAwareHandler } from '../middleware/requireDeviceSessionStepUp.js'; import type { SecuritySchemeAwareHandler } from '../middleware/requireOidcEdge.js'; import { registry } from '../openapi/registry.js'; import { ErrorSchema, ValidationErrorSchema } from '../schemas/generic.responses.js'; +import { StepUpRequiredErrorSchema } from '../schemas/stepUp.responses.js'; import { AuthTokenType } from '../services/sessionService.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; @@ -145,6 +147,28 @@ function withValidationResponse( return { ...responseMap, 400: ValidationErrorSchema }; } +/** + * The same for a 403 only middleware writes: `requireAdmin` and `requireDeviceSessionStepUp` + * answer `step_up_required` to a session from a device grant (ADR 0003, X7), on routes whose + * handlers never mention it. + */ +function withDeviceStepUpResponse( + response: ZodTypeAny | Record | undefined, + middleware: RequestHandler[] | undefined, +): ZodTypeAny | Record | undefined { + const refuses = middleware?.some( + (handler) => (handler as DeviceStepUpAwareHandler).seamlessDeviceStepUp, + ); + if (!refuses) return response; + + const responseMap = response && isZodSchema(response) ? { 200: response } : (response ?? {}); + const declared = (responseMap as Record)[403]; + + if (declared && declared !== ErrorSchema) return response; + + return { ...responseMap, 403: StepUpRequiredErrorSchema }; +} + /** * Statuses every route can answer with, whatever its handler declares. * @@ -314,7 +338,12 @@ export function defineRoute( } : undefined, }, - responses: buildResponses(withValidationResponse(response, validatesRequest)), + responses: buildResponses( + withValidationResponse( + withDeviceStepUpResponse(response, options.middleware), + validatesRequest, + ), + ), }); const validate: RequestHandler = (req, res, next) => { diff --git a/src/middleware/rateLimit.ts b/src/middleware/rateLimit.ts index 02797014..98ea7892 100644 --- a/src/middleware/rateLimit.ts +++ b/src/middleware/rateLimit.ts @@ -201,3 +201,15 @@ export function oauthIpLimiter(req: Request, res: Response, next: NextFunction) export function oauthProviderLimiter(req: Request, res: Response, next: NextFunction) { return oauthProviderCachedLimiter(req, res, next); } + +// Not one of the shared `flow_rate_limits` keys, because a new key there needs a types +// release. A client exchanges once per sign-in, so this is well above any real traffic. +const SESSION_EXCHANGES_PER_WINDOW_PER_ADDRESS = 30; + +const sessionExchangeCachedLimiter = createFlowLimiter( + () => SESSION_EXCHANGES_PER_WINDOW_PER_ADDRESS, +); + +export function sessionExchangeLimiter(req: Request, res: Response, next: NextFunction) { + return sessionExchangeCachedLimiter(req, res, next); +} diff --git a/src/middleware/requireAdmin.ts b/src/middleware/requireAdmin.ts index ab0d2bc9..63f287f1 100644 --- a/src/middleware/requireAdmin.ts +++ b/src/middleware/requireAdmin.ts @@ -9,6 +9,10 @@ import { NextFunction, Response } from 'express'; import { hasScopedRole } from '../lib/scopedRoles.js'; import { AuthenticatedRequest } from '../types/types.js'; import getLogger from '../utils/logger.js'; +import { + type DeviceStepUpAwareHandler, + refuseDeviceSessionWithoutStepUp, +} from './requireDeviceSessionStepUp.js'; const logger = getLogger('requireAdmin'); @@ -17,7 +21,7 @@ export type AdminAccessLevel = 'read' | 'write'; export function requireAdmin(accessLevel?: AdminAccessLevel) { const requiredRole = accessLevel ? `admin:${accessLevel}` : 'admin'; - return (req: AuthenticatedRequest, res: Response, next: NextFunction) => { + const guard = (req: AuthenticatedRequest, res: Response, next: NextFunction) => { try { if (!req.user) { logger.error('Admin route hit without authenticated user'); @@ -29,10 +33,16 @@ export function requireAdmin(accessLevel?: AdminAccessLevel) { return res.status(403).json({ error: 'Forbidden' }); } + if (refuseDeviceSessionWithoutStepUp(req, res)) return; + next(); } catch (err) { logger.error(`requireAdmin failure: ${err}`); return res.status(500).json({ error: 'Internal server error' }); } }; + + const handler = guard as typeof guard & DeviceStepUpAwareHandler; + handler.seamlessDeviceStepUp = true; + return handler; } diff --git a/src/middleware/requireDeviceSessionStepUp.ts b/src/middleware/requireDeviceSessionStepUp.ts new file mode 100644 index 00000000..aeab1956 --- /dev/null +++ b/src/middleware/requireDeviceSessionStepUp.ts @@ -0,0 +1,51 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { NextFunction, Request, RequestHandler, Response } from 'express'; + +import { getStepUpStatusFromSession, serializeStepUpStatus } from '../services/stepUpService.js'; +import { AuthenticatedRequest } from '../types/types.js'; + +/** Marks a middleware that can answer `403 step_up_required`, so `defineRoute` documents it. */ +export type DeviceStepUpAwareHandler = RequestHandler & { seamlessDeviceStepUp?: true }; + +/** + * ADR 0003, X7. Someone who phished a device approval holds a session from a device grant + * without holding the user's factor (RFC 8628 section 5.4). This answers for them until that + * session has a fresh step-up of its own, and answers nothing for any other session. + * + * @returns whether a refusal was sent. + */ +export function refuseDeviceSessionWithoutStepUp(req: Request, res: Response): boolean { + const { deviceChildSession } = req as AuthenticatedRequest; + if (!deviceChildSession) return false; + + const status = getStepUpStatusFromSession(deviceChildSession); + if (status.fresh) return false; + + res.status(403).json({ + error: 'step_up_required', + message: 'Recent step-up authentication is required', + ...serializeStepUpStatus(status), + }); + return true; +} + +/** + * For a route that changes how the account signs in or recovers. Anything a session from a + * device grant could add here, it could then authenticate with, so without this an attacker's + * own TOTP would satisfy every other step-up on the instance. A new route of that kind opts in + * by listing this first in its `middleware`. + */ +export const requireDeviceSessionStepUp: DeviceStepUpAwareHandler = ( + req: Request, + res: Response, + next: NextFunction, +) => { + if (!refuseDeviceSessionWithoutStepUp(req, res)) next(); +}; + +requireDeviceSessionStepUp.seamlessDeviceStepUp = true; diff --git a/src/middleware/verifyBearerAuth.ts b/src/middleware/verifyBearerAuth.ts index e0a24951..dab3c9f2 100644 --- a/src/middleware/verifyBearerAuth.ts +++ b/src/middleware/verifyBearerAuth.ts @@ -89,6 +89,9 @@ export async function verifyBearerAuth( if (result.attemptId !== undefined) { (req as AuthenticatedRequest).attemptId = result.attemptId; } + if (result.deviceChildSession !== undefined) { + (req as AuthenticatedRequest).deviceChildSession = result.deviceChildSession; + } if (result.organizationId !== undefined) { (req as AuthenticatedRequest).organizationId = result.organizationId; } diff --git a/src/migrations/20261104120000-add-session-exchange.cjs b/src/migrations/20261104120000-add-session-exchange.cjs new file mode 100644 index 00000000..b9c39fab --- /dev/null +++ b/src/migrations/20261104120000-add-session-exchange.cjs @@ -0,0 +1,50 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +'use strict'; + +/** + * A native client can exchange a provider sign-in for a first-party session of its own, + * linked to the sign-in's session as its parent (ADR 0003). The link cascades on delete: a + * child whose parent row is gone must end, and clearing the link would turn it into an + * ordinary session with nothing bounding it. + * + * @type {import('sequelize-cli').Migration} + */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query(` + ALTER TABLE public.oidc_clients + ADD COLUMN IF NOT EXISTS "sessionExchangeEnabled" boolean DEFAULT false NOT NULL; + + ALTER TABLE public.sessions + ADD COLUMN IF NOT EXISTS "parentSessionId" uuid + REFERENCES public.sessions(id) ON UPDATE CASCADE ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS "exchangeClientId" character varying(255), + ADD COLUMN IF NOT EXISTS "exchangeGrantType" character varying(255); + + CREATE INDEX IF NOT EXISTS sessions_parent_session_idx + ON public.sessions ("parentSessionId") WHERE "parentSessionId" IS NOT NULL; + + CREATE INDEX IF NOT EXISTS sessions_exchange_client_idx + ON public.sessions ("exchangeClientId") WHERE "exchangeClientId" IS NOT NULL; + `); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(` + DROP INDEX IF EXISTS public.sessions_exchange_client_idx; + DROP INDEX IF EXISTS public.sessions_parent_session_idx; + + ALTER TABLE public.sessions + DROP COLUMN IF EXISTS "exchangeGrantType", + DROP COLUMN IF EXISTS "exchangeClientId", + DROP COLUMN IF EXISTS "parentSessionId"; + + ALTER TABLE public.oidc_clients DROP COLUMN IF EXISTS "sessionExchangeEnabled"; + `); + }, +}; diff --git a/src/models/oidcClients.ts b/src/models/oidcClients.ts index 295ee41f..53511488 100644 --- a/src/models/oidcClients.ts +++ b/src/models/oidcClients.ts @@ -30,6 +30,8 @@ export interface OidcClientAttributes { refreshTokenTtlSeconds?: number | null; deviceFlowEnabled: boolean; noticesEnabled: boolean; + /** Lets a first-party public native client exchange a sign-in for a session (ADR 0003). */ + sessionExchangeEnabled: boolean; /** https origins an account notice's action link may point at, besides https redirects. */ noticeActionOrigins: string[]; pkceRequired: boolean; @@ -54,6 +56,7 @@ type OidcClientCreationAttributes = Optional< | 'refreshTokenTtlSeconds' | 'deviceFlowEnabled' | 'noticesEnabled' + | 'sessionExchangeEnabled' | 'noticeActionOrigins' | 'pkceRequired' | 'nonceRequired' @@ -81,6 +84,7 @@ export class OidcClient declare refreshTokenTtlSeconds: number | null; declare deviceFlowEnabled: boolean; declare noticesEnabled: boolean; + declare sessionExchangeEnabled: boolean; declare noticeActionOrigins: string[]; declare pkceRequired: boolean; declare nonceRequired: boolean; @@ -113,6 +117,7 @@ const initializeOidcClientModel = (sequelize: Sequelize) => { refreshTokenTtlSeconds: { type: DataTypes.INTEGER, allowNull: true }, deviceFlowEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, noticesEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, + sessionExchangeEnabled: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false }, noticeActionOrigins: { type: DataTypes.JSONB, allowNull: false, defaultValue: [] }, pkceRequired: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, nonceRequired: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: true }, diff --git a/src/models/sessions.ts b/src/models/sessions.ts index fe3cb81e..91483b91 100644 --- a/src/models/sessions.ts +++ b/src/models/sessions.ts @@ -29,6 +29,9 @@ export interface SessionAttributes { authMethods?: SessionAuthMethod[]; authTime?: Date | null; replacedBySessionId?: string | null; + parentSessionId?: string | null; + exchangeClientId?: string | null; + exchangeGrantType?: string | null; revokedAt?: Date | null; revokedReason?: string | null; createdAt?: Date; @@ -47,6 +50,9 @@ type SessionCreationAttributes = Optional< | 'stepUpMethod' | 'authMethods' | 'authTime' + | 'parentSessionId' + | 'exchangeClientId' + | 'exchangeGrantType' >; export class Session @@ -72,6 +78,9 @@ export class Session declare authMethods: SessionAuthMethod[]; declare authTime: Date | null; declare replacedBySessionId: string | null; + declare parentSessionId: string | null; + declare exchangeClientId: string | null; + declare exchangeGrantType: string | null; declare revokedAt: Date | null; declare revokedReason: string | null; declare readonly createdAt: Date; @@ -157,6 +166,20 @@ const initializeSessionModel = (sequelize: Sequelize) => { type: DataTypes.UUID, allowNull: true, }, + // A session a token exchange issued (ADR 0003): the provider sign-in it ends with, the + // client that exchanged, and the grant behind the sign-in. Carried across rotation. + parentSessionId: { + type: DataTypes.UUID, + allowNull: true, + }, + exchangeClientId: { + type: DataTypes.STRING(255), + allowNull: true, + }, + exchangeGrantType: { + type: DataTypes.STRING(255), + allowNull: true, + }, revokedAt: DataTypes.DATE, revokedReason: DataTypes.STRING, }, diff --git a/src/routes/accountRecovery.routes.ts b/src/routes/accountRecovery.routes.ts index 8c706509..b428284f 100644 --- a/src/routes/accountRecovery.routes.ts +++ b/src/routes/accountRecovery.routes.ts @@ -21,6 +21,7 @@ import { } from '../controllers/decoyResponders.js'; import { createRouter } from '../lib/createRouter.js'; import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { AccountRecoveryCancelRequestSchema, AccountRecoveryRequestIdParamSchema, @@ -184,6 +185,7 @@ accountRecoveryRouter.get( accountRecoveryRouter.delete( '/requests/:id', { + middleware: [requireDeviceSessionStepUp], auth: 'access', summary: 'Cancel an account recovery request from a session', description: diff --git a/src/routes/oidcApplications.routes.ts b/src/routes/oidcApplications.routes.ts index 151e9443..94c09256 100644 --- a/src/routes/oidcApplications.routes.ts +++ b/src/routes/oidcApplications.routes.ts @@ -69,7 +69,7 @@ router.post( ...write, summary: 'Create an OpenID Provider application', description: - 'Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins.', + 'Public clients authenticate with PKCE (S256) alone. Confidential clients default to `private_key_jwt`. A shared-secret client needs `allow_client_secrets`, and its secret is returned here once. A third-party client needs `third_party_clients_enabled`, and only a first-party client may enable the device grant. `noticeActionOrigins` are bare `https` origins, never loopback, where the action link of an account notice may point, besides the `https` redirect origins. `sessionExchangeEnabled` is accepted only on a first-party public native client (ADR 0003).', schemas: { body: OidcApplicationCreateRequestSchema, response: { @@ -101,7 +101,7 @@ router.patch( ...write, summary: 'Update an OpenID Provider application', description: - 'Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens.', + 'Every create rule is checked again on the merged result. Moving off `private_key_jwt` drops the registered keys, and moving off a shared-secret method drops the secret. A disabled application keeps its settings but cannot sign anyone in or get tokens. Disabling an application, or turning `sessionExchangeEnabled` off, revokes every session it exchanged for.', schemas: { params: OidcApplicationIdParamsSchema, body: OidcApplicationUpdateRequestSchema, @@ -122,7 +122,7 @@ router.delete( ...write, summary: 'Delete an OpenID Provider application', description: - 'Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it.', + 'Also deletes its resource grants, its links in both directions, and every provider grant, code and token issued to it, and revokes every session it exchanged for.', schemas: { params: OidcApplicationIdParamsSchema, response: { 200: MessageSchema, 403: StepUpRequiredErrorSchema, 404: ErrorSchema }, diff --git a/src/routes/organization.routes.ts b/src/routes/organization.routes.ts index 1c5f9960..c2a3d00e 100644 --- a/src/routes/organization.routes.ts +++ b/src/routes/organization.routes.ts @@ -17,6 +17,7 @@ import { } from '../controllers/organizations.js'; import { createRouter } from '../lib/createRouter.js'; import { requireAdmin } from '../middleware/requireAdmin.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { requireStepUp } from '../middleware/requireStepUp.js'; import { InternalErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; import { @@ -41,6 +42,7 @@ const organizationRouter = createRouter('/organizations'); organizationRouter.get( '/', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'List organizations for the authenticated user', @@ -56,6 +58,7 @@ organizationRouter.get( organizationRouter.post( '/', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'Create organization', @@ -72,6 +75,7 @@ organizationRouter.post( organizationRouter.get( '/:organizationId', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'Get organization', @@ -89,6 +93,7 @@ organizationRouter.get( organizationRouter.patch( '/:organizationId', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'Update organization', @@ -126,6 +131,7 @@ organizationRouter.post( organizationRouter.get( '/:organizationId/members', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'List organization members', @@ -150,7 +156,7 @@ organizationRouter.post( 'Requires admin:write. Adding someone by email without their consent would let any ' + 'account holder learn which addresses have accounts and attach strangers to an ' + 'organization, so self-service adds wait for invitations.', - middleware: [requireAdmin('write'), requireStepUp()], + middleware: [requireDeviceSessionStepUp, requireAdmin('write'), requireStepUp()], schemas: { params: OrganizationIdParamSchema, body: AddOrganizationMemberRequestSchema, @@ -168,6 +174,7 @@ organizationRouter.post( organizationRouter.patch( '/:organizationId/members/:userId', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'Update organization member', @@ -187,6 +194,7 @@ organizationRouter.patch( organizationRouter.delete( '/:organizationId/members/:userId', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Organizations'], summary: 'Remove organization member', diff --git a/src/routes/recoveryCodes.routes.ts b/src/routes/recoveryCodes.routes.ts index 169b4ac6..4b018a9e 100644 --- a/src/routes/recoveryCodes.routes.ts +++ b/src/routes/recoveryCodes.routes.ts @@ -12,6 +12,7 @@ import { } from '../controllers/recoveryCodes.js'; import { createRouter } from '../lib/createRouter.js'; import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { ErrorSchema } from '../schemas/generic.responses.js'; import { RecoveryCodeRedeemRequestSchema, @@ -43,6 +44,7 @@ recoveryCodesRouter.get( recoveryCodesRouter.post( '/', { + middleware: [requireDeviceSessionStepUp], auth: 'access', allowRecoverySession: true, summary: 'Generate recovery codes', diff --git a/src/routes/registration.routes.ts b/src/routes/registration.routes.ts index 449778d0..071092fa 100644 --- a/src/routes/registration.routes.ts +++ b/src/routes/registration.routes.ts @@ -7,6 +7,7 @@ import { register, registerPhone, verifyRegisteredPhone } from '../controllers/registration.js'; import { createRouter } from '../lib/createRouter.js'; import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { ErrorSchema } from '../schemas/generic.responses.js'; import { VerifyOTPRequestSchema } from '../schemas/otp.requests.js'; import { OTPVerifyTokenSuccessSchema } from '../schemas/otp.responses.js'; @@ -50,7 +51,7 @@ registrationRouter.post( auth: 'access', summary: 'Register a phone number for the authenticated user', tags: ['Registration'], - middleware: [otpIpLimiter, otpIdentityLimiter], + middleware: [requireDeviceSessionStepUp, otpIpLimiter, otpIdentityLimiter], schemas: { body: RegisterPhoneRequestSchema, @@ -70,6 +71,7 @@ registrationRouter.post( registrationRouter.post( '/phone/verify', { + middleware: [requireDeviceSessionStepUp], auth: 'access', summary: 'Verify the authenticated user phone number', tags: ['Registration'], diff --git a/src/routes/session.routes.ts b/src/routes/session.routes.ts index 44fa5bf0..989c9f13 100644 --- a/src/routes/session.routes.ts +++ b/src/routes/session.routes.ts @@ -4,11 +4,22 @@ * See LICENSE file in the project root for full license information */ -import { listSessions, revokeAllSessions, revokeSession } from '../controllers/sessions.js'; +import { + exchangeSession, + listSessions, + revokeAllSessions, + revokeSession, +} from '../controllers/sessions.js'; import { createRouter } from '../lib/createRouter.js'; -import { ErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; +import { sessionExchangeLimiter } from '../middleware/rateLimit.js'; +import { ErrorSchema, MessageSchema, ValidationErrorSchema } from '../schemas/generic.responses.js'; import { SessionIdParamsSchema } from '../schemas/session.params.js'; -import { SessionListResponseSchema } from '../schemas/session.responses.js'; +import { + SessionExchangeErrorSchema, + SessionExchangeRequestSchema, + SessionExchangeResponseSchema, + SessionListResponseSchema, +} from '../schemas/session.responses.js'; const sessionsRouter = createRouter('/sessions'); @@ -29,6 +40,34 @@ sessionsRouter.get( listSessions, ); +sessionsRouter.post( + '/exchange', + { + adapter: false, + middleware: [sessionExchangeLimiter], + summary: 'Exchange a provider sign-in for a first-party session', + description: + 'For first-party public native clients signed in through the OpenID Provider (ADR 0003). Takes the provider access token for `/seamless` with the single `session:exchange` scope, once, and returns the same session `/refresh` returns, for a new session linked to the provider sign-in. Every subject token failure answers the same `400 invalid_request`. A second presentation adds `error_description: token_already_used` and revokes the session the first one created.', + tags: ['Sessions'], + + schemas: { + body: SessionExchangeRequestSchema, + + response: { + 200: SessionExchangeResponseSchema, + // Also what a request that fails schema validation answers with. + 400: SessionExchangeErrorSchema.extend( + ValidationErrorSchema.pick({ + message: true, + details: true, + }).shape, + ), + }, + }, + }, + exchangeSession, +); + sessionsRouter.delete( '/:id', { diff --git a/src/routes/totp.routes.ts b/src/routes/totp.routes.ts index 3b4602e2..36e86903 100644 --- a/src/routes/totp.routes.ts +++ b/src/routes/totp.routes.ts @@ -15,6 +15,7 @@ import { } from '../controllers/totp.js'; import { createRouter } from '../lib/createRouter.js'; import { otpIdentityLimiter, otpIpLimiter } from '../middleware/rateLimit.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { ErrorSchema, InternalErrorSchema, @@ -53,6 +54,7 @@ totpRouter.get( totpRouter.post( '/enroll/start', { + middleware: [requireDeviceSessionStepUp], auth: 'access', allowRecoverySession: true, summary: 'Start TOTP enrollment', @@ -73,7 +75,7 @@ totpRouter.post( totpRouter.post( '/enroll/verify', { - middleware: [otpIpLimiter, otpIdentityLimiter], + middleware: [requireDeviceSessionStepUp, otpIpLimiter, otpIdentityLimiter], auth: 'access', allowRecoverySession: true, summary: 'Verify TOTP enrollment', @@ -94,7 +96,7 @@ totpRouter.post( totpRouter.post( '/disable', { - middleware: [otpIpLimiter, otpIdentityLimiter], + middleware: [requireDeviceSessionStepUp, otpIpLimiter, otpIdentityLimiter], auth: 'access', summary: 'Disable TOTP for the current user', tags: ['TOTP'], diff --git a/src/routes/users.routes.ts b/src/routes/users.routes.ts index 657e88cb..dfdd6edb 100644 --- a/src/routes/users.routes.ts +++ b/src/routes/users.routes.ts @@ -8,6 +8,7 @@ import { DeleteCredentialRequestSchema, UpdateCredentialRequestSchema } from '@s import { deleteCredential, deleteUser, getUser, updateCredential } from '../controllers/user.js'; import { createRouter } from '../lib/createRouter.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { requireStepUp } from '../middleware/requireStepUp.js'; import { CredentialUpdateResponseSchema } from '../schemas/credential.responses.js'; import { ErrorSchema, MessageSchema } from '../schemas/generic.responses.js'; @@ -77,6 +78,7 @@ usersRouter.delete( usersRouter.delete( '/credentials', { + middleware: [requireDeviceSessionStepUp], auth: 'access', tags: ['Users'], summary: 'Delete credential', diff --git a/src/routes/webauthn.routes.ts b/src/routes/webauthn.routes.ts index b38ff7e5..b7149cc4 100644 --- a/src/routes/webauthn.routes.ts +++ b/src/routes/webauthn.routes.ts @@ -15,6 +15,7 @@ import { verifyWebAuthnRegistration, } from '../controllers/webauthn.js'; import { createRouter } from '../lib/createRouter.js'; +import { requireDeviceSessionStepUp } from '../middleware/requireDeviceSessionStepUp.js'; import { CredentialUpdateResponseSchema } from '../schemas/credential.responses.js'; import { ErrorSchema, InternalErrorSchema } from '../schemas/generic.responses.js'; import { @@ -39,6 +40,7 @@ const webauthnRouter = createRouter('/webauthn'); webauthnRouter.get( '/register/start', { + middleware: [requireDeviceSessionStepUp], auth: 'access', allowRecoverySession: true, summary: 'Start WebAuthn registration', @@ -62,6 +64,7 @@ webauthnRouter.get( webauthnRouter.post( '/register/finish', { + middleware: [requireDeviceSessionStepUp], auth: 'access', allowRecoverySession: true, summary: 'Finish WebAuthn registration', diff --git a/src/schemas/authEvent.types.ts b/src/schemas/authEvent.types.ts index 7c072667..99ad5227 100644 --- a/src/schemas/authEvent.types.ts +++ b/src/schemas/authEvent.types.ts @@ -28,6 +28,7 @@ export const AUTH_EVENT_TYPES = [ 'admin_oidc_application_pkce_updated', 'admin_oidc_application_resources_updated', 'admin_oidc_application_secret_rotated', + 'admin_oidc_application_session_exchange_updated', 'admin_oidc_application_updated', 'admin_oidc_resource_created', 'admin_oidc_resource_deleted', @@ -99,6 +100,8 @@ export const AUTH_EVENT_TYPES = [ 'service_token_rotated', 'service_token_success', 'session_evicted', + 'session_exchange_replayed', + 'session_exchanged', 'step_up_challenge', 'step_up_failed', 'step_up_success', diff --git a/src/schemas/session.responses.ts b/src/schemas/session.responses.ts index 89f34ead..829e8659 100644 --- a/src/schemas/session.responses.ts +++ b/src/schemas/session.responses.ts @@ -4,4 +4,9 @@ * See LICENSE file in the project root for full license information */ -export { SessionListResponseSchema } from '@seamless-auth/types'; +export { + SessionExchangeErrorSchema, + SessionExchangeRequestSchema, + SessionExchangeResponseSchema, + SessionListResponseSchema, +} from '@seamless-auth/types'; diff --git a/src/services/apiResponseSerializers.ts b/src/services/apiResponseSerializers.ts index 94afbbdd..aa1312f7 100644 --- a/src/services/apiResponseSerializers.ts +++ b/src/services/apiResponseSerializers.ts @@ -159,5 +159,8 @@ export function serializeSession(session: unknown, currentSessionId?: string | n lastUsedAt: dateField(session, 'lastUsedAt'), expiresAt: dateField(session, 'expiresAt'), current: currentSessionId ? id === currentSessionId : false, + parentSessionId: nullableStringField(session, 'parentSessionId') ?? undefined, + exchangeClientId: nullableStringField(session, 'exchangeClientId') ?? undefined, + exchangeGrantType: nullableStringField(session, 'exchangeGrantType') ?? undefined, }); } diff --git a/src/services/concurrentSessionPolicy.ts b/src/services/concurrentSessionPolicy.ts index 6748ccb5..15e02eda 100644 --- a/src/services/concurrentSessionPolicy.ts +++ b/src/services/concurrentSessionPolicy.ts @@ -10,6 +10,7 @@ import { Op } from 'sequelize'; import { Session } from '../models/sessions.js'; import getLogger from '../utils/logger.js'; import { AuthEventService } from './authEventService.js'; +import { revokeOrphanedChildSessions } from './sessionService.js'; const logger = getLogger('concurrentSessionPolicy'); @@ -32,6 +33,10 @@ export const EVICTION_REASON = 'concurrent_session_limit'; * leaves users above it, and they should converge on the next sign-in rather * than one session per login forever. * + * An exchanged session does not count and is never evicted here. It ends with its parent, so + * the parent is what the limit counts, and evicting the parent to make room for its own child + * would end both. + * * Never throws. A session that could not be revoked is logged and the sign-in * continues, because failing an authentication over a housekeeping step is worse * than briefly exceeding the cap. @@ -56,6 +61,7 @@ export async function enforceConcurrentSessionLimit(params: { userId, revokedAt: null, replacedBySessionId: null, + parentSessionId: null, expiresAt: { [Op.gt]: now }, }, order: [['createdAt', 'ASC']], @@ -73,6 +79,7 @@ export async function enforceConcurrentSessionLimit(params: { for (const session of evicted) { await session.update({ revokedAt: now, revokedReason: EVICTION_REASON }); + await revokeOrphanedChildSessions(session); await AuthEventService.log({ userId, diff --git a/src/services/oidcApplicationService.ts b/src/services/oidcApplicationService.ts index c4bb608a..8641fe19 100644 --- a/src/services/oidcApplicationService.ts +++ b/src/services/oidcApplicationService.ts @@ -26,6 +26,7 @@ import type { } from '../schemas/oidcApplications.requests.js'; import type { OidcApplicationView } from '../schemas/oidcApplications.responses.js'; import { createOidcClientSecretLookup, isNativeRedirectUri } from './oidcClientRegistry.js'; +import { revokeSessionsExchangedBy } from './sessionRevocation.js'; export const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; @@ -66,6 +67,7 @@ export function serializeOidcApplication(client: OidcClient): OidcApplication { ), deviceFlowEnabled: client.deviceFlowEnabled, noticesEnabled: client.noticesEnabled, + sessionExchangeEnabled: client.sessionExchangeEnabled, pkceRequired: client.pkceRequired, nonceRequired: client.nonceRequired, jwks: (client.jwks as OidcApplication['jwks']) ?? null, @@ -185,6 +187,7 @@ export async function createOidcApplication(input: OidcApplicationCreateRequest) refreshTokenTtlSeconds: input.refreshTokenTtlSeconds ?? null, deviceFlowEnabled: input.deviceFlowEnabled, noticesEnabled: input.noticesEnabled, + sessionExchangeEnabled: input.sessionExchangeEnabled, noticeActionOrigins: input.noticeActionOrigins, pkceRequired: input.pkceRequired, nonceRequired: input.nonceRequired, @@ -218,6 +221,7 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp grantTypes: current.grantTypes, deviceFlowEnabled: current.deviceFlowEnabled, noticesEnabled: current.noticesEnabled, + sessionExchangeEnabled: current.sessionExchangeEnabled, pkceRequired: current.pkceRequired, nonceRequired: current.nonceRequired, backchannelLogoutUri: current.backchannelLogoutUri, @@ -233,6 +237,8 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp if (!parsed.success) throw new OidcApplicationInvalidError(parsed.error); const next = parsed.data; + const wasExchanging = client.sessionExchangeEnabled; + const wasDisabled = Boolean(client.disabledAt); await client.update({ name: next.name, @@ -243,6 +249,7 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp grantTypes: storedGrantTypes(next.grantTypes, next.deviceFlowEnabled), deviceFlowEnabled: next.deviceFlowEnabled, noticesEnabled: next.noticesEnabled, + sessionExchangeEnabled: next.sessionExchangeEnabled, pkceRequired: next.pkceRequired, nonceRequired: next.nonceRequired, backchannelLogoutUri: next.backchannelLogoutUri ?? null, @@ -257,6 +264,14 @@ export async function updateOidcApplication(id: string, patch: OidcApplicationUp : { disabledAt: disabled ? (client.disabledAt ?? new Date()) : null }), }); + // After the write, so an exchange that read the client before it is one the exchange's own + // re-read or this revoke catches, whichever comes last (ADR 0003). + if (!wasDisabled && client.disabledAt) { + await revokeSessionsExchangedBy(id, 'oidc_client_disabled'); + } else if (wasExchanging && !client.sessionExchangeEnabled) { + await revokeSessionsExchangedBy(id, 'session_exchange_disabled'); + } + return client; } @@ -294,14 +309,15 @@ export async function rotateOidcApplicationSecret(id: string) { } /** - * Deletes the application with its resource grants, its links in both directions and - * every provider record issued to it (grants, codes and tokens), so nothing it was given - * outlives it. + * Deletes the application with its resource grants, its links in both directions, every + * provider record issued to it (grants, codes and tokens) and the sessions it exchanged for, + * so nothing it was given outlives it. */ export async function deleteOidcApplication(id: string) { const client = await findOidcApplication(id); await OidcClient.sequelize!.transaction(async (transaction) => { + await revokeSessionsExchangedBy(id, 'oidc_client_deleted', transaction); await OidcPayload.destroy({ where: { payload: { clientId: id } }, transaction }); await OidcClientResource.destroy({ where: { clientId: id }, transaction }); await OidcClientLink.destroy({ diff --git a/src/services/oidcClientRegistry.ts b/src/services/oidcClientRegistry.ts index e9f3db29..1d7968ff 100644 --- a/src/services/oidcClientRegistry.ts +++ b/src/services/oidcClientRegistry.ts @@ -11,6 +11,7 @@ import { type OidcPublicJwk, oidcRedirectUriKind, seamlessBuiltInResource, + SESSION_EXCHANGE_SCOPE, } from '@seamless-auth/types'; import { createHmac, timingSafeEqual } from 'crypto'; import { Op } from 'sequelize'; @@ -26,6 +27,7 @@ import { getDefaultOrganizationIdForUser } from './organizationService.js'; const SHARED_SECRET_METHODS = new Set(['client_secret_basic', 'client_secret_post']); const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; +const SESSION_EXCHANGE_TOKEN_TTL_SECONDS = 60; /** * Keyed with the refresh token lookup secret, but over a prefixed input, so a client @@ -57,6 +59,20 @@ export function isNativeRedirectUri(uri: string) { return kind === 'loopback' || kind === 'private_use'; } +/** + * ADR 0003, X2. Checked on the client as stored rather than trusting the flag alone, so a + * row that reached this state some other way still cannot exchange. + */ +export function isSessionExchangeClient(client: OidcClient) { + return ( + client.sessionExchangeEnabled && + client.firstParty && + client.clientType === 'public' && + client.tokenEndpointAuthMethod === 'none' && + client.redirectUris.some(isNativeRedirectUri) + ); +} + export async function getOidcClientMetadata(id: string): Promise { const client = await findActiveOidcClient(id); @@ -128,19 +144,30 @@ export async function verifyOidcClientSecret(id: string, secret: string) { * The resources a client may ask an access token for, each with the scopes it is allowed * there. A link may only narrow what the resource defines, whatever was stored on it. The * built-in `/seamless` resource is never stored: an admin turning notices on - * for the client is what links it (ADR 0001, D14). + * for the client (ADR 0001, D14) or session exchange (ADR 0003, X2) is what links it. */ export async function getOidcClientResources(clientId: string) { const client = await findActiveOidcClient(clientId); const { oidc_issuer } = await getSystemConfig(); const builtIn = oidc_issuer ? seamlessBuiltInResource(oidc_issuer) : null; + const exchange = client ? isSessionExchangeClient(client) : false; + const builtInScopes = [ + ...(client?.noticesEnabled ? [APPLICATION_NOTICE_SCOPE] : []), + ...(exchange ? [SESSION_EXCHANGE_SCOPE] : []), + ]; const resources: { identifier: string; scopes: string[]; accessTokenTtlSeconds: number | null; }[] = - builtIn && client?.noticesEnabled - ? [{ identifier: builtIn, scopes: [APPLICATION_NOTICE_SCOPE], accessTokenTtlSeconds: null }] + builtIn && builtInScopes.length > 0 + ? [ + { + identifier: builtIn, + scopes: builtInScopes, + accessTokenTtlSeconds: exchange ? SESSION_EXCHANGE_TOKEN_TTL_SECONDS : null, + }, + ] : []; const links = await OidcClientResource.findAll({ where: { clientId } }); diff --git a/src/services/oidcPayloadStore.ts b/src/services/oidcPayloadStore.ts index dfc0ea07..45be3f48 100644 --- a/src/services/oidcPayloadStore.ts +++ b/src/services/oidcPayloadStore.ts @@ -9,7 +9,7 @@ import type { OidcPayloadRecord, OidcPayloadUpsertRequest, } from '@seamless-auth/types'; -import { Op, QueryTypes, WhereOptions } from 'sequelize'; +import { Op, QueryTypes, UniqueConstraintError, WhereOptions } from 'sequelize'; import { OidcPayload, OidcPayloadAttributes } from '../models/oidcPayloads.js'; import getLogger from '../utils/logger.js'; @@ -186,6 +186,43 @@ export async function bindOidcSession(sessionId: string, userId: string, clientI }); } +// ADR 0003, X3. Under a model the edge's payload routes cannot name, for the same reason as +// the binding: the edge must not be able to read, write or consume these. +export const SESSION_EXCHANGE_TOKEN = 'SessionExchangeToken'; + +export interface SessionExchangeTokenPayload extends Record { + clientId: string; + accountId: string; + grantType: string; + /** Set when the token is exchanged, so a replay can revoke what the first use created. */ + childSessionId?: string; +} + +/** + * Written when `/oidc/sign` signs a `session:exchange` token. Only ever inserted: a second + * signature over the same `jti` is refused rather than resetting a record already consumed. + */ +export async function recordSessionExchangeToken( + jti: string, + sessionId: string, + payload: SessionExchangeTokenPayload, + expiresAt: Date, +): Promise { + try { + await OidcPayload.create({ + id: jti, + model: SESSION_EXCHANGE_TOKEN, + payload, + sessionId, + expiresAt, + }); + return true; + } catch (error) { + if (error instanceof UniqueConstraintError) return false; + throw error; + } +} + function findBinding(where: Record) { return OidcPayload.findOne({ where: { diff --git a/src/services/oidcSigning.ts b/src/services/oidcSigning.ts index 0ce3b588..3bf6989d 100644 --- a/src/services/oidcSigning.ts +++ b/src/services/oidcSigning.ts @@ -4,7 +4,12 @@ * See LICENSE file in the project root for full license information */ -import type { OidcSignRefusalCode, OidcSignRequest } from '@seamless-auth/types'; +import { + type OidcSignRefusalCode, + type OidcSignRequest, + seamlessBuiltInResource, + SESSION_EXCHANGE_SCOPE, +} from '@seamless-auth/types'; import { constants, sign } from 'crypto'; import { getSystemConfig } from '../config/getSystemConfig.js'; @@ -16,7 +21,7 @@ import { getOidcAccountClaims, getOidcClientResourceScopes, } from './oidcClientRegistry.js'; -import { hasOidcGrantForSession } from './oidcPayloadStore.js'; +import { hasOidcGrantForSession, recordSessionExchangeToken } from './oidcPayloadStore.js'; import { getActiveOidcSigningKey, OidcProviderKeysError } from './oidcProviderKeys.js'; type JsonObject = Record; @@ -35,6 +40,12 @@ const MAX_TOKEN_LIFETIME_SECONDS = 60 * 60 * 24; const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; +// ADR 0003, X2. The edge sets 60 seconds, and this is the most the API will sign. +const MAX_SESSION_EXCHANGE_TOKEN_SECONDS = 120; +// A token the user just signed in for. A refresh is not a sign-in, and the record has to +// say which grant to treat the child as. +const SESSION_EXCHANGE_GRANTS = new Set(['authorization_code', DEVICE_CODE_GRANT]); + const PROTOCOL_CLAIMS = [ 'iss', 'sub', @@ -78,6 +89,10 @@ const IDENTITY_CLAIMS = [ 'org_id', ] as const; +function isSessionExchangeToken(request: OidcSignRequest, claims: JsonObject) { + return request.kind === 'access_token' && scopesOf(claims).includes(SESSION_EXCHANGE_SCOPE); +} + function refuse(code: OidcSignRefusalCode, userId?: string): OidcSignOutcome { return { ok: false, code, ...(userId ? { userId } : {}) }; } @@ -199,6 +214,7 @@ export async function signForOidcEdge(request: OidcSignRequest): Promise MAX_SESSION_EXCHANGE_TOKEN_SECONDS || + (claims.exp as number) - now > MAX_SESSION_EXCHANGE_TOKEN_SECONDS + ) { + return refuse('malformed_signing_input', userId); + } + + return null; } diff --git a/src/services/sessionExchangeService.ts b/src/services/sessionExchangeService.ts new file mode 100644 index 00000000..54f58c72 --- /dev/null +++ b/src/services/sessionExchangeService.ts @@ -0,0 +1,302 @@ +/* + * Copyright © 2026 Fells Code, LLC + * Licensed under the Apache License, Version 2.0 + * See LICENSE file in the project root for full license information + */ + +import { + seamlessBuiltInResource, + SESSION_EXCHANGE_ISSUED_TOKEN_TYPE, + SESSION_EXCHANGE_SCOPE, + type SessionExchangeResponse, +} from '@seamless-auth/types'; +import { Request } from 'express'; +import { createLocalJWKSet, type JSONWebKeySet, type JWTPayload, jwtVerify } from 'jose'; +import { Op, WhereOptions } from 'sequelize'; + +import { getSystemConfig } from '../config/getSystemConfig.js'; +import { createRefreshTokenLookup, generateRefreshToken, signAccessToken } from '../lib/token.js'; +import { OidcPayload, OidcPayloadAttributes } from '../models/oidcPayloads.js'; +import { Session } from '../models/sessions.js'; +import { User } from '../models/users.js'; +import getLogger from '../utils/logger.js'; +import { computeSessionTimes, parseDurationToSeconds } from '../utils/utils.js'; +import { AuthEventService } from './authEventService.js'; +import { resolveCurrentSession } from './oidcAuthContext.js'; +import { findActiveOidcClient, isSessionExchangeClient } from './oidcClientRegistry.js'; +import { SESSION_EXCHANGE_TOKEN, SessionExchangeTokenPayload } from './oidcPayloadStore.js'; +import { getOidcPublicJwks } from './oidcProviderKeys.js'; +import { revokeSessionChain } from './sessionService.js'; + +const logger = getLogger('sessionExchange'); + +const AUTHORIZATION_CODE_GRANT = 'authorization_code'; +const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; + +export type SessionExchangeOutcome = + { ok: true; response: SessionExchangeResponse } | { ok: false; replayed: boolean }; + +const REFUSED: SessionExchangeOutcome = { ok: false, replayed: false }; + +type SubjectClaims = JWTPayload & { sub: string; jti: string; client_id: string }; + +function refuse(reason: string): SessionExchangeOutcome { + logger.warn(`Session exchange refused: ${reason}`); + return REFUSED; +} + +/** ADR 0003, X4 to X7: only a token the API signed for this one purpose gets past here. */ +async function verifySubjectToken(token: string): Promise { + const { oidc_issuer } = await getSystemConfig(); + if (!oidc_issuer) return null; + + const audience = seamlessBuiltInResource(oidc_issuer); + + let claims: JWTPayload; + try { + const jwks = createLocalJWKSet({ keys: getOidcPublicJwks().keys } as JSONWebKeySet); + ({ payload: claims } = await jwtVerify(token, jwks, { + issuer: oidc_issuer, + audience, + algorithms: ['RS256'], + typ: 'at+jwt', + requiredClaims: ['exp', 'iat', 'sub', 'jti', 'client_id', 'scope'], + })); + } catch (error) { + logger.debug(`Subject token refused: ${(error as Error).message}`); + return null; + } + + const clientId: unknown = claims.client_id; + if ( + claims.aud !== audience || + claims.scope !== SESSION_EXCHANGE_SCOPE || + typeof clientId !== 'string' || + claims.sub === clientId + ) { + return null; + } + + return claims as SubjectClaims; +} + +function findRecord(jti: string) { + return OidcPayload.findOne({ where: { model: SESSION_EXCHANGE_TOKEN, id: jti } }); +} + +/** Whatever the first presentation created goes, wherever its refresh chain has reached. */ +async function handleReplay( + claims: SubjectClaims, + record: OidcPayload, + req: Request, +): Promise { + const { childSessionId } = record.payload as SessionExchangeTokenPayload; + const child = childSessionId ? await Session.findByPk(childSessionId) : null; + + if (child) { + await revokeSessionChain(child, 'session_exchange_replayed'); + } + + await AuthEventService.log({ + userId: claims.sub, + sessionId: child?.id ?? null, + type: 'session_exchange_replayed', + req, + metadata: { + clientId: claims.client_id, + parentSessionId: child?.parentSessionId ?? record.sessionId, + }, + }); + + return { ok: false, replayed: true }; +} + +function issuedChildTimes(parent: Session, ttls: { absoluteTtl: string; idleTtl: string }) { + const { expiresAt, idleExpiresAt } = computeSessionTimes(ttls); + const cap = Math.min(expiresAt.getTime(), new Date(parent.expiresAt).getTime()); + + return { + expiresAt: new Date(cap), + idleExpiresAt: new Date(Math.min(idleExpiresAt.getTime(), cap)), + }; +} + +/** + * ADR 0003, X5. The passkey behind a device grant proved who approved, not who holds the + * token, so that child starts with nothing (ADR 0001, D10). + */ +function inheritedAssurance(parent: Session, grantType: string) { + if (grantType === DEVICE_CODE_GRANT) { + return { authMethods: [], authTime: null, stepUpVerifiedAt: null, stepUpMethod: null }; + } + + return { + authMethods: parent.authMethods ?? [], + authTime: parent.authTime ?? parent.chainStartedAt ?? parent.createdAt ?? null, + stepUpVerifiedAt: parent.stepUpVerifiedAt ?? null, + stepUpMethod: parent.stepUpMethod ?? null, + }; +} + +/** + * Consumes the record and creates the child in one transaction. Concurrent presentations + * queue on the record's row, so the losers read a `childSessionId` the winner has committed + * and can revoke it, rather than finding the record consumed with nothing to revoke yet. + */ +async function consumeAndIssue( + record: OidcPayload, + parent: Session, + user: User, + clientId: string, + grantType: string, + req: Request, +) { + const { access_token_ttl, refresh_token_ttl, session_idle_ttl } = await getSystemConfig(); + const now = new Date(); + const { expiresAt, idleExpiresAt } = issuedChildTimes(parent, { + absoluteTtl: refresh_token_ttl || '1d', + idleTtl: session_idle_ttl || '8h', + }); + const refreshToken = generateRefreshToken(); + + const issued = await OidcPayload.sequelize!.transaction(async (transaction) => { + const [consumed] = await OidcPayload.update( + { consumedAt: now }, + { + where: { + model: SESSION_EXCHANGE_TOKEN, + id: record.id, + consumedAt: { [Op.is]: null }, + expiresAt: { [Op.gt]: now }, + } as WhereOptions, + transaction, + }, + ); + if (consumed === 0) return null; + + const child = await Session.create( + { + userId: user.id, + infraId: parent.infraId, + organizationId: parent.organizationId, + mode: 'server', + refreshTokenLookup: createRefreshTokenLookup(refreshToken), + userAgent: req.get('user-agent'), + ipAddress: req.ip, + expiresAt, + idleExpiresAt, + chainStartedAt: now, + lastUsedAt: now, + parentSessionId: parent.id, + exchangeClientId: clientId, + exchangeGrantType: grantType, + ...inheritedAssurance(parent, grantType), + }, + { transaction }, + ); + + await OidcPayload.update( + { payload: { ...record.payload, childSessionId: child.id } }, + { + where: { + model: SESSION_EXCHANGE_TOKEN, + id: record.id, + } as WhereOptions, + transaction, + }, + ); + + // Inside, so a signing failure hands the token back rather than spending it. + const token = await signAccessToken(child.id, user.id, user.roles, parent.organizationId); + + return { child, token }; + }); + + if (!issued) return null; + + return { + ...issued, + response: { + message: 'Success', + token: issued.token, + refreshToken, + sub: user.id, + sessionId: issued.child.id, + organizationId: parent.organizationId, + roles: user.roles, + email: user.email, + phone: user.phone, + ttl: parseDurationToSeconds(access_token_ttl || '15m'), + refreshTtl: Math.floor((expiresAt.getTime() - now.getTime()) / 1000), + issued_token_type: SESSION_EXCHANGE_ISSUED_TOKEN_TYPE, + } as SessionExchangeResponse, + }; +} + +/** + * `POST /sessions/exchange` (ADR 0003). The one place a provider token is accepted, and only + * to mint a first-party session (ADR 0001, D11). Every token failure answers the same, so + * the route cannot be used to learn about sessions; the reason goes to the log. + */ +export async function exchangeSubjectToken( + subjectToken: string, + req: Request, +): Promise { + const claims = await verifySubjectToken(subjectToken); + if (!claims) return refuse('token did not verify'); + + const record = await findRecord(claims.jti); + if (!record) return refuse('no signing record'); + + const payload = record.payload as SessionExchangeTokenPayload; + if (payload.accountId !== claims.sub || payload.clientId !== claims.client_id) { + return refuse('signing record names another user or client'); + } + + if (record.consumedAt) return handleReplay(claims, record, req); + + if (record.expiresAt <= new Date()) return refuse('signing record expired'); + + const { grantType } = payload; + if (grantType !== AUTHORIZATION_CODE_GRANT && grantType !== DEVICE_CODE_GRANT) { + return refuse('signing record names a grant that cannot exchange'); + } + + const client = await findActiveOidcClient(claims.client_id); + if (!client || !isSessionExchangeClient(client)) return refuse('client cannot exchange'); + + const parent = record.sessionId ? await resolveCurrentSession(record.sessionId) : null; + if (!parent || parent.userId !== claims.sub) return refuse('parent session is not live'); + if (parent.recovery) return refuse('parent is a recovery session'); + if (parent.parentSessionId) return refuse('parent is an exchanged session'); + + const user = await User.findOne({ where: { id: parent.userId, revoked: false } }); + if (!user) return refuse('user is not active'); + + const issued = await consumeAndIssue(record, parent, user, client.id, grantType, req); + + if (!issued) { + const latest = await findRecord(claims.jti); + return latest?.consumedAt + ? handleReplay(claims, latest, req) + : refuse('signing record expired'); + } + + // An admin turning the client off revokes its sessions after writing the flag, so reading + // the flag after the child is committed means one of the two always sees the other. + const current = await findActiveOidcClient(client.id); + if (!current || !isSessionExchangeClient(current)) { + await revokeSessionChain(issued.child, 'session_exchange_disabled'); + return refuse('client stopped being able to exchange'); + } + + await AuthEventService.log({ + userId: user.id, + sessionId: issued.child.id, + type: 'session_exchanged', + req, + metadata: { clientId: client.id, grantType, parentSessionId: parent.id }, + }); + + return { ok: true, response: issued.response }; +} diff --git a/src/services/sessionRevocation.ts b/src/services/sessionRevocation.ts index 5762d60c..262483ea 100644 --- a/src/services/sessionRevocation.ts +++ b/src/services/sessionRevocation.ts @@ -9,6 +9,9 @@ import type { Transaction } from 'sequelize'; import { Session } from '../models/sessions.js'; import { User } from '../models/users.js'; +/** What a session exchanged from another records when that parent ends (ADR 0003, X4). */ +export const PARENT_SESSION_ENDED = 'parent_session_ended'; + /** * Ends every session the given users hold, including one a refresh is creating right now. * @@ -53,3 +56,23 @@ export function isRevokedByCutoff( return new Date(session.createdAt).getTime() <= new Date(sessionsRevokedBefore).getTime(); } + +/** + * Ends every session a client exchanged for, including superseded rows (ADR 0003). Used when + * exchange is turned off for the client, or the client is disabled or deleted. + */ +export async function revokeSessionsExchangedBy( + clientId: string, + reason: string, + transaction?: Transaction, +): Promise { + const [revoked] = await Session.update( + { revokedAt: new Date(), revokedReason: reason }, + { + where: { exchangeClientId: clientId, revokedAt: null }, + ...(transaction ? { transaction } : {}), + }, + ); + + return revoked; +} diff --git a/src/services/sessionService.ts b/src/services/sessionService.ts index fed61b34..3375c7ce 100644 --- a/src/services/sessionService.ts +++ b/src/services/sessionService.ts @@ -14,13 +14,15 @@ import { User } from '../models/users.js'; import getLogger from '../utils/logger.js'; import { getPublicKeyByKid } from '../utils/signingKeyStore.js'; import { decoyPrincipalAsUser, decoyPrincipalForSubject } from './decoyPrincipal.js'; -import { isRevokedByCutoff } from './sessionRevocation.js'; +import { resolveCurrentSession } from './oidcAuthContext.js'; +import { isRevokedByCutoff, PARENT_SESSION_ENDED } from './sessionRevocation.js'; const logger = getLogger('sessionService'); export type AuthTokenType = 'ephemeral' | 'access'; const ISSUER = process.env.ISSUER!; +const DEVICE_CODE_GRANT = 'urn:ietf:params:oauth:grant-type:device_code'; export interface ValidatedAccessToken { userId: string; @@ -105,12 +107,52 @@ export async function revokeSessionChain(session: Session, reason = 'refresh_tok if (!current.replacedBySessionId) break; current = await Session.findByPk(current.replacedBySessionId); } + + await revokeOrphanedChildSessions(session); } export async function hardRevokeSession(session: Session, reason = 'manual_revoke') { session.revokedAt = new Date(); session.revokedReason = reason; await session.save(); + + await revokeOrphanedChildSessions(session); +} + +/** + * The cascade that follows a session ending, for a user who holds exchanged sessions (ADR + * 0003, X4). A child records the row its parent was on when it was exchanged, and the parent + * rotates on from there, so which children just lost their parent is answered the same way + * validation answers it: by following each child's link forward. This keeps session lists + * accurate. It is not what ends a child, which is the check in `validateSessionRecord`, so a + * failure here is logged rather than failing the revoke that caused it. + */ +export async function revokeOrphanedChildSessions( + session: Pick, +) { + if (session.parentSessionId) return; + + try { + const children = await Session.findAll({ + where: { + userId: session.userId, + parentSessionId: { [Op.ne]: null }, + revokedAt: null, + replacedBySessionId: null, + }, + }); + + for (const child of children) { + if (await resolveCurrentSession(child.parentSessionId as string)) continue; + + await Session.update( + { revokedAt: new Date(), revokedReason: PARENT_SESSION_ENDED }, + { where: { id: child.id, revokedAt: null } }, + ); + } + } catch (error) { + logger.error(`Could not revoke the child sessions of a session that ended: ${error}`); + } } /** @@ -255,6 +297,12 @@ export async function validateSessionRecord(sessionId: string) { }); if (!owner || isRevokedByCutoff(session, owner.sessionsRevokedBefore)) return null; + // An exchanged session ends with its parent whether or not a revoke's cascade reached it, + // and the parent also ends without any revoke when it expires or idles out (ADR 0003, X4). + if (session.parentSessionId && !(await resolveCurrentSession(session.parentSessionId))) { + return null; + } + return session; } @@ -275,6 +323,8 @@ export interface ValidatedBearerToken { attemptId?: string; /** An access session a recovery issued, restricted until a new factor is enrolled. */ recovery?: boolean; + /** The step-up state of a session exchanged from a device grant (ADR 0003, X7). */ + deviceChildSession?: Pick; } export async function validateEphemeralToken(token: string): Promise { @@ -344,6 +394,14 @@ export async function validateBearerToken( // Read from the session rather than the token: enrolling a factor clears it on // the row, which takes effect at once, before the client next refreshes. recovery: session.recovery === true, + ...(session.exchangeGrantType === DEVICE_CODE_GRANT + ? { + deviceChildSession: { + stepUpVerifiedAt: session.stepUpVerifiedAt, + stepUpMethod: session.stepUpMethod, + }, + } + : {}), } : null; } diff --git a/src/types/types.ts b/src/types/types.ts index 555c6ce6..a6fc5f6b 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -35,6 +35,8 @@ export interface AuthenticatedRequest extends RouteRequest { attemptId?: string; /** Set on an access session a recovery issued; see `allowRecoverySession`. */ recoverySession?: boolean; + /** The step-up state of a session exchanged from a device grant; see `requireAdmin`. */ + deviceChildSession?: Pick; organizationId?: string | null; clientId?: string; trustedClientIp?: string; diff --git a/tests/integration/admin/adminStepUp.spec.ts b/tests/integration/admin/adminStepUp.spec.ts index 60f62249..987e3da5 100644 --- a/tests/integration/admin/adminStepUp.spec.ts +++ b/tests/integration/admin/adminStepUp.spec.ts @@ -82,7 +82,7 @@ beforeEach(() => { }); describe('admin writes require a fresh step-up', () => { - it('documents the step-up 403 on exactly the guarded routes', async () => { + it('documents the step-up 403 on the guarded routes and the device session gate', async () => { await loadRoutes(express()); const documented = new Set(); @@ -98,7 +98,17 @@ describe('admin writes require a fresh step-up', () => { } } - expect([...documented].sort()).toEqual([...GUARDED].sort()); + for (const route of [ + ...GUARDED, + 'GET /admin/users', + 'POST /totp/enroll/start', + 'POST /recovery-codes/', + ]) { + expect(documented).toContain(route); + } + for (const route of ['GET /users/me', 'GET /sessions', 'POST /totp/verify-mfa']) { + expect(documented).not.toContain(route); + } }); it.each(GUARDED)('%s refuses a session that never stepped up', async (route) => { diff --git a/tests/integration/authentication/authentication.spec.ts b/tests/integration/authentication/authentication.spec.ts index 8cf6420c..91dd5593 100644 --- a/tests/integration/authentication/authentication.spec.ts +++ b/tests/integration/authentication/authentication.spec.ts @@ -24,6 +24,8 @@ import { AuthEvent } from '../../../src/models/authEvents'; import { AuthFailure } from '../../../src/models/authFailures'; import { AuthEventService } from '../../../src/services/authEventService'; import { logoutCurrentSession } from '../../../src/controllers/authentication'; +import { resolveCurrentSession } from '../../../src/services/oidcAuthContext'; +import { recordProviderActivity } from '../../../src/services/oidcSigning'; let app: Application; @@ -44,6 +46,16 @@ function buildReq(overrides: Record = {}) { } as any; } +vi.mock('../../../src/services/oidcAuthContext.js', async (importOriginal) => ({ + ...(await importOriginal()), + resolveCurrentSession: vi.fn(), +})); + +vi.mock('../../../src/services/oidcSigning.js', async (importOriginal) => ({ + ...(await importOriginal()), + recordProviderActivity: vi.fn(), +})); + vi.mock('../../../src/middleware/attachAuthMiddleware.js', async (importOriginal) => { const actual = await importOriginal(); @@ -684,6 +696,86 @@ describe('POST /refresh', () => { expect(res.body.refreshToken).toBe('refresh'); }); + describe('of an exchanged session', () => { + const child = () => ({ + id: 'child-1', + replacedBySessionId: null, + revokedAt: null, + userId: 'user-1', + infraId: 'app', + mode: 'server', + parentSessionId: 'parent-1', + exchangeClientId: 'cli', + exchangeGrantType: 'authorization_code', + chainStartedAt: new Date(), + save: vi.fn(), + }); + + beforeEach(() => { + (User.findOne as any).mockResolvedValue(buildUser()); + (Session.create as any).mockResolvedValue({ id: 'new-session' }); + (claimSessionRotation as any).mockResolvedValue(true); + (signAccessToken as any).mockResolvedValue('access'); + (generateRefreshToken as any).mockReturnValue('refresh'); + (createRefreshTokenLookup as any).mockReturnValue('refresh-lookup'); + (getSystemConfig as any).mockResolvedValue({ + access_token_ttl: '15m', + refresh_token_ttl: '1h', + session_idle_ttl: '8h', + }); + vi.mocked(recordProviderActivity).mockResolvedValue(undefined); + }); + + it('refuses and ends it once its parent has ended', async () => { + const session = child(); + (findRefreshSessionByToken as any).mockResolvedValue(session); + vi.mocked(resolveCurrentSession).mockResolvedValue(null); + + const res = await request(app).post('/refresh').set('Authorization', 'Bearer refresh-token'); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('invalid_refresh_token'); + expect(hardRevokeSession).toHaveBeenCalledWith(session, 'parent_session_ended'); + expect(Session.create).not.toHaveBeenCalled(); + }); + + it('keeps the link, stays inside the parent, and records activity on it', async () => { + const parentExpiry = new Date(Date.now() + 10 * 60_000); + (findRefreshSessionByToken as any).mockResolvedValue(child()); + vi.mocked(resolveCurrentSession).mockResolvedValue({ + id: 'parent-now', + expiresAt: parentExpiry, + } as never); + + const res = await request(app).post('/refresh').set('Authorization', 'Bearer refresh-token'); + + expect(res.status).toBe(200); + expect(Session.create).toHaveBeenCalledWith( + expect.objectContaining({ + parentSessionId: 'parent-1', + exchangeClientId: 'cli', + exchangeGrantType: 'authorization_code', + expiresAt: parentExpiry, + idleExpiresAt: parentExpiry, + }), + ); + expect(recordProviderActivity).toHaveBeenCalledWith('parent-now'); + }); + + it('still answers when the activity cannot be recorded', async () => { + (findRefreshSessionByToken as any).mockResolvedValue(child()); + vi.mocked(resolveCurrentSession).mockResolvedValue({ + id: 'parent-now', + expiresAt: new Date(Date.now() + 10 * 60_000), + } as never); + vi.mocked(recordProviderActivity).mockRejectedValue(new Error('down')); + + const res = await request(app).post('/refresh').set('Authorization', 'Bearer refresh-token'); + + expect(res.status).toBe(200); + }); + }); + it('carries the sign-in auth context to the rotated session', async () => { const authTime = new Date('2026-10-30T12:00:00.000Z'); const authMethods = [{ method: 'passkey', at: authTime.toISOString(), deviceBound: true }]; diff --git a/tests/integration/oidc/oidcApplications.spec.ts b/tests/integration/oidc/oidcApplications.spec.ts index abdaa25b..3d6f26b2 100644 --- a/tests/integration/oidc/oidcApplications.spec.ts +++ b/tests/integration/oidc/oidcApplications.spec.ts @@ -60,6 +60,7 @@ function row(overrides: Record = {}): Row { refreshTokenTtlSeconds: null, deviceFlowEnabled: false, noticesEnabled: false, + sessionExchangeEnabled: false, noticeActionOrigins: [], pkceRequired: true, nonceRequired: true, @@ -174,6 +175,25 @@ describe('POST /admin/applications', () => { expect(loggedTypes()).toEqual(['admin_oidc_application_created']); }); + it('audits creating a client with session exchange on', async () => { + const res = await request(app) + .post('/admin/applications') + .send({ + name: 'CLI', + clientType: 'public', + firstParty: true, + redirectUris: ['http://127.0.0.1/callback'], + sessionExchangeEnabled: true, + }); + + expect(res.status).toBe(201); + expect(res.body.application.sessionExchangeEnabled).toBe(true); + expect(loggedTypes()).toEqual([ + 'admin_oidc_application_created', + 'admin_oidc_application_session_exchange_updated', + ]); + }); + it('defaults a confidential client to private_key_jwt', async () => { const res = await request(app) .post('/admin/applications') @@ -509,6 +529,64 @@ describe('PATCH /admin/applications/:id', () => { }); }); + describe('sessionExchangeEnabled', () => { + const native = () => + row({ + clientType: 'public', + firstParty: true, + tokenEndpointAuthMethod: 'none', + jwksUri: null, + redirectUris: ['http://127.0.0.1/callback'], + sessionExchangeEnabled: true, + }); + + it('audits turning it off and revokes what the client exchanged for', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue(native() as never); + + const res = await request(app) + .patch('/admin/applications/app-1') + .send({ sessionExchangeEnabled: false }); + + expect(res.status).toBe(200); + expect(vi.mocked(AuthEventService.log).mock.calls[0][0]).toMatchObject({ + type: 'admin_oidc_application_session_exchange_updated', + metadata: { clientId: 'app-1', sessionExchangeEnabled: false }, + }); + expect(vi.mocked(Session.update).mock.calls[0]).toEqual([ + expect.objectContaining({ revokedReason: 'session_exchange_disabled' }), + { where: { exchangeClientId: 'app-1', revokedAt: null } }, + ]); + }); + + it('revokes them when the client is disabled', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue(native() as never); + + await request(app).patch('/admin/applications/app-1').send({ disabled: true }); + + expect(vi.mocked(Session.update).mock.calls[0][0]).toMatchObject({ + revokedReason: 'oidc_client_disabled', + }); + }); + + it('leaves them alone on any other change', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue(native() as never); + + await request(app).patch('/admin/applications/app-1').send({ name: 'Renamed' }); + + expect(Session.update).not.toHaveBeenCalled(); + }); + + it('is refused on a client that is not first-party public native', async () => { + vi.mocked(OidcClient.findByPk).mockResolvedValue(row() as never); + + const res = await request(app) + .patch('/admin/applications/app-1') + .send({ sessionExchangeEnabled: true }); + + expect(res.status).toBe(400); + }); + }); + it('audits a PKCE change on its own, and not a patch that leaves it alone', async () => { const existing = row(); vi.mocked(OidcClient.findByPk).mockResolvedValue(existing as never); diff --git a/tests/integration/oidc/oidcClientLinks.spec.ts b/tests/integration/oidc/oidcClientLinks.spec.ts index bd7a7fa8..adaf57c4 100644 --- a/tests/integration/oidc/oidcClientLinks.spec.ts +++ b/tests/integration/oidc/oidcClientLinks.spec.ts @@ -38,6 +38,7 @@ function client(id: string, overrides: Record = {}) { refreshTokenTtlSeconds: null, deviceFlowEnabled: false, noticesEnabled: false, + sessionExchangeEnabled: false, noticeActionOrigins: [], pkceRequired: true, nonceRequired: true, diff --git a/tests/integration/session/session.spec.ts b/tests/integration/session/session.spec.ts index 1760a0fb..ad35e01c 100644 --- a/tests/integration/session/session.spec.ts +++ b/tests/integration/session/session.spec.ts @@ -4,6 +4,7 @@ import { createApp } from '../../../src/app'; import { Application } from 'express'; import { Op } from 'sequelize'; +import { getSystemConfig } from '../../../src/config/getSystemConfig.js'; import { Session } from '../../../src/models/sessions.js'; import { User } from '../../../src/models/users.js'; import { hardRevokeSession } from '../../../src/services/sessionService.js'; @@ -105,3 +106,32 @@ describe('DELETE /sessions', () => { expect(hardRevokeSession).not.toHaveBeenCalled(); }); }); + +describe('POST /sessions/exchange', () => { + const body = { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: 'a.b.c', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', + }; + + it('answers invalid_request to a malformed request, with no store', async () => { + const res = await request(app) + .post('/sessions/exchange') + .send({ ...body, grant_type: 'password' }); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + }); + + it('answers one body for a token that does not verify', async () => { + vi.mocked(getSystemConfig).mockResolvedValue({ + oidc_issuer: 'https://login.example.com', + } as never); + + const res = await request(app).post('/sessions/exchange').send(body); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'invalid_request' }); + expect(res.headers['cache-control']).toBe('no-store'); + }); +}); diff --git a/tests/integration/session/sessionExchangeRoute.spec.ts b/tests/integration/session/sessionExchangeRoute.spec.ts new file mode 100644 index 00000000..ecd4290a --- /dev/null +++ b/tests/integration/session/sessionExchangeRoute.spec.ts @@ -0,0 +1,60 @@ +import { Application } from 'express'; +import request from 'supertest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/services/sessionExchangeService.js', () => ({ + exchangeSubjectToken: vi.fn(), +})); + +import { createApp } from '../../../src/app'; +import { exchangeSubjectToken } from '../../../src/services/sessionExchangeService.js'; + +let app: Application; + +const body = { + grant_type: 'urn:ietf:params:oauth:grant-type:token-exchange', + subject_token: 'a.b.c', + subject_token_type: 'urn:ietf:params:oauth:token-type:access_token', +}; + +beforeAll(async () => { + app = await createApp(); +}); + +describe('POST /sessions/exchange', () => { + it('answers the session the service issued, and no one may cache it', async () => { + vi.mocked(exchangeSubjectToken).mockResolvedValue({ + ok: true, + response: { + message: 'Success', + token: 'access', + refreshToken: 'refresh', + issued_token_type: 'urn:ietf:params:oauth:token-type:refresh_token', + }, + }); + + const res = await request(app).post('/sessions/exchange').send(body); + + expect(res.status).toBe(200); + expect(res.headers['cache-control']).toBe('no-store'); + expect(res.body).toMatchObject({ token: 'access', refreshToken: 'refresh' }); + }); + + it('tells the client a replay happened', async () => { + vi.mocked(exchangeSubjectToken).mockResolvedValue({ ok: false, replayed: true }); + + const res = await request(app).post('/sessions/exchange').send(body); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'invalid_request', error_description: 'token_already_used' }); + }); + + it('answers every other failure with one body', async () => { + vi.mocked(exchangeSubjectToken).mockResolvedValue({ ok: false, replayed: false }); + + const res = await request(app).post('/sessions/exchange').send(body); + + expect(res.status).toBe(400); + expect(res.body).toEqual({ error: 'invalid_request' }); + }); +}); diff --git a/tests/postgres/oidcApplications.spec.ts b/tests/postgres/oidcApplications.spec.ts index 7dcaa002..51095e2b 100644 --- a/tests/postgres/oidcApplications.spec.ts +++ b/tests/postgres/oidcApplications.spec.ts @@ -14,6 +14,7 @@ import { listOidcApplicationLinks, } from '../../src/services/oidcClientLinkService.js'; import { getOidcClientResourceScopes } from '../../src/services/oidcClientRegistry.js'; +import { SystemConfig } from '../../src/models/systemConfig.js'; import { upsertOidcPayload } from '../../src/services/oidcPayloadStore.js'; import { createOidcResource, @@ -21,9 +22,14 @@ import { setOidcApplicationResources, updateOidcResource, } from '../../src/services/oidcResourceService.js'; +import { buildSystemConfig } from '../factories/systemConfigFactory.js'; beforeAll(async () => { await initializeModels(); + await SystemConfig.bulkCreate( + Object.entries(buildSystemConfig()).map(([key, value]) => ({ key, value })) as never, + { ignoreDuplicates: true }, + ); }); afterAll(async () => { diff --git a/tests/postgres/sessionExchange.spec.ts b/tests/postgres/sessionExchange.spec.ts new file mode 100644 index 00000000..f8fefb83 --- /dev/null +++ b/tests/postgres/sessionExchange.spec.ts @@ -0,0 +1,2195 @@ +import { generateKeyPairSync, randomUUID } from 'crypto'; +import fs from 'fs'; +import { SignJWT } from 'jose'; +import os from 'os'; +import path from 'path'; +import request from 'supertest'; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { invalidateSystemConfigCache } from '../../src/config/getSystemConfig.js'; +import { AuthEvent } from '../../src/models/authEvents.js'; +import { getSequelize, initializeModels } from '../../src/models/index.js'; +import { OidcClient, type OidcClientAttributes } from '../../src/models/oidcClients.js'; +import { OidcPayload } from '../../src/models/oidcPayloads.js'; +import { Session, type SessionAttributes } from '../../src/models/sessions.js'; +import { SystemConfig } from '../../src/models/systemConfig.js'; +import { User } from '../../src/models/users.js'; +import { buildSystemConfig } from '../factories/systemConfigFactory.js'; + +const API_ISSUER = 'http://localhost:5312'; +const PROVIDER_ISSUER = 'https://login.session-exchange.test'; +const BUILT_IN_RESOURCE = `${PROVIDER_ISSUER}/seamless`; +const KID = 'exchange-key'; +const AUTHORIZATION_CODE = 'authorization_code'; +const DEVICE_CODE = 'urn:ietf:params:oauth:grant-type:device_code'; +const SCOPE = 'session:exchange'; + +const providerKey = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + +type App = Awaited>; +type Signing = typeof import('../../src/services/oidcSigning.js'); +type Registry = typeof import('../../src/services/oidcClientRegistry.js'); +type Applications = typeof import('../../src/services/oidcApplicationService.js'); +type Store = typeof import('../../src/services/oidcPayloadStore.js'); +type Tokens = typeof import('../../src/lib/token.js'); +type Sessions = typeof import('../../src/services/sessionService.js'); + +let app: App; +let signing: Signing; +let registry: Registry; +let applications: Applications; +let store: Store; +let tokens: Tokens; +let sessions: Sessions; +const savedEnv: Record = {}; + +function setEnv(values: Record) { + for (const [key, value] of Object.entries(values)) { + savedEnv[key] = process.env[key]; + process.env[key] = value; + } +} + +async function setConfig(key: string, value: unknown) { + await getSequelize().query( + `INSERT INTO system_config (key, value, "createdAt", "updatedAt") + VALUES (:key, CAST(:value AS jsonb), now(), now()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value`, + { replacements: { key, value: JSON.stringify(value) } }, + ); +} + +beforeAll(async () => { + setEnv({ + ISSUER: API_ISSUER, + ALLOW_UNCREDENTIALED_DELIVERY_SECRETS: 'true', + DISABLE_AUTH_RATE_LIMITS: 'true', + SEAMLESS_DEV_KEYS_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'seamless-keys-')), + OIDC_JWKS_ACTIVE_KID: KID, + OIDC_JWKS_KEYS: JSON.stringify({ + keys: [{ ...providerKey.export({ format: 'jwk' }), kid: KID }], + }), + }); + + await initializeModels(); + + for (const [key, value] of Object.entries(buildSystemConfig())) { + await getSequelize().query( + `INSERT INTO system_config (key, value, "createdAt", "updatedAt") + VALUES (:key, CAST(:value AS jsonb), now(), now()) + ON CONFLICT (key) DO NOTHING`, + { replacements: { key, value: JSON.stringify(value) } }, + ); + } + await setConfig('oidc_issuer', PROVIDER_ISSUER); + await setConfig('max_concurrent_sessions', null); + invalidateSystemConfigCache(); + + signing = await import('../../src/services/oidcSigning.js'); + registry = await import('../../src/services/oidcClientRegistry.js'); + applications = await import('../../src/services/oidcApplicationService.js'); + store = await import('../../src/services/oidcPayloadStore.js'); + tokens = await import('../../src/lib/token.js'); + sessions = await import('../../src/services/sessionService.js'); + + const { initDevSigningKey } = await import('../../src/utils/signingKeyStore.js'); + const { createApp } = await import('../../src/app.js'); + initDevSigningKey(); + app = await createApp(); +}); + +afterAll(async () => { + await SystemConfig.destroy({ where: { key: ['oidc_issuer', 'max_concurrent_sessions'] } }); + await getSequelize().query(`DELETE FROM users WHERE email LIKE :pattern`, { + replacements: { pattern: '%@session-exchange.test' }, + }); + invalidateSystemConfigCache(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await getSequelize().close(); +}); + +beforeEach(() => { + invalidateSystemConfigCache(); +}); + +function nativeClient(overrides: Partial = {}) { + return OidcClient.create({ + id: `cli-${randomUUID()}`, + name: 'Seamless CLI', + firstParty: true, + clientType: 'public', + tokenEndpointAuthMethod: 'none', + redirectUris: ['http://127.0.0.1/callback'], + grantTypes: [AUTHORIZATION_CODE, DEVICE_CODE], + deviceFlowEnabled: true, + sessionExchangeEnabled: true, + ...overrides, + }); +} + +function createUser(roles: string[] = ['user']) { + return User.create({ + email: `${randomUUID()}@session-exchange.test`, + phone: null, + roles, + } as never); +} + +function createSession(userId: string, overrides: Partial = {}) { + const now = Date.now(); + return Session.create({ + userId, + mode: 'server', + refreshTokenLookup: randomUUID().replaceAll('-', ''), + expiresAt: new Date(now + 3_600_000), + idleExpiresAt: new Date(now + 3_600_000), + lastUsedAt: new Date(now), + chainStartedAt: new Date(now), + ...overrides, + }); +} + +/** The first-party session a provider sign-in produced for the client (ADR 0001, D3). */ +async function providerSignIn( + client: OidcClient, + user: User, + overrides: Partial = {}, +) { + const parent = await createSession(user.id, overrides); + await store.bindOidcSession(parent.id, user.id, client.id); + await store.upsertOidcPayload('Grant', randomUUID(), { + payload: { accountId: user.id, clientId: client.id }, + expiresIn: 3600, + sessionId: parent.id, + }); + return parent; +} + +function accessTokenFor(session: Session, roles: string[] = ['user']) { + return tokens.signAccessToken(session.id, session.userId, roles, null); +} + +function jwtInput(header: Record, claims: Record) { + const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode(header)}.${encode(claims)}`; +} + +function subjectClaims(client: OidcClient, userId: string, overrides = {}) { + const now = Math.floor(Date.now() / 1000); + return { + iss: PROVIDER_ISSUER, + sub: userId, + aud: BUILT_IN_RESOURCE, + exp: now + 60, + iat: now, + jti: randomUUID(), + client_id: client.id, + scope: SCOPE, + ...overrides, + }; +} + +/** Has `/oidc/sign` sign an access token, the way the edge asks for one. */ +async function signSubject( + client: OidcClient, + parent: Session, + { grantType = AUTHORIZATION_CODE, claims = {} } = {}, +) { + const body = subjectClaims(client, parent.userId, claims); + const signingInput = jwtInput({ alg: 'RS256', kid: KID, typ: 'at+jwt' }, body); + const outcome = await signing.signForOidcEdge({ + kind: 'access_token', + kid: KID, + clientId: client.id, + sessionId: parent.id, + grantType, + signingInput, + } as never); + return { + outcome, + jti: body.jti as string, + token: outcome.ok ? `${signingInput}.${outcome.signature}` : '', + }; +} + +async function admin(roles = ['admin:write', 'admin:read'], stepUp = true) { + const user = await createUser(roles); + const session = await createSession(user.id, stepUp ? { stepUpVerifiedAt: new Date() } : {}); + return { user, session, token: await accessTokenFor(session, roles) }; +} + +describe('the sessionExchangeEnabled registry flag', () => { + it('adds the built-in resource for an eligible client, with a 60 second lifetime', async () => { + const client = await nativeClient(); + + expect(await registry.getOidcClientResources(client.id)).toEqual([ + { identifier: BUILT_IN_RESOURCE, scopes: [SCOPE], accessTokenTtlSeconds: 60 }, + ]); + }); + + it('keeps one built-in entry when notices are on as well', async () => { + const client = await nativeClient({ noticesEnabled: true }); + const resources = await registry.getOidcClientResources(client.id); + + expect(resources).toHaveLength(1); + expect(resources[0].scopes.sort()).toEqual(['notices:send', SCOPE]); + expect(resources[0].accessTokenTtlSeconds).toBe(60); + }); + + it.each([ + ['the flag is off', { sessionExchangeEnabled: false }], + ['the client is third-party', { firstParty: false }], + [ + 'the client is confidential', + { clientType: 'confidential', tokenEndpointAuthMethod: 'private_key_jwt' }, + ], + ['the client is a web client', { redirectUris: ['https://app.example.com/callback'] }], + ['the client is disabled', { disabledAt: new Date() }], + ] as [string, Partial][])( + 'lists nothing when %s, whatever is stored', + async (_name, overrides) => { + const client = await nativeClient(overrides); + + expect(await registry.getOidcClientResources(client.id)).toEqual([]); + }, + ); + + it('is off by default and refused on a client that is not first-party public native', async () => { + const { client } = await applications.createOidcApplication({ + name: 'Default', + clientType: 'public', + firstParty: true, + redirectUris: ['http://127.0.0.1/callback'], + grantTypes: ['authorization_code'], + } as never); + expect(client.sessionExchangeEnabled).toBe(false); + + const web = await nativeClient({ + sessionExchangeEnabled: false, + redirectUris: ['https://app.example.com/callback'], + }); + await expect( + applications.updateOidcApplication(web.id, { sessionExchangeEnabled: true }), + ).rejects.toBeInstanceOf(applications.OidcApplicationInvalidError); + await web.reload(); + expect(web.sessionExchangeEnabled).toBe(false); + }); + + it('refuses an update that would make an enabled client ineligible', async () => { + const client = await nativeClient(); + + await expect( + applications.updateOidcApplication(client.id, { + redirectUris: ['https://app.example.com/callback'], + }), + ).rejects.toBeInstanceOf(applications.OidcApplicationInvalidError); + await expect( + applications.updateOidcApplication(client.id, { firstParty: false }), + ).rejects.toBeInstanceOf(applications.OidcApplicationError); + }); + + async function exchangedSessions(client: OidcClient) { + const user = await createUser(); + const parent = await createSession(user.id); + const child = await createSession(user.id, { + parentSessionId: parent.id, + exchangeClientId: client.id, + exchangeGrantType: AUTHORIZATION_CODE, + }); + const rotated = await createSession(user.id, { + parentSessionId: parent.id, + exchangeClientId: client.id, + exchangeGrantType: AUTHORIZATION_CODE, + }); + return { parent, child, rotated }; + } + + it('revokes what the client exchanged for when the flag goes off, and only that', async () => { + const client = await nativeClient(); + const other = await nativeClient(); + const { parent, child, rotated } = await exchangedSessions(client); + const unrelated = await exchangedSessions(other); + + await applications.updateOidcApplication(client.id, { sessionExchangeEnabled: false }); + + await child.reload(); + await rotated.reload(); + await parent.reload(); + await unrelated.child.reload(); + expect(child.revokedReason).toBe('session_exchange_disabled'); + expect(rotated.revokedAt).not.toBeNull(); + expect(parent.revokedAt).toBeNull(); + expect(unrelated.child.revokedAt).toBeNull(); + expect(await registry.getOidcClientResources(client.id)).toEqual([]); + }); + + it('revokes what the client exchanged for when the client is disabled', async () => { + const client = await nativeClient(); + const { child } = await exchangedSessions(client); + + await applications.updateOidcApplication(client.id, { disabled: true }); + + await child.reload(); + expect(child.revokedReason).toBe('oidc_client_disabled'); + }); + + it('revokes what the client exchanged for when the client is deleted', async () => { + const client = await nativeClient(); + const { child } = await exchangedSessions(client); + + await applications.deleteOidcApplication(client.id); + + await child.reload(); + expect(child.revokedReason).toBe('oidc_client_deleted'); + }); + + it('leaves exchanged sessions alone on an unrelated update', async () => { + const client = await nativeClient(); + const { child } = await exchangedSessions(client); + + await applications.updateOidcApplication(client.id, { name: 'Renamed' }); + + await child.reload(); + expect(child.revokedAt).toBeNull(); + }); + + describe('on the admin routes', () => { + it('needs a step-up, and audits the change', async () => { + const client = await nativeClient({ sessionExchangeEnabled: false }); + const noStepUp = await admin(['admin:write'], false); + + const refused = await request(app) + .patch(`/admin/applications/${client.id}`) + .set('Authorization', `Bearer ${noStepUp.token}`) + .send({ sessionExchangeEnabled: true }); + expect(refused.status).toBe(403); + expect(refused.body.error).toBe('step_up_required'); + + const stepped = await admin(); + const res = await request(app) + .patch(`/admin/applications/${client.id}`) + .set('Authorization', `Bearer ${stepped.token}`) + .send({ sessionExchangeEnabled: true }); + + expect(res.status).toBe(200); + expect(res.body.application.sessionExchangeEnabled).toBe(true); + const events = await AuthEvent.findAll({ + where: { type: 'admin_oidc_application_session_exchange_updated' }, + }); + expect( + events.some( + (event) => + (event.metadata as Record)?.clientId === client.id && + (event.metadata as Record)?.sessionExchangeEnabled === true, + ), + ).toBe(true); + }); + + it('answers 400 for a client that is not eligible', async () => { + const web = await nativeClient({ + sessionExchangeEnabled: false, + redirectUris: ['https://app.example.com/callback'], + }); + const { token } = await admin(); + + const res = await request(app) + .patch(`/admin/applications/${web.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ sessionExchangeEnabled: true }); + + expect(res.status).toBe(400); + }); + + it('audits an application created with the flag on', async () => { + const { token } = await admin(); + + const res = await request(app) + .post('/admin/applications') + .set('Authorization', `Bearer ${token}`) + .send({ + name: 'Created', + clientType: 'public', + firstParty: true, + redirectUris: ['http://127.0.0.1/callback'], + grantTypes: ['authorization_code'], + sessionExchangeEnabled: true, + }); + + expect(res.status).toBe(201); + expect(res.body.application.sessionExchangeEnabled).toBe(true); + const events = await AuthEvent.findAll({ + where: { type: 'admin_oidc_application_session_exchange_updated' }, + }); + expect( + events.some( + (event) => + (event.metadata as Record)?.clientId === res.body.application.id, + ), + ).toBe(true); + }); + }); +}); + +describe('/oidc/sign for a session:exchange token', () => { + async function eligible(overrides: Partial = {}) { + const client = await nativeClient(); + const user = await createUser(); + const parent = await providerSignIn(client, user, overrides); + return { client, user, parent }; + } + + it('signs it, and records which session and grant it came from', async () => { + const { client, user, parent } = await eligible(); + + const { outcome, jti } = await signSubject(client, parent, { grantType: DEVICE_CODE }); + + expect(outcome.ok).toBe(true); + const record = await OidcPayload.findOne({ where: { model: 'SessionExchangeToken', id: jti } }); + expect(record?.sessionId).toBe(parent.id); + expect(record?.consumedAt).toBeNull(); + expect(record?.payload).toEqual({ + clientId: client.id, + accountId: user.id, + grantType: DEVICE_CODE, + }); + }); + + it('expires the record with the token', async () => { + const { client, parent } = await eligible(); + const exp = Math.floor(Date.now() / 1000) + 90; + + const { jti } = await signSubject(client, parent, { claims: { exp } }); + + const record = await OidcPayload.findOne({ where: { model: 'SessionExchangeToken', id: jti } }); + expect(record?.expiresAt.getTime()).toBe(exp * 1000); + }); + + it('records nothing for a token with no session:exchange scope', async () => { + const client = await nativeClient({ noticesEnabled: true }); + const user = await createUser(); + const parent = await providerSignIn(client, user); + + const { outcome, jti } = await signSubject(client, parent, { + claims: { scope: 'notices:send' }, + }); + + expect(outcome.ok).toBe(true); + expect(await OidcPayload.count({ where: { model: 'SessionExchangeToken', id: jti } })).toBe(0); + }); + + it.each([ + ['another scope with it', { scope: `${SCOPE} notices:send` }, 'grant_not_allowed'], + ['an OIDC scope with it', { scope: `openid ${SCOPE}` }, 'grant_not_allowed'], + ['an audience list', { aud: [BUILT_IN_RESOURCE] }, 'audience_mismatch'], + ['another audience', { aud: 'https://api.example.com' }, 'audience_mismatch'], + ['no jti', { jti: undefined }, 'malformed_signing_input'], + ['no iat', { iat: undefined }, 'malformed_signing_input'], + [ + 'a lifetime over two minutes', + { exp: Math.floor(Date.now() / 1000) + 600 }, + 'malformed_signing_input', + ], + [ + 'an iat that hides a longer lifetime', + { iat: Math.floor(Date.now() / 1000) + 1000, exp: Math.floor(Date.now() / 1000) + 1100 }, + 'malformed_signing_input', + ], + ] as [string, Record, string][])( + 'refuses a token with %s', + async (_name, claims, code) => { + const { client, parent } = await eligible(); + + const { outcome } = await signSubject(client, parent, { claims }); + + expect(outcome).toMatchObject({ ok: false, code }); + }, + ); + + it('refuses another resource that happens to declare the scope', async () => { + const { client, parent } = await eligible(); + const identifier = `https://api-${randomUUID()}.example.com`; + const { createOidcResource, setOidcApplicationResources } = + await import('../../src/services/oidcResourceService.js'); + const resource = await createOidcResource({ + identifier, + name: 'Lookalike', + scopes: [SCOPE], + } as never); + await setOidcApplicationResources(client.id, { + resources: [{ resourceId: resource.id, scopes: [SCOPE] }], + } as never); + + const { outcome } = await signSubject(client, parent, { claims: { aud: identifier } }); + + expect(outcome).toMatchObject({ ok: false, code: 'audience_mismatch' }); + }); + + it('refuses a refresh grant, which has no business minting one', async () => { + const client = await nativeClient({ + grantTypes: [AUTHORIZATION_CODE, 'refresh_token', DEVICE_CODE], + }); + const parent = await providerSignIn(client, await createUser()); + + const { outcome } = await signSubject(client, parent, { grantType: 'refresh_token' }); + + expect(outcome).toMatchObject({ ok: false, code: 'grant_not_allowed' }); + }); + + it.each([ + ['exchange is off', { sessionExchangeEnabled: false }, 'audience_mismatch'], + ['it is a web client', { redirectUris: ['https://app.example.com/cb'] }, 'audience_mismatch'], + ['it is disabled', { disabledAt: new Date() }, 'client_inactive'], + ] as [string, Partial, string][])( + 'refuses a client when %s', + async (_name, overrides, code) => { + const client = await nativeClient(overrides); + const parent = await providerSignIn(client, await createUser()); + + const { outcome } = await signSubject(client, parent); + + expect(outcome).toMatchObject({ ok: false, code }); + }, + ); + + it('never signs the scope onto a client credentials token', async () => { + const client = await nativeClient({ + grantTypes: ['client_credentials', AUTHORIZATION_CODE], + }); + const claims = subjectClaims(client, client.id); + const signingInput = jwtInput({ alg: 'RS256', kid: KID, typ: 'at+jwt' }, claims); + + const outcome = await signing.signForOidcEdge({ + kind: 'client_credentials', + kid: KID, + clientId: client.id, + grantType: 'client_credentials', + signingInput, + } as never); + + expect(outcome).toMatchObject({ ok: false, code: 'grant_not_allowed' }); + }); + + it('refuses to sign the same jti twice, so a consumed record cannot be reset', async () => { + const { client, parent } = await eligible(); + const { jti } = await signSubject(client, parent); + await OidcPayload.update( + { consumedAt: new Date() }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + const { outcome } = await signSubject(client, parent, { claims: { jti } }); + + expect(outcome).toMatchObject({ ok: false, code: 'malformed_signing_input' }); + const record = await OidcPayload.findOne({ where: { model: 'SessionExchangeToken', id: jti } }); + expect(record?.consumedAt).not.toBeNull(); + }); + + it('keeps the record out of reach of the edge payload routes', async () => { + const { OidcPayloadModelSchema } = await import('@seamless-auth/types'); + + expect(OidcPayloadModelSchema.safeParse('SessionExchangeToken').success).toBe(false); + }); +}); + +const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:token-exchange'; +const ACCESS_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:access_token'; +const REFRESH_TOKEN_TYPE = 'urn:ietf:params:oauth:token-type:refresh_token'; +const TOKEN_FAILURE = { error: 'invalid_request' }; + +function exchange(subjectToken: string, overrides: Record = {}) { + return request(app) + .post('/sessions/exchange') + .send({ + grant_type: GRANT_TYPE, + subject_token: subjectToken, + subject_token_type: ACCESS_TOKEN_TYPE, + requested_token_type: REFRESH_TOKEN_TYPE, + ...overrides, + }); +} + +async function signedInToCli( + overrides: Partial = {}, + clientOverrides: Partial = {}, + roles: string[] = ['user'], +) { + const client = await nativeClient(clientOverrides); + const user = await createUser(roles); + const parent = await providerSignIn(client, user, overrides); + return { client, user, parent }; +} + +/** A token signed with the provider key that `/oidc/sign` never saw. */ +function forge(claims: Record, typ = 'at+jwt') { + return new SignJWT(claims).setProtectedHeader({ alg: 'RS256', kid: KID, typ }).sign(providerKey); +} + +function me(token: string) { + return request(app).get('/users/me').set('Authorization', `Bearer ${token}`); +} + +describe('POST /sessions/exchange', () => { + it('issues a linked child session the first-party routes accept', async () => { + const { client, user, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + + const res = await exchange(token); + + expect(res.status).toBe(200); + expect(res.headers['cache-control']).toBe('no-store'); + expect(res.body).toMatchObject({ + message: 'Success', + sub: user.id, + email: user.email, + roles: user.roles, + issued_token_type: REFRESH_TOKEN_TYPE, + }); + expect(res.body.ttl).toBeGreaterThan(0); + expect(res.body.refreshTtl).toBeGreaterThan(0); + expect(res.body.sessionId).not.toBe(parent.id); + + const child = await Session.findByPk(res.body.sessionId); + expect(child).toMatchObject({ + userId: user.id, + parentSessionId: parent.id, + exchangeClientId: client.id, + exchangeGrantType: AUTHORIZATION_CODE, + organizationId: parent.organizationId, + }); + expect(child?.refreshTokenLookup).not.toBe(parent.refreshTokenLookup); + + const record = await OidcPayload.findOne({ where: { model: 'SessionExchangeToken', id: jti } }); + expect(record?.consumedAt).not.toBeNull(); + + expect((await me(res.body.token)).status).toBe(200); + expect((await me(await accessTokenFor(parent))).status).toBe(200); + + const refreshed = await request(app) + .post('/refresh') + .set('Authorization', `Bearer ${res.body.refreshToken}`); + expect(refreshed.status).toBe(200); + + const event = await AuthEvent.findOne({ + where: { type: 'session_exchanged', session_id: res.body.sessionId }, + }); + expect(event?.user_id).toBe(user.id); + expect(event?.metadata).toMatchObject({ + clientId: client.id, + grantType: AUTHORIZATION_CODE, + parentSessionId: parent.id, + }); + }); + + it('accepts a request that leaves out the optional requested_token_type', async () => { + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + + const res = await exchange(token, { requested_token_type: undefined }); + + expect(res.status).toBe(200); + }); + + it('caps the child at what is left of the parent', async () => { + const parentExpiry = new Date(Date.now() + 30 * 60_000); + const { client, parent } = await signedInToCli({ + expiresAt: parentExpiry, + idleExpiresAt: parentExpiry, + }); + const { token } = await signSubject(client, parent); + + const res = await exchange(token); + + const child = await Session.findByPk(res.body.sessionId); + expect(child?.expiresAt.getTime()).toBeLessThanOrEqual(parentExpiry.getTime()); + expect(child?.idleExpiresAt.getTime()).toBeLessThanOrEqual(parentExpiry.getTime()); + expect(res.body.refreshTtl).toBeLessThanOrEqual(30 * 60); + }); + + it('resolves the parent forward through rotation', async () => { + const { client, user, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + const rotated = await createSession(user.id, { organizationId: null }); + await Session.update({ replacedBySessionId: rotated.id }, { where: { id: parent.id } }); + + const res = await exchange(token); + + expect(res.status).toBe(200); + expect((await Session.findByPk(res.body.sessionId))?.parentSessionId).toBe(rotated.id); + }); + + describe('single use', () => { + it('refuses a second presentation and revokes what the first one created', async () => { + const { client, user, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + + const first = await exchange(token); + const second = await exchange(token); + + expect(second.status).toBe(400); + expect(second.body).toEqual({ + error: 'invalid_request', + error_description: 'token_already_used', + }); + expect((await me(first.body.token)).status).toBe(401); + const child = await Session.findByPk(first.body.sessionId); + expect(child?.revokedReason).toBe('session_exchange_replayed'); + expect((await me(await accessTokenFor(parent))).status).toBe(200); + const event = await AuthEvent.findOne({ + where: { type: 'session_exchange_replayed', user_id: user.id }, + }); + expect(event?.metadata).toMatchObject({ clientId: client.id }); + }); + + it('revokes a child that has since rotated', async () => { + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + const first = await exchange(token); + const refreshed = await request(app) + .post('/refresh') + .set('Authorization', `Bearer ${first.body.refreshToken}`); + + await exchange(token); + + expect((await me(refreshed.body.token)).status).toBe(401); + }); + + it('lets one of many concurrent presentations win, then revokes it', async () => { + const { client, user, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + + const results = await Promise.all(Array.from({ length: 6 }, () => exchange(token))); + + const winners = results.filter((res) => res.status === 200); + const losers = results.filter((res) => res.status === 400); + expect(winners).toHaveLength(1); + expect(losers).toHaveLength(5); + for (const loser of losers) { + expect(loser.body.error_description).toBe('token_already_used'); + } + expect((await me(winners[0].body.token)).status).toBe(401); + + const children = await Session.findAll({ + where: { userId: user.id, parentSessionId: parent.id }, + }); + expect(children).toHaveLength(1); + expect(children[0].revokedAt).not.toBeNull(); + const record = await OidcPayload.findOne({ + where: { model: 'SessionExchangeToken', id: jti }, + }); + expect(record?.payload.childSessionId).toBe(children[0].id); + }); + }); + + describe('refuses with one body', () => { + async function expectTokenFailure(token: string) { + const res = await exchange(token); + expect(res.status).toBe(400); + expect(res.body).toEqual(TOKEN_FAILURE); + } + + function goodClaims(client: OidcClient, userId: string) { + return subjectClaims(client, userId); + } + + it.each([ + ['with no signing record', {}], + ['with another audience', { aud: 'https://api.example.com' }], + ['with an audience list', { aud: [BUILT_IN_RESOURCE, 'https://api.example.com'] }], + ['with an extra scope', { scope: `${SCOPE} notices:send` }], + ['with another scope', { scope: 'notices:send' }], + ['from another issuer', { iss: 'https://elsewhere.example.com' }], + ['with no jti', { jti: undefined }], + ['with no client_id', { client_id: undefined }], + ] as [string, Record][])('a token %s', async (_name, overrides) => { + const { client, parent } = await signedInToCli(); + + await expectTokenFailure(await forge({ ...goodClaims(client, parent.userId), ...overrides })); + }); + + it.each([ + ['another audience', { aud: 'https://api.example.com' }], + ['an extra scope', { scope: `${SCOPE} notices:send` }], + ] as [string, Record][])( + 'a token with %s, even when its record exists', + async (_name, overrides) => { + const { client, parent } = await signedInToCli(); + const { jti } = await signSubject(client, parent); + + await expectTokenFailure( + await forge({ ...goodClaims(client, parent.userId), jti, ...overrides }), + ); + const record = await OidcPayload.findOne({ + where: { model: 'SessionExchangeToken', id: jti }, + }); + expect(record?.consumedAt).toBeNull(); + }, + ); + + it('a token whose subject is the client', async () => { + const { client, parent } = await signedInToCli(); + const { jti } = await signSubject(client, parent); + + await expectTokenFailure(await forge({ ...goodClaims(client, client.id), jti })); + }); + + it('a token that is not an at+jwt', async () => { + const { client, parent } = await signedInToCli(); + const { jti } = await signSubject(client, parent); + + await expectTokenFailure(await forge({ ...goodClaims(client, parent.userId), jti }, 'JWT')); + }); + + it('a token signed by someone else', async () => { + const { client, parent } = await signedInToCli(); + const { jti } = await signSubject(client, parent); + const stranger = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + const token = await new SignJWT({ ...goodClaims(client, parent.userId), jti }) + .setProtectedHeader({ alg: 'RS256', kid: KID, typ: 'at+jwt' }) + .sign(stranger); + + await expectTokenFailure(token); + }); + + it('a first-party access token', async () => { + const { parent } = await signedInToCli(); + + await expectTokenFailure(await accessTokenFor(parent)); + }); + + it('an expired token', async () => { + const { client, parent } = await signedInToCli(); + const { jti } = await signSubject(client, parent); + const now = Math.floor(Date.now() / 1000); + + await expectTokenFailure( + await forge({ ...goodClaims(client, parent.userId), jti, iat: now - 90, exp: now - 30 }), + ); + }); + + it('an expired record', async () => { + const { client, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + await OidcPayload.update( + { expiresAt: new Date(Date.now() - 1000) }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + await expectTokenFailure(token); + }); + + it('a record that names another user', async () => { + const { client, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + await OidcPayload.update( + { payload: { clientId: client.id, accountId: randomUUID(), grantType: DEVICE_CODE } }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + await expectTokenFailure(token); + }); + + it('a record that names another client', async () => { + const { client, user, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + await OidcPayload.update( + { payload: { clientId: 'someone-else', accountId: user.id, grantType: DEVICE_CODE } }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + await expectTokenFailure(token); + }); + + it.each([ + ['disabled', { disabledAt: new Date() }], + ['no longer enabled for exchange', { sessionExchangeEnabled: false }], + ['no longer first-party', { firstParty: false }], + [ + 'no longer public', + { clientType: 'confidential', tokenEndpointAuthMethod: 'private_key_jwt' }, + ], + ['no longer native', { redirectUris: ['https://app.example.com/cb'] }], + ] as [string, Partial][])('a client %s', async (_name, change) => { + const { client, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + await OidcClient.update(change, { where: { id: client.id } }); + + await expectTokenFailure(token); + const record = await OidcPayload.findOne({ + where: { model: 'SessionExchangeToken', id: jti }, + }); + expect(record?.consumedAt).toBeNull(); + }); + + it('a recovery parent', async () => { + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + await Session.update({ recovery: true }, { where: { id: parent.id } }); + + await expectTokenFailure(token); + }); + + it('a parent that is no longer live', async () => { + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + await Session.update({ revokedAt: new Date() }, { where: { id: parent.id } }); + + await expectTokenFailure(token); + }); + + it('a parent that belongs to another user', async () => { + const { client, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + const stranger = await createUser(); + const elsewhere = await createSession(stranger.id); + await OidcPayload.update( + { sessionId: elsewhere.id }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + await expectTokenFailure(token); + }); + + it('a parent that is itself an exchanged session', async () => { + const { client, user, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + const grandparent = await createSession(user.id); + await Session.update( + { parentSessionId: grandparent.id, exchangeClientId: client.id }, + { where: { id: parent.id } }, + ); + + await expectTokenFailure(token); + }); + + it('a revoked user', async () => { + const { client, user, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + await User.update({ revoked: true }, { where: { id: user.id } }); + + await expectTokenFailure(token); + }); + + it('a record with a grant that cannot exchange', async () => { + const { client, user, parent } = await signedInToCli(); + const { token, jti } = await signSubject(client, parent); + await OidcPayload.update( + { payload: { clientId: client.id, accountId: user.id, grantType: 'refresh_token' } }, + { where: { model: 'SessionExchangeToken', id: jti } }, + ); + + await expectTokenFailure(token); + }); + }); + + it.each([ + ['another grant type', { grant_type: 'refresh_token' }], + ['another subject token type', { subject_token_type: 'urn:ietf:params:oauth:token-type:jwt' }], + ['another requested type', { requested_token_type: 'urn:ietf:params:oauth:token-type:jwt' }], + ['no subject token', { subject_token: undefined }], + ] as [string, Record][])( + 'answers invalid_request to a request with %s', + async (_name, overrides) => { + const res = await exchange('a.b.c', overrides); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('invalid_request'); + }, + ); + + it('is not a route adapters proxy', async () => { + const manifest = await request(app).get('/.well-known/seamless-adapter.json'); + + expect(JSON.stringify(manifest.body)).not.toContain('/sessions/exchange'); + }); + + describe('assurance (X5)', () => { + const passkey = { + method: 'passkey', + at: '2026-11-04T12:00:00.000Z', + deviceBound: true, + userVerified: true, + }; + + it('copies the parent auth context for an authorization code grant', async () => { + const authTime = new Date(Date.now() - 60_000); + const stepUpVerifiedAt = new Date(Date.now() - 30_000); + const { client, parent } = await signedInToCli({ + authMethods: [passkey] as never, + authTime, + stepUpVerifiedAt, + stepUpMethod: 'webauthn', + }); + const { token } = await signSubject(client, parent); + + const res = await exchange(token); + + const child = await Session.findByPk(res.body.sessionId); + expect(child?.authMethods).toEqual([passkey]); + expect(child?.authTime?.getTime()).toBe(authTime.getTime()); + expect(child?.stepUpVerifiedAt?.getTime()).toBe(stepUpVerifiedAt.getTime()); + expect(child?.stepUpMethod).toBe('webauthn'); + }); + + it('lets a stale step-up stay stale', async () => { + const { client, parent } = await signedInToCli({ + stepUpVerifiedAt: new Date(Date.now() - 10 * 60_000), + stepUpMethod: 'webauthn', + }); + const { token } = await signSubject(client, parent); + + const res = await exchange(token); + + const { getSessionStepUpStatus } = await import('../../src/services/stepUpService.js'); + const status = await getSessionStepUpStatus({ + sessionId: res.body.sessionId, + userId: parent.userId, + }); + expect(status.fresh).toBe(false); + }); + + it('copies nothing for a device grant', async () => { + const { client, parent } = await signedInToCli({ + authMethods: [passkey] as never, + authTime: new Date(), + stepUpVerifiedAt: new Date(), + stepUpMethod: 'webauthn', + }); + const { token } = await signSubject(client, parent, { grantType: DEVICE_CODE }); + + const res = await exchange(token); + + const child = await Session.findByPk(res.body.sessionId); + expect(child?.exchangeGrantType).toBe(DEVICE_CODE); + expect(child?.authMethods).toEqual([]); + expect(child?.authTime).toBeNull(); + expect(child?.stepUpVerifiedAt).toBeNull(); + expect(child?.stepUpMethod).toBeNull(); + }); + + it('keeps later step-ups on either session to that session', async () => { + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + const res = await exchange(token); + const { recordStepUpVerification } = await import('../../src/services/stepUpService.js'); + + await recordStepUpVerification({ + sessionId: res.body.sessionId, + userId: parent.userId, + method: 'totp', + }); + + await parent.reload(); + expect(parent.stepUpVerifiedAt).toBeNull(); + }); + }); + + it('refuses when the client is switched off between the checks and the write', async () => { + // Stands in for an admin's write landing mid-exchange: the exchange's re-read after its + // commit, or the admin's revoke after the write, has to catch it either way. + const { client, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent); + const original = OidcClient.findByPk.bind(OidcClient); + let reads = 0; + const spy = vi.spyOn(OidcClient, 'findByPk').mockImplementation(async (...args) => { + reads += 1; + if (reads === 2) { + await OidcClient.update({ sessionExchangeEnabled: false }, { where: { id: client.id } }); + } + return original(...args); + }); + + const res = await exchange(token); + spy.mockRestore(); + + expect(res.status).toBe(400); + expect(res.body).toEqual(TOKEN_FAILURE); + const children = await Session.findAll({ where: { parentSessionId: parent.id } }); + expect(children.every((child) => child.revokedAt !== null)).toBe(true); + }); +}); + +describe('the child session lifecycle', () => { + /** A signed-in parent with known refresh token, and the child exchanged from it. */ + async function family( + parentOverrides: Partial = {}, + roles: string[] = ['user'], + grantType = AUTHORIZATION_CODE, + ) { + const parentRefresh = tokens.generateRefreshToken(); + const { client, user, parent } = await signedInToCli( + { refreshTokenLookup: tokens.createRefreshTokenLookup(parentRefresh), ...parentOverrides }, + {}, + roles, + ); + const { token } = await signSubject(client, parent, { grantType }); + const res = await exchange(token); + expect(res.status).toBe(200); + return { + client, + user, + parent, + parentRefresh, + childId: res.body.sessionId as string, + childToken: res.body.token as string, + childRefresh: res.body.refreshToken as string, + }; + } + + function refresh(token: string) { + return request(app).post('/refresh').set('Authorization', `Bearer ${token}`); + } + + async function expectEnded(childId: string, childToken: string) { + expect((await me(childToken)).status).toBe(401); + const child = await Session.findByPk(childId); + expect(child?.revokedAt).not.toBeNull(); + } + + describe('validity', () => { + it('refuses a child whose parent ended, even if no cascade reached it', async () => { + const { parent, childId, childToken } = await family(); + await Session.update({ revokedAt: new Date() }, { where: { id: parent.id } }); + + const row = await Session.findByPk(childId); + expect(row?.revokedAt).toBeNull(); + expect((await me(childToken)).status).toBe(401); + }); + + it('refuses a child whose parent idled out', async () => { + const { parent, childToken } = await family(); + await Session.update( + { idleExpiresAt: new Date(Date.now() - 1000) }, + { where: { id: parent.id } }, + ); + + expect((await me(childToken)).status).toBe(401); + }); + + it('keeps a child live while its parent rotates, and ends it with the current row', async () => { + const { parentRefresh, childToken, childId } = await family(); + + const rotated = await refresh(parentRefresh); + expect(rotated.status).toBe(200); + expect((await me(childToken)).status).toBe(200); + + const logout = await request(app) + .delete('/logout') + .set('Authorization', `Bearer ${rotated.body.token}`); + expect(logout.status).toBe(200); + + await expectEnded(childId, childToken); + }); + + it('leaves an ordinary session with no parent alone', async () => { + const user = await createUser(); + const session = await createSession(user.id); + + expect((await me(await accessTokenFor(session))).status).toBe(200); + }); + }); + + describe('refresh', () => { + it('carries the link forward on rotation', async () => { + const { client, parent, childRefresh, childId } = await family(); + + const res = await refresh(childRefresh); + + expect(res.status).toBe(200); + expect(res.body.sessionId).not.toBe(childId); + expect(await Session.findByPk(res.body.sessionId)).toMatchObject({ + parentSessionId: parent.id, + exchangeClientId: client.id, + exchangeGrantType: AUTHORIZATION_CODE, + }); + }); + + it('refuses and ends a child whose parent ended', async () => { + const { user, parent, childRefresh, childId } = await family(); + await Session.update({ revokedAt: new Date() }, { where: { id: parent.id } }); + + const res = await refresh(childRefresh); + + expect(res.status).toBe(401); + expect(res.body.error).toBe('invalid_refresh_token'); + const child = await Session.findByPk(childId); + expect(child?.revokedReason).toBe('parent_session_ended'); + expect(child?.replacedBySessionId).toBeNull(); + const event = await AuthEvent.findOne({ + where: { type: 'refresh_token_failed', user_id: user.id, session_id: childId }, + }); + expect((event?.metadata as Record)?.refusal).toBe('parent_session_ended'); + }); + + it('never outlives the parent', async () => { + const parentExpiry = new Date(Date.now() + 30 * 60_000); + const { childRefresh } = await family({ + expiresAt: parentExpiry, + idleExpiresAt: parentExpiry, + }); + + const res = await refresh(childRefresh); + + const child = await Session.findByPk(res.body.sessionId); + expect(child?.expiresAt.getTime()).toBeLessThanOrEqual(parentExpiry.getTime()); + expect(child?.idleExpiresAt.getTime()).toBeLessThanOrEqual(parentExpiry.getTime()); + expect(res.body.refreshTtl).toBeLessThanOrEqual(30 * 60); + }); + + it('counts as activity on the parent, up to its absolute expiry', async () => { + const parentExpiry = new Date(Date.now() + 2 * 3_600_000); + const { parent, childRefresh } = await family({ + expiresAt: parentExpiry, + idleExpiresAt: new Date(Date.now() + 60_000), + lastUsedAt: new Date(Date.now() - 3_600_000), + }); + + const res = await refresh(childRefresh); + expect(res.status).toBe(200); + + await parent.reload(); + expect(parent.idleExpiresAt.getTime()).toBeGreaterThan(Date.now() + 60_000); + expect(parent.idleExpiresAt.getTime()).toBeLessThanOrEqual(parentExpiry.getTime()); + expect(parent.lastUsedAt.getTime()).toBeGreaterThan(Date.now() - 60_000); + }); + + it('revokes only the child chain on refresh token reuse', async () => { + const { parent, childRefresh } = await family(); + + const rotated = await refresh(childRefresh); + const reused = await refresh(childRefresh); + + expect(reused.status).toBe(401); + expect((await me(rotated.body.token)).status).toBe(401); + await parent.reload(); + expect(parent.revokedAt).toBeNull(); + expect((await me(await accessTokenFor(parent))).status).toBe(200); + }); + }); + + describe('every way a parent ends takes the child with it', () => { + it('logout', async () => { + const { parent, childId, childToken } = await family(); + + await request(app) + .delete('/logout') + .set('Authorization', `Bearer ${await accessTokenFor(parent)}`); + + await expectEnded(childId, childToken); + }); + + it('sign out everywhere', async () => { + const { parent, childId, childToken } = await family(); + + await request(app) + .delete('/logout/all') + .set('Authorization', `Bearer ${await accessTokenFor(parent)}`); + + await expectEnded(childId, childToken); + }); + + it('revoking the session from the user session list', async () => { + const { parent, childId, childToken } = await family(); + const other = await createSession(parent.userId); + + const res = await request(app) + .delete(`/sessions/${parent.id}`) + .set('Authorization', `Bearer ${await accessTokenFor(other)}`); + expect(res.status).toBe(200); + + await expectEnded(childId, childToken); + }); + + it('revoking all sessions from the user session list', async () => { + const { parent, childId, childToken } = await family(); + + await request(app) + .delete('/sessions') + .set('Authorization', `Bearer ${await accessTokenFor(parent)}`); + + await expectEnded(childId, childToken); + }); + + it('an admin revoking the session', async () => { + const { parent, childId, childToken } = await family(); + const { token } = await admin(); + + const res = await request(app) + .delete(`/admin/sessions/by-id/${parent.id}`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + + await expectEnded(childId, childToken); + }); + + it('an admin revoking every session of the user', async () => { + const { user, childId, childToken } = await family(); + const { token } = await admin(); + + const res = await request(app) + .delete(`/admin/sessions/${user.id}/revoke-all`) + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(200); + + await expectEnded(childId, childToken); + }); + + it('refresh token reuse on the parent chain', async () => { + const { parentRefresh, childId, childToken } = await family(); + + await refresh(parentRefresh); + const reused = await refresh(parentRefresh); + expect(reused.status).toBe(401); + + await expectEnded(childId, childToken); + }); + + it('the provider revoking the sign-in, from the row it recorded', async () => { + const { parent, parentRefresh, childId, childToken } = await family(); + const rotated = await refresh(parentRefresh); + expect(rotated.status).toBe(200); + + await sessions.revokeSessionChain(parent, 'oidc_end_session'); + + await expectEnded(childId, childToken); + }); + + it('concurrent session eviction', async () => { + const { user, parent, childId, childToken } = await family(); + const { enforceConcurrentSessionLimit } = + await import('../../src/services/concurrentSessionPolicy.js'); + + const evicted = await enforceConcurrentSessionLimit({ + userId: user.id, + limit: 1, + req: { ip: '127.0.0.1', get: () => undefined, headers: {} } as never, + }); + + expect(evicted).toBe(1); + await parent.reload(); + expect(parent.revokedReason).toBe('concurrent_session_limit'); + await expectEnded(childId, childToken); + }); + }); + + describe('ending a child leaves the parent alone', () => { + it('logout', async () => { + const { parent, childId, childToken } = await family(); + + const res = await request(app).delete('/logout').set('Authorization', `Bearer ${childToken}`); + expect(res.status).toBe(200); + + await expectEnded(childId, childToken); + expect((await me(await accessTokenFor(parent))).status).toBe(200); + }); + + it('an admin revoking the child', async () => { + const { parent, childId, childToken } = await family(); + const { token } = await admin(); + + await request(app) + .delete(`/admin/sessions/by-id/${childId}`) + .set('Authorization', `Bearer ${token}`); + + await expectEnded(childId, childToken); + expect((await me(await accessTokenFor(parent))).status).toBe(200); + }); + }); + + it('does not count a child against the concurrent session limit', async () => { + const { user, parent, childId } = await family(); + const { enforceConcurrentSessionLimit } = + await import('../../src/services/concurrentSessionPolicy.js'); + + const evicted = await enforceConcurrentSessionLimit({ + userId: user.id, + limit: 2, + req: { ip: '127.0.0.1', get: () => undefined, headers: {} } as never, + }); + + expect(evicted).toBe(0); + await parent.reload(); + expect(parent.revokedAt).toBeNull(); + expect((await Session.findByPk(childId))?.revokedAt).toBeNull(); + }); +}); + +describe('a device child on the admin routes (X7)', () => { + const ADMIN_ROLES = ['admin:read', 'admin:write']; + + async function child(grantType: string, roles = ADMIN_ROLES) { + const { client, user, parent } = await signedInToCli( + { stepUpVerifiedAt: new Date(), stepUpMethod: 'webauthn' }, + {}, + roles, + ); + const { token } = await signSubject(client, parent, { grantType }); + const res = await exchange(token); + return { user, parent, token: res.body.token as string, sessionId: res.body.sessionId }; + } + + function adminRead(token: string) { + return request(app).get('/admin/users').set('Authorization', `Bearer ${token}`); + } + + it('is refused on an admin read until it steps up, with the step-up body', async () => { + const { token } = await child(DEVICE_CODE); + + const res = await adminRead(token); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ error: 'step_up_required', fresh: false, method: null }); + }); + + it('borrows nothing from the approving browser', async () => { + const { parent, token, sessionId } = await child(DEVICE_CODE); + + await parent.reload(); + expect(parent.stepUpVerifiedAt).not.toBeNull(); + expect((await adminRead(token)).status).toBe(403); + expect((await Session.findByPk(sessionId))?.stepUpVerifiedAt).toBeNull(); + }); + + it('gets in after a TOTP step-up on its own session', async () => { + const { user, token, sessionId } = await child(DEVICE_CODE); + const { recordStepUpVerification } = await import('../../src/services/stepUpService.js'); + + await recordStepUpVerification({ sessionId, userId: user.id, method: 'totp' }); + + expect((await adminRead(token)).status).toBe(200); + }); + + it('is refused again once the step-up goes stale', async () => { + const { token, sessionId } = await child(DEVICE_CODE); + await Session.update( + { stepUpVerifiedAt: new Date(Date.now() - 6 * 60_000), stepUpMethod: 'totp' }, + { where: { id: sessionId } }, + ); + + expect((await adminRead(token)).status).toBe(403); + }); + + it('still needs the role, and says so before it asks for a step-up', async () => { + const { token } = await child(DEVICE_CODE, ['user']); + + const res = await adminRead(token); + + expect(res.status).toBe(403); + expect(res.body.error).toBe('Forbidden'); + }); + + it('can reach its own account without a step-up', async () => { + const { token } = await child(DEVICE_CODE); + + expect((await me(token)).status).toBe(200); + }); + + it('does not apply to a loopback child, which inherited its step-up', async () => { + const { token } = await child(AUTHORIZATION_CODE); + + expect((await adminRead(token)).status).toBe(200); + }); + + it('does not change a loopback child that has no step-up on an admin read', async () => { + const { client, parent } = await signedInToCli({}, {}, ADMIN_ROLES); + const { token: subject } = await signSubject(client, parent); + const res = await exchange(subject); + + expect((await adminRead(res.body.token)).status).toBe(200); + }); + + it('applies to a rotated device child too', async () => { + const { client, parent } = await signedInToCli({}, {}, ADMIN_ROLES); + const { token: subject } = await signSubject(client, parent, { grantType: DEVICE_CODE }); + const first = await exchange(subject); + const rotated = await request(app) + .post('/refresh') + .set('Authorization', `Bearer ${first.body.refreshToken}`); + + expect((await adminRead(rotated.body.token)).status).toBe(403); + }); +}); + +describe('session lists', () => { + async function family(grantType = DEVICE_CODE) { + const { client, user, parent } = await signedInToCli(); + const { token } = await signSubject(client, parent, { grantType }); + const res = await exchange(token); + return { client, user, parent, child: res.body as Record }; + } + + it('labels a child with its client and grant in /sessions, and leaves the parent plain', async () => { + const { client, parent, child } = await family(); + + const res = await request(app).get('/sessions').set('Authorization', `Bearer ${child.token}`); + + expect(res.status).toBe(200); + const listed = res.body.sessions.find((row: { id: string }) => row.id === child.sessionId); + expect(listed).toMatchObject({ + current: true, + parentSessionId: parent.id, + exchangeClientId: client.id, + exchangeGrantType: DEVICE_CODE, + }); + const plain = res.body.sessions.find((row: { id: string }) => row.id === parent.id); + expect(plain).not.toHaveProperty('exchangeClientId'); + expect(plain).not.toHaveProperty('parentSessionId'); + }); + + it.each([ + ['the user sessions view', (userId: string) => `/admin/sessions/${userId}`], + ['the user detail view', (userId: string) => `/admin/users/${userId}`], + ])('labels a child in %s', async (_name, url) => { + const { client, user, child } = await family(); + const { token } = await admin(); + + const res = await request(app).get(url(user.id)).set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + const listed = res.body.sessions.find((row: { id: string }) => row.id === child.sessionId); + expect(listed).toMatchObject({ + exchangeClientId: client.id, + exchangeGrantType: DEVICE_CODE, + }); + }); + + it('labels a child in the instance-wide view', async () => { + const { client, child } = await family(); + const { token } = await admin(); + + // Newest rows come last, and the route takes at most a page at a time. + const first = await request(app) + .get('/admin/sessions?limit=1') + .set('Authorization', `Bearer ${token}`); + const offset = Math.max(first.body.total - 50, 0); + const res = await request(app) + .get(`/admin/sessions?limit=50&offset=${offset}`) + .set('Authorization', `Bearer ${token}`); + + expect(res.status).toBe(200); + const listed = res.body.sessions.find((row: { id: string }) => row.id === child.sessionId); + expect(listed?.exchangeClientId).toBe(client.id); + }); +}); + +describe('factor changes on a device child', () => { + const ADMIN_ROLES = ['admin:read', 'admin:write']; + + async function childOf(grantType: string, roles = ADMIN_ROLES) { + const { client, user, parent } = await signedInToCli( + { stepUpVerifiedAt: new Date(), stepUpMethod: 'webauthn' }, + {}, + roles, + ); + const { token } = await signSubject(client, parent, { grantType }); + const res = await exchange(token); + expect(res.status).toBe(200); + return { + user, + parent, + token: res.body.token as string, + sessionId: res.body.sessionId as string, + }; + } + + const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); + + function adminRead(token: string) { + return request(app).get('/admin/users').set(bearer(token)); + } + + /** The user's own authenticator, from before the device sign-in. */ + async function enrollTotp(user: User) { + const { startTotpEnrollment, verifyTotpEnrollment } = + await import('../../src/services/totpService.js'); + const { generateTotpCode, getTotpCounter } = await import('../../src/utils/totp.js'); + const { secret } = await startTotpEnrollment({ + userId: user.id, + email: user.email, + issuer: 'Test', + }); + // The previous step's code, so the user's next one is not refused as a repeat. + const enrolled = await verifyTotpEnrollment( + user.id, + generateTotpCode({ secret, counter: getTotpCounter() - 1 }), + ); + expect(enrolled.verified).toBe(true); + return { secret, code: () => generateTotpCode({ secret }) }; + } + + async function credentialsOf(userId: string) { + const { TotpCredential } = await import('../../src/models/totpCredentials.js'); + return TotpCredential.findAll({ where: { userId } }); + } + + function expectStepUpRequired(res: request.Response) { + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ + error: 'step_up_required', + message: 'Recent step-up authentication is required', + fresh: false, + method: null, + maxAgeSeconds: 300, + }); + } + + it('leaves a device child with no TOTP unable to enroll one, so X7 holds', async () => { + const { user, token } = await childOf(DEVICE_CODE); + const { generateTotpCode, generateTotpSecret } = await import('../../src/utils/totp.js'); + const chosen = generateTotpSecret(); + + expectStepUpRequired(await adminRead(token)); + + expectStepUpRequired(await request(app).post('/totp/enroll/start').set(bearer(token))); + expect(await credentialsOf(user.id)).toEqual([]); + + const code = generateTotpCode({ secret: chosen }); + expectStepUpRequired( + await request(app).post('/totp/enroll/verify').set(bearer(token)).send({ code }), + ); + + const stepUp = await request(app).post('/totp/verify-mfa').set(bearer(token)).send({ code }); + expect(stepUp.status).toBe(401); + + expect(await credentialsOf(user.id)).toEqual([]); + expectStepUpRequired(await adminRead(token)); + }); + + it('cannot replace the TOTP of a user who has one', async () => { + const { user, token } = await childOf(DEVICE_CODE); + await enrollTotp(user); + const [original] = await credentialsOf(user.id); + const { generateTotpCode, generateTotpSecret } = await import('../../src/utils/totp.js'); + const code = generateTotpCode({ secret: generateTotpSecret() }); + + expectStepUpRequired(await request(app).post('/totp/enroll/start').set(bearer(token))); + expectStepUpRequired( + await request(app).post('/totp/enroll/verify').set(bearer(token)).send({ code }), + ); + expectStepUpRequired( + await request(app).post('/totp/disable').set(bearer(token)).send({ code }), + ); + + const after = await credentialsOf(user.id); + expect(after).toHaveLength(1); + expect(after[0].id).toBe(original.id); + expect(after[0].enabled).toBe(true); + expectStepUpRequired(await adminRead(token)); + }); + + it('lets a device child through once it steps up with the real TOTP', async () => { + const { user, token } = await childOf(DEVICE_CODE); + const { code } = await enrollTotp(user); + + expectStepUpRequired(await request(app).post('/recovery-codes').set(bearer(token)).send({})); + + const stepUp = await request(app) + .post('/totp/verify-mfa') + .set(bearer(token)) + .send({ code: code() }); + expect(stepUp.status).toBe(200); + expect(stepUp.body.method).toBe('totp'); + + expect((await adminRead(token)).status).toBe(200); + expect((await request(app).post('/recovery-codes').set(bearer(token)).send({})).status).toBe( + 201, + ); + expect((await request(app).post('/totp/enroll/start').set(bearer(token))).status).toBe(200); + }); + + it('refuses again once the step-up goes stale', async () => { + const { token, sessionId } = await childOf(DEVICE_CODE); + await Session.update( + { stepUpVerifiedAt: new Date(Date.now() - 6 * 60_000), stepUpMethod: 'totp' }, + { where: { id: sessionId } }, + ); + + const res = await request(app).post('/totp/enroll/start').set(bearer(token)); + + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ error: 'step_up_required', fresh: false, method: 'totp' }); + }); + + it.each([ + ['post', '/totp/enroll/start'], + ['post', '/totp/enroll/verify'], + ['post', '/totp/disable'], + ['get', '/webauthn/register/start'], + ['post', '/webauthn/register/finish'], + ['post', '/recovery-codes'], + ['delete', '/users/credentials'], + ['post', '/registration/phone'], + ['post', '/registration/phone/verify'], + ['delete', `/recovery/requests/${randomUUID()}`], + ] as ['post' | 'get' | 'delete', string][])( + 'answers step_up_required on %s %s, before reading the request', + async (method, url) => { + const { token } = await childOf(DEVICE_CODE); + + expectStepUpRequired(await request(app)[method](url).set(bearer(token))); + }, + ); + + it('does not delete a credential', async () => { + const { Credential } = await import('../../src/models/credentials.js'); + const { user, token } = await childOf(DEVICE_CODE); + const seed = (id: string) => + Credential.create({ + id, + userId: user.id, + publicKey: Buffer.from('key'), + counter: 0, + transports: [], + deviceType: 'singleDevice', + backedup: false, + } as never); + const id = `cred-${randomUUID()}`; + await seed(id); + await seed(`cred-${randomUUID()}`); + + expectStepUpRequired( + await request(app).delete('/users/credentials').set(bearer(token)).send({ id }), + ); + + expect(await Credential.count({ where: { userId: user.id } })).toBe(2); + }); + + it('does not change the phone on the account', async () => { + const { user, token } = await childOf(DEVICE_CODE); + + expectStepUpRequired( + await request(app) + .post('/registration/phone') + .set(bearer(token)) + .send({ phone: '+14155550123' }), + ); + + await user.reload(); + expect(user.phone).toBeNull(); + }); + + it('does not change a loopback child, with a step-up or without one', async () => { + const { token } = await childOf(AUTHORIZATION_CODE); + expect((await request(app).post('/totp/enroll/start').set(bearer(token))).status).toBe(200); + expect((await request(app).post('/recovery-codes').set(bearer(token)).send({})).status).toBe( + 201, + ); + + const { client, parent } = await signedInToCli(); + const { token: subject } = await signSubject(client, parent); + const plain = await exchange(subject); + expect( + (await request(app).post('/totp/enroll/start').set(bearer(plain.body.token))).status, + ).toBe(200); + }); + + it('does not change a first-party session', async () => { + const user = await createUser(); + const session = await createSession(user.id); + const token = await accessTokenFor(session); + + expect((await request(app).post('/totp/enroll/start').set(bearer(token))).status).toBe(200); + expect((await request(app).post('/recovery-codes').set(bearer(token)).send({})).status).toBe( + 201, + ); + expect((await request(app).get('/webauthn/register/start').set(bearer(token))).status).toBe( + 200, + ); + }); + + it('cannot turn a device child into a parent for another exchange', async () => { + const { client, parent } = await signedInToCli(); + const { token: first } = await signSubject(client, parent, { grantType: DEVICE_CODE }); + const child = await exchange(first); + await store.bindOidcSession(child.body.sessionId, parent.userId, client.id); + await store.upsertOidcPayload('Grant', randomUUID(), { + payload: { accountId: parent.userId, clientId: client.id }, + expiresIn: 3600, + sessionId: child.body.sessionId, + }); + + const childRow = await Session.findByPk(child.body.sessionId); + const { token: second } = await signSubject(client, childRow as Session); + const res = await exchange(second); + + expect(res.status).toBe(400); + expect(res.body).toEqual(TOKEN_FAILURE); + }); + + it('keeps the child and its device grant on an organization switch', async () => { + const { Organization } = await import('../../src/models/organizations.js'); + const { OrganizationMembership } = await import('../../src/models/organizationMemberships.js'); + const { user, token, sessionId } = await childOf(DEVICE_CODE); + const organization = await Organization.create({ + name: 'Org', + slug: `org-${randomUUID()}`, + } as never); + await OrganizationMembership.create({ + userId: user.id, + organizationId: organization.id, + roles: ['member'], + scopes: [], + } as never); + + const res = await request(app) + .post(`/organizations/${organization.id}/switch`) + .set(bearer(token)); + + expect(res.status).toBe(200); + expect(res.body.sessionId).toBe(sessionId); + expectStepUpRequired(await adminRead(res.body.token)); + }); +}); + +describe('organization routes on a device child', () => { + const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); + + async function organizationWith(ownerId: string | null) { + const { Organization } = await import('../../src/models/organizations.js'); + const { OrganizationMembership } = await import('../../src/models/organizationMemberships.js'); + const member = await createUser(); + const organization = await Organization.create({ + name: 'Before', + slug: `org-${randomUUID()}`, + } as never); + const membership = await OrganizationMembership.create({ + userId: member.id, + organizationId: organization.id, + roles: ['member'], + scopes: [], + } as never); + if (ownerId) { + await OrganizationMembership.create({ + userId: ownerId, + organizationId: organization.id, + roles: ['owner'], + scopes: [], + } as never); + } + return { organization, member, membership }; + } + + /** A child acting through a global admin role, with no membership of the organization. */ + async function adminPersona(grantType: string) { + const { client, user, parent } = await signedInToCli({}, {}, ['admin:read', 'admin:write']); + const { token: subject } = await signSubject(client, parent, { grantType }); + const res = await exchange(subject); + return { user, token: res.body.token as string, ...(await organizationWith(null)) }; + } + + /** A child acting as an ordinary owner of the organization, with no global admin role. */ + async function ownerPersona(grantType: string) { + const { client, user, parent } = await signedInToCli(); + const { token: subject } = await signSubject(client, parent, { grantType }); + const res = await exchange(subject); + return { user, token: res.body.token as string, ...(await organizationWith(user.id)) }; + } + + const personas = [ + ['a global admin', adminPersona], + ['an ordinary organization owner', ownerPersona], + ] as const; + + function expectStepUpRequired(res: request.Response) { + expect(res.status).toBe(403); + expect(res.body).toMatchObject({ error: 'step_up_required', fresh: false, method: null }); + } + + async function stepUpWithRealTotp(user: User, token: string) { + const { startTotpEnrollment, verifyTotpEnrollment } = + await import('../../src/services/totpService.js'); + const { generateTotpCode, getTotpCounter } = await import('../../src/utils/totp.js'); + const { secret } = await startTotpEnrollment({ + userId: user.id, + email: user.email, + issuer: 'Test', + }); + await verifyTotpEnrollment( + user.id, + generateTotpCode({ secret, counter: getTotpCounter() - 1 }), + ); + const stepUp = await request(app) + .post('/totp/verify-mfa') + .set(bearer(token)) + .send({ code: generateTotpCode({ secret }) }); + expect(stepUp.status).toBe(200); + } + + describe.each(personas)('acting as %s', (_name, persona) => { + it('cannot read the member list, with its emails', async () => { + const { token, organization } = await persona(DEVICE_CODE); + + const res = await request(app) + .get(`/organizations/${organization.id}/members`) + .set(bearer(token)); + + expectStepUpRequired(res); + expect(JSON.stringify(res.body)).not.toContain('@session-exchange.test'); + }); + + it('cannot change a member to owner', async () => { + const { token, organization, member, membership } = await persona(DEVICE_CODE); + + expectStepUpRequired( + await request(app) + .patch(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)) + .send({ roles: ['owner'] }), + ); + + await membership.reload(); + expect(membership.roles).toEqual(['member']); + }); + + it('cannot remove a member', async () => { + const { token, organization, member, membership } = await persona(DEVICE_CODE); + + expectStepUpRequired( + await request(app) + .delete(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)), + ); + + await expect(membership.reload()).resolves.toBeDefined(); + }); + + it('cannot add a member', async () => { + const { token, organization } = await persona(DEVICE_CODE); + const stranger = await createUser(); + + expectStepUpRequired( + await request(app) + .post(`/organizations/${organization.id}/members`) + .set(bearer(token)) + .send({ userId: stranger.id, roles: ['member'] }), + ); + }); + + it('cannot get or change the organization', async () => { + const { token, organization } = await persona(DEVICE_CODE); + + expectStepUpRequired( + await request(app).get(`/organizations/${organization.id}`).set(bearer(token)), + ); + expectStepUpRequired( + await request(app) + .patch(`/organizations/${organization.id}`) + .set(bearer(token)) + .send({ name: 'After' }), + ); + + await organization.reload(); + expect(organization.name).toBe('Before'); + }); + + it('cannot list or create organizations', async () => { + const { token } = await persona(DEVICE_CODE); + + expectStepUpRequired(await request(app).get('/organizations').set(bearer(token))); + expectStepUpRequired( + await request(app) + .post('/organizations') + .set(bearer(token)) + .send({ name: 'New', slug: `new-${randomUUID()}` }), + ); + }); + + it('can still switch organization, and X7 still applies afterwards', async () => { + const { token, organization, user } = await persona(DEVICE_CODE); + const { OrganizationMembership } = + await import('../../src/models/organizationMemberships.js'); + await OrganizationMembership.findOrCreate({ + where: { userId: user.id, organizationId: organization.id }, + defaults: { + userId: user.id, + organizationId: organization.id, + roles: ['member'], + scopes: [], + } as never, + }); + + const res = await request(app) + .post(`/organizations/${organization.id}/switch`) + .set(bearer(token)); + + expect(res.status).toBe(200); + expectStepUpRequired( + await request(app).get(`/organizations/${organization.id}`).set(bearer(res.body.token)), + ); + }); + + it('proceeds once it steps up with the real TOTP', async () => { + const { user, token, organization, member, membership } = await persona(DEVICE_CODE); + await stepUpWithRealTotp(user, token); + + const members = await request(app) + .get(`/organizations/${organization.id}/members`) + .set(bearer(token)); + expect(members.status).toBe(200); + expect(JSON.stringify(members.body)).toContain(member.email); + + expect((await request(app).get('/organizations').set(bearer(token))).status).toBe(200); + expect( + (await request(app).get(`/organizations/${organization.id}`).set(bearer(token))).status, + ).toBe(200); + expect( + ( + await request(app) + .patch(`/organizations/${organization.id}`) + .set(bearer(token)) + .send({ name: 'After' }) + ).status, + ).toBe(200); + expect( + ( + await request(app) + .patch(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)) + .send({ roles: ['admin'] }) + ).status, + ).toBe(200); + await membership.reload(); + expect(membership.roles).toEqual(['admin']); + expect( + ( + await request(app) + .delete(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)) + ).status, + ).toBe(200); + }); + + it('does not change a loopback child', async () => { + const { token, organization, member } = await persona(AUTHORIZATION_CODE); + + expect( + (await request(app).get(`/organizations/${organization.id}/members`).set(bearer(token))) + .status, + ).toBe(200); + expect( + ( + await request(app) + .patch(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)) + .send({ roles: ['admin'] }) + ).status, + ).toBe(200); + }); + }); + + it.each([ + ['a global admin', ['admin:read', 'admin:write'], false], + ['an ordinary organization owner', ['user'], true], + ] as [string, string[], boolean][])( + 'does not change a first-party session acting as %s', + async (_name, roles, owner) => { + const user = await createUser(roles); + const session = await createSession(user.id); + const token = await accessTokenFor(session, roles); + const { organization, member } = await organizationWith(owner ? user.id : null); + + expect( + (await request(app).get(`/organizations/${organization.id}/members`).set(bearer(token))) + .status, + ).toBe(200); + expect( + ( + await request(app) + .patch(`/organizations/${organization.id}/members/${member.id}`) + .set(bearer(token)) + .send({ roles: ['admin'] }) + ).status, + ).toBe(200); + }, + ); +}); + +describe('a passkey step-up on a device child', () => { + const RP_ID = 'localhost'; + const ORIGIN = 'http://localhost:5174'; + const bearer = (token: string) => ({ Authorization: `Bearer ${token}` }); + + /** The user's own passkey, registered from a first-party session, and a device sign-in. */ + async function signedInWithPasskey() { + const { SoftwareAuthenticator } = await import('../factories/softwareAuthenticator.js'); + const { client, user, parent } = await signedInToCli({}, {}, ['admin:read']); + const browser = await accessTokenFor(parent, ['admin:read']); + const passkey = new SoftwareAuthenticator(); + + const start = await request(app).get('/webauthn/register/start').set(bearer(browser)); + expect(start.status).toBe(200); + const finish = await request(app) + .post('/webauthn/register/finish') + .set(bearer(browser)) + .send({ + attestationResponse: passkey.register({ + challenge: start.body.challenge, + origin: ORIGIN, + rpId: RP_ID, + }), + }); + expect(finish.status).toBe(200); + + const { token: subject } = await signSubject(client, parent, { grantType: DEVICE_CODE }); + const child = await exchange(subject); + return { passkey, token: child.body.token as string }; + } + + async function assertWith( + token: string, + authenticator: { + authenticate: (params: { challenge: string; origin: string; rpId: string }) => unknown; + }, + origin = ORIGIN, + ) { + const start = await request(app).post('/step-up/webauthn/start').set(bearer(token)).send({}); + expect(start.status).toBe(200); + return request(app) + .post('/step-up/webauthn/finish') + .set(bearer(token)) + .send({ + assertionResponse: authenticator.authenticate({ + challenge: start.body.challenge, + origin, + rpId: RP_ID, + }), + }); + } + + function adminRead(token: string) { + return request(app).get('/admin/users').set(bearer(token)); + } + + it('is accepted with the user own passkey on the relying party domain', async () => { + const { passkey, token } = await signedInWithPasskey(); + expect((await adminRead(token)).status).toBe(403); + + const res = await assertWith(token, passkey); + + expect(res.status).toBe(200); + expect(res.body.method).toBe('webauthn'); + expect((await adminRead(token)).status).toBe(200); + }); + + it('is refused from another origin, which is what a phisher would have', async () => { + const { passkey, token } = await signedInWithPasskey(); + + const res = await assertWith(token, passkey, 'https://phish.example.com'); + + expect(res.status).not.toBe(200); + expect((await adminRead(token)).status).toBe(403); + }); + + it('is refused from a key that is not the registered one', async () => { + const { SoftwareAuthenticator } = await import('../factories/softwareAuthenticator.js'); + const { passkey, token } = await signedInWithPasskey(); + const forged = new SoftwareAuthenticator(Buffer.from(passkey.credentialId)); + + const res = await assertWith(token, forged); + + expect(res.status).not.toBe(200); + expect((await adminRead(token)).status).toBe(403); + }); +}); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 5137ae7f..fbe41216 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -175,6 +175,7 @@ vi.mock('../../src/models/sessions.js', () => ({ Session: { create: vi.fn(), findAll: vi.fn(), + findByPk: vi.fn(), findOne: vi.fn(), count: vi.fn(), update: vi.fn(async () => [0]), @@ -223,6 +224,7 @@ vi.mock('../../src/services/sessionService.js', () => ({ verifyJwtWithKid: vi.fn(), revokeSessionChain: vi.fn(), hardRevokeSession: vi.fn(), + revokeOrphanedChildSessions: vi.fn(), liftRecoveryRestriction: vi.fn(async () => true), })); @@ -268,9 +270,13 @@ vi.mock('../../src/middleware/authenticateServiceToken.js', () => ({ })); vi.mock('../../src/middleware/requireAdmin.js', () => ({ - requireAdmin: () => (_req: any, _res: any, next: any) => { - next(); - }, + requireAdmin: () => + Object.assign( + (_req: any, _res: any, next: any) => { + next(); + }, + { seamlessDeviceStepUp: true }, + ), })); vi.mock('../../src/middleware/rateLimit.js', () => ({ @@ -278,6 +284,7 @@ vi.mock('../../src/middleware/rateLimit.js', () => ({ magicLinkEmailLimiter: (_req: any, _res: any, next: any) => next(), oauthIpLimiter: (_req: any, _res: any, next: any) => next(), oauthProviderLimiter: (_req: any, _res: any, next: any) => next(), + sessionExchangeLimiter: (_req: any, _res: any, next: any) => next(), otpIpLimiter: (_req: any, _res: any, next: any) => next(), otpIdentityLimiter: (_req: any, _res: any, next: any) => next(), dynamicRateLimit: (_req: any, _res: any, next: any) => next(), diff --git a/tests/unit/middleware/rateLimit.spec.ts b/tests/unit/middleware/rateLimit.spec.ts index 8529e843..b7e0c3dc 100644 --- a/tests/unit/middleware/rateLimit.spec.ts +++ b/tests/unit/middleware/rateLimit.spec.ts @@ -208,6 +208,7 @@ describe('flow limiters', () => { ['magicLinkIpLimiter', 20], ['magicLinkEmailLimiter', 20], ['otpIpLimiter', 10], + ['sessionExchangeLimiter', 30], ['otpIdentityLimiter', 20], ['oauthIpLimiter', 30], ['oauthProviderLimiter', 10], diff --git a/tests/unit/middleware/requireAdmin.spec.ts b/tests/unit/middleware/requireAdmin.spec.ts index 68c35829..b36211c3 100644 --- a/tests/unit/middleware/requireAdmin.spec.ts +++ b/tests/unit/middleware/requireAdmin.spec.ts @@ -151,4 +151,42 @@ describe('requireAdmin', () => { error: 'Internal server error', }); }); + + describe('a session exchanged from a device grant', () => { + const admin = { id: 'user-1', roles: ['admin:read'] }; + + it('needs a fresh step-up, even to read', async () => { + const { requireAdmin } = await import('../../../src/middleware/requireAdmin'); + req.user = admin; + req.deviceChildSession = { stepUpVerifiedAt: null, stepUpMethod: null }; + + await requireAdmin('read')(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'step_up_required', fresh: false }), + ); + expect(next).not.toHaveBeenCalled(); + }); + + it('passes once it has one', async () => { + const { requireAdmin } = await import('../../../src/middleware/requireAdmin'); + req.user = admin; + req.deviceChildSession = { stepUpVerifiedAt: new Date(), stepUpMethod: 'totp' }; + + await requireAdmin('read')(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + it('checks the role first', async () => { + const { requireAdmin } = await import('../../../src/middleware/requireAdmin'); + req.user = { id: 'user-1', roles: ['user'] }; + req.deviceChildSession = { stepUpVerifiedAt: null, stepUpMethod: null }; + + await requireAdmin('read')(req, res, next); + + expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' }); + }); + }); }); diff --git a/tests/unit/middleware/requireDeviceSessionStepUp.spec.ts b/tests/unit/middleware/requireDeviceSessionStepUp.spec.ts new file mode 100644 index 00000000..fcfd07ab --- /dev/null +++ b/tests/unit/middleware/requireDeviceSessionStepUp.spec.ts @@ -0,0 +1,55 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { requireDeviceSessionStepUp } from '../../../src/middleware/requireDeviceSessionStepUp'; + +describe('requireDeviceSessionStepUp', () => { + let res: any; + let next: any; + + beforeEach(() => { + res = { status: vi.fn().mockReturnThis(), json: vi.fn() }; + next = vi.fn(); + }); + + it('passes every session that is not from a device grant', () => { + requireDeviceSessionStepUp({} as never, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('refuses a session from a device grant with no step-up', () => { + const req = { deviceChildSession: { stepUpVerifiedAt: null, stepUpMethod: null } }; + + requireDeviceSessionStepUp(req as never, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'step_up_required', fresh: false, method: null }), + ); + }); + + it('refuses a stale step-up', () => { + const req = { + deviceChildSession: { + stepUpVerifiedAt: new Date(Date.now() - 6 * 60_000), + stepUpMethod: 'totp', + }, + }; + + requireDeviceSessionStepUp(req as never, res, next); + + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: 'step_up_required', fresh: false, method: 'totp' }), + ); + }); + + it('passes a fresh step-up', () => { + const req = { deviceChildSession: { stepUpVerifiedAt: new Date(), stepUpMethod: 'totp' } }; + + requireDeviceSessionStepUp(req as never, res, next); + + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/middleware/verifyBearerAuth.spec.ts b/tests/unit/middleware/verifyBearerAuth.spec.ts index 06a89cf1..e083d3a9 100644 --- a/tests/unit/middleware/verifyBearerAuth.spec.ts +++ b/tests/unit/middleware/verifyBearerAuth.spec.ts @@ -175,6 +175,21 @@ describe('verifyBearerAuth', () => { expect(next).toHaveBeenCalled(); }); + it('attaches the step-up state of a session exchanged from a device grant', async () => { + req.headers.authorization = 'Bearer token'; + const deviceChildSession = { stepUpVerifiedAt: null, stepUpMethod: null }; + + (validateBearerToken as any).mockResolvedValue({ + user: { id: 'user-1' }, + sessionId: 'session-1', + deviceChildSession, + }); + + await verifyBearerAuth(req, res, next); + + expect(req.deviceChildSession).toBe(deviceChildSession); + }); + it('returns 401 if validation throws', async () => { req.headers.authorization = 'Bearer token'; diff --git a/tests/unit/services/oidcPayloadStore.spec.ts b/tests/unit/services/oidcPayloadStore.spec.ts index 618ef5af..994f4d32 100644 --- a/tests/unit/services/oidcPayloadStore.spec.ts +++ b/tests/unit/services/oidcPayloadStore.spec.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../../src/models/oidcPayloads', () => ({ OidcPayload: { + create: vi.fn(), upsert: vi.fn(), findOne: vi.fn(), update: vi.fn(), @@ -467,3 +468,38 @@ describe('startOidcPayloadPurge', () => { } }); }); + +describe('recordSessionExchangeToken', () => { + const payload = { clientId: 'cli', accountId: 'user-1', grantType: 'authorization_code' }; + const expiresAt = new Date('2026-11-04T12:00:00.000Z'); + + it('inserts under a model the edge routes cannot name', async () => { + const { recordSessionExchangeToken } = await import('../../../src/services/oidcPayloadStore'); + + expect(await recordSessionExchangeToken('jti', 'session-1', payload, expiresAt)).toBe(true); + expect(OidcPayload.create).toHaveBeenCalledWith({ + id: 'jti', + model: 'SessionExchangeToken', + payload, + sessionId: 'session-1', + expiresAt, + }); + }); + + it('refuses a jti it has seen, without touching the record', async () => { + const { UniqueConstraintError } = await import('sequelize'); + const { recordSessionExchangeToken } = await import('../../../src/services/oidcPayloadStore'); + vi.mocked(OidcPayload.create).mockRejectedValue(new UniqueConstraintError({})); + + expect(await recordSessionExchangeToken('jti', 'session-1', payload, expiresAt)).toBe(false); + }); + + it('lets any other failure surface', async () => { + const { recordSessionExchangeToken } = await import('../../../src/services/oidcPayloadStore'); + vi.mocked(OidcPayload.create).mockRejectedValue(new Error('down')); + + await expect( + recordSessionExchangeToken('jti', 'session-1', payload, expiresAt), + ).rejects.toThrow('down'); + }); +}); diff --git a/tests/unit/services/oidcSigning.spec.ts b/tests/unit/services/oidcSigning.spec.ts index ab35d269..1bf3a7ba 100644 --- a/tests/unit/services/oidcSigning.spec.ts +++ b/tests/unit/services/oidcSigning.spec.ts @@ -10,6 +10,7 @@ vi.mock('../../../src/services/oidcClientRegistry', () => ({ vi.mock('../../../src/services/oidcPayloadStore', () => ({ hasOidcGrantForSession: vi.fn(), + recordSessionExchangeToken: vi.fn(), })); vi.mock('../../../src/services/oidcAuthContext', async (importOriginal) => ({ @@ -30,7 +31,10 @@ import { getOidcAccountClaims, getOidcClientResourceScopes, } from '../../../src/services/oidcClientRegistry'; -import { hasOidcGrantForSession } from '../../../src/services/oidcPayloadStore'; +import { + hasOidcGrantForSession, + recordSessionExchangeToken, +} from '../../../src/services/oidcPayloadStore'; import { getActiveOidcSigningKey, OidcProviderKeysError, @@ -565,3 +569,124 @@ describe('signForOidcEdge', () => { }); }); }); + +describe('a session:exchange token', () => { + const BUILT_IN = `${ISSUER}/seamless`; + const DEVICE = 'urn:ietf:params:oauth:grant-type:device_code'; + + function exchangeRequest(claims: Record = {}, grantType = 'authorization_code') { + const issuedAt = now(); + return { + kind: 'access_token' as const, + kid: 'k1', + clientId: 'cli', + sessionId: SESSION, + grantType, + signingInput: input( + { alg: 'RS256', kid: 'k1', typ: 'at+jwt' }, + { + iss: ISSUER, + sub: 'user-1', + aud: BUILT_IN, + iat: issuedAt, + exp: issuedAt + 60, + jti: 'jti-1', + client_id: 'cli', + scope: 'session:exchange', + ...claims, + }, + ), + }; + } + + beforeEach(() => { + vi.mocked(getSystemConfig).mockResolvedValue({ + oidc_issuer: ISSUER, + session_idle_ttl: '8h', + } as never); + vi.mocked(getActiveOidcSigningKey).mockReturnValue(privateKey); + vi.mocked(findActiveOidcClient).mockResolvedValue({ + id: 'cli', + grantTypes: ['authorization_code', DEVICE, 'refresh_token', 'client_credentials'], + firstParty: true, + deviceFlowEnabled: true, + } as never); + vi.mocked(getOidcClientResourceScopes).mockResolvedValue( + new Map([ + [BUILT_IN, new Set(['session:exchange', 'notices:send'])], + [API, new Set(['session:exchange'])], + ]), + ); + vi.mocked(resolveCurrentSession).mockResolvedValue(passkeySession as never); + vi.mocked(hasOidcGrantForSession).mockResolvedValue(true); + vi.mocked(recordSessionExchangeToken).mockResolvedValue(true); + }); + + it('is signed and recorded', async () => { + const outcome = await signForOidcEdge(exchangeRequest({}, DEVICE)); + + expect(outcome).toMatchObject({ ok: true }); + expect(recordSessionExchangeToken).toHaveBeenCalledWith( + 'jti-1', + SESSION, + { clientId: 'cli', accountId: 'user-1', grantType: DEVICE }, + expect.any(Date), + ); + }); + + it('is refused when the jti was seen before', async () => { + vi.mocked(recordSessionExchangeToken).mockResolvedValue(false); + + expect(await signForOidcEdge(exchangeRequest())).toMatchObject({ + ok: false, + code: 'malformed_signing_input', + }); + }); + + it.each([ + ['another scope', { scope: 'session:exchange notices:send' }, 'grant_not_allowed'], + ['another resource', { aud: API }, 'audience_mismatch'], + ['no jti', { jti: undefined }, 'malformed_signing_input'], + ['an empty jti', { jti: '' }, 'malformed_signing_input'], + ['no iat', { iat: undefined }, 'malformed_signing_input'], + ['a long lifetime', { exp: now() + 600 }, 'malformed_signing_input'], + ['a future iat', { iat: now() + 1000, exp: now() + 1100 }, 'malformed_signing_input'], + ] as [string, Record, string][])( + 'is refused with %s', + async (_name, claims, code) => { + expect(await signForOidcEdge(exchangeRequest(claims))).toMatchObject({ ok: false, code }); + expect(recordSessionExchangeToken).not.toHaveBeenCalled(); + }, + ); + + it('is refused on a refresh grant', async () => { + expect(await signForOidcEdge(exchangeRequest({}, 'refresh_token'))).toMatchObject({ + ok: false, + code: 'grant_not_allowed', + }); + }); + + it('is refused on a client credentials token', async () => { + const issuedAt = now(); + const outcome = await signForOidcEdge({ + kind: 'client_credentials', + kid: 'k1', + clientId: 'cli', + signingInput: input( + { alg: 'RS256', kid: 'k1', typ: 'at+jwt' }, + { + iss: ISSUER, + sub: 'cli', + aud: BUILT_IN, + iat: issuedAt, + exp: issuedAt + 60, + jti: 'jti-1', + client_id: 'cli', + scope: 'session:exchange', + }, + ), + } as never); + + expect(outcome).toMatchObject({ ok: false, code: 'grant_not_allowed' }); + }); +}); diff --git a/tests/unit/services/sessionExchangeService.spec.ts b/tests/unit/services/sessionExchangeService.spec.ts new file mode 100644 index 00000000..a8e0f673 --- /dev/null +++ b/tests/unit/services/sessionExchangeService.spec.ts @@ -0,0 +1,358 @@ +import { generateKeyPairSync, randomUUID } from 'crypto'; +import { SignJWT } from 'jose'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { provider } = vi.hoisted(() => ({ provider: { jwk: {} as Record } })); + +vi.mock('../../../src/models/oidcPayloads', () => ({ + OidcPayload: { + findOne: vi.fn(), + update: vi.fn(), + sequelize: { transaction: (fn: (transaction: unknown) => unknown) => fn({}) }, + }, +})); + +vi.mock('../../../src/services/oidcAuthContext', async (importOriginal) => ({ + ...(await importOriginal()), + resolveCurrentSession: vi.fn(), +})); + +vi.mock('../../../src/services/oidcClientRegistry', async (importOriginal) => ({ + ...(await importOriginal()), + findActiveOidcClient: vi.fn(), +})); + +vi.mock('../../../src/services/oidcProviderKeys', () => ({ + getOidcPublicJwks: vi.fn(() => ({ activeKid: 'k1', keys: [provider.jwk] })), +})); + +vi.mock('../../../src/services/authEventService', () => ({ + AuthEventService: { log: vi.fn() }, +})); + +vi.mock('../../../src/lib/token', () => ({ + generateRefreshToken: vi.fn(() => 'refresh-token'), + createRefreshTokenLookup: vi.fn(() => 'lookup'), + signAccessToken: vi.fn(async () => 'access-token'), +})); + +import { getSystemConfig } from '../../../src/config/getSystemConfig'; +import { OidcPayload } from '../../../src/models/oidcPayloads'; +import { Session } from '../../../src/models/sessions'; +import { User } from '../../../src/models/users'; +import { AuthEventService } from '../../../src/services/authEventService'; +import { resolveCurrentSession } from '../../../src/services/oidcAuthContext'; +import { findActiveOidcClient } from '../../../src/services/oidcClientRegistry'; +import { getOidcPublicJwks } from '../../../src/services/oidcProviderKeys'; +import { exchangeSubjectToken } from '../../../src/services/sessionExchangeService'; +import { revokeSessionChain } from '../../../src/services/sessionService'; + +const ISSUER = 'https://login.example.com'; +const RESOURCE = `${ISSUER}/seamless`; +const USER = '11111111-1111-4111-8111-111111111111'; +const PARENT = '22222222-2222-4222-8222-222222222222'; +const CHILD = '33333333-3333-4333-8333-333333333333'; +const CLIENT = 'cli'; +const DEVICE = 'urn:ietf:params:oauth:grant-type:device_code'; + +const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +provider.jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'k1', alg: 'RS256' }; + +const req = { ip: '127.0.0.1', get: () => 'test-agent' } as never; + +function sign(claims: Record = {}, typ = 'at+jwt') { + const now = Math.floor(Date.now() / 1000); + return new SignJWT({ + iss: ISSUER, + sub: USER, + aud: RESOURCE, + iat: now, + exp: now + 60, + jti: 'jti-1', + client_id: CLIENT, + scope: 'session:exchange', + ...claims, + }) + .setProtectedHeader({ alg: 'RS256', kid: 'k1', typ }) + .sign(privateKey); +} + +function record(overrides: Record = {}) { + return { + id: 'jti-1', + sessionId: PARENT, + consumedAt: null, + expiresAt: new Date(Date.now() + 60_000), + payload: { clientId: CLIENT, accountId: USER, grantType: 'authorization_code' }, + ...overrides, + }; +} + +function client(overrides: Record = {}) { + return { + id: CLIENT, + firstParty: true, + clientType: 'public', + tokenEndpointAuthMethod: 'none', + redirectUris: ['http://127.0.0.1/callback'], + sessionExchangeEnabled: true, + ...overrides, + }; +} + +const stepUpVerifiedAt = new Date(); +const authTime = new Date('2026-11-04T12:00:00.000Z'); + +function parent(overrides: Record = {}) { + return { + id: PARENT, + userId: USER, + infraId: 'infra', + organizationId: 'org', + recovery: false, + parentSessionId: null, + authMethods: [{ method: 'passkey' }], + authTime, + stepUpVerifiedAt, + stepUpMethod: 'webauthn', + expiresAt: new Date(Date.now() + 60_000), + ...overrides, + }; +} + +function created() { + return vi.mocked(Session.create).mock.calls[0][0] as unknown as Record; +} + +beforeEach(() => { + vi.mocked(getSystemConfig).mockResolvedValue({ + oidc_issuer: ISSUER, + access_token_ttl: '15m', + refresh_token_ttl: '7d', + session_idle_ttl: '8h', + } as never); + vi.mocked(getOidcPublicJwks).mockImplementation( + () => ({ activeKid: 'k1', keys: [provider.jwk] }) as never, + ); + vi.mocked(OidcPayload.findOne).mockResolvedValue(record() as never); + vi.mocked(OidcPayload.update).mockResolvedValue([1] as never); + vi.mocked(findActiveOidcClient).mockResolvedValue(client() as never); + vi.mocked(resolveCurrentSession).mockResolvedValue(parent() as never); + vi.mocked(User.findOne).mockResolvedValue({ + id: USER, + email: 'a@example.com', + phone: null, + roles: ['user'], + } as never); + vi.mocked(Session.create).mockResolvedValue({ id: CHILD, parentSessionId: PARENT } as never); +}); + +describe('exchangeSubjectToken', () => { + it('issues a child capped at the parent, carrying its assurance', async () => { + const outcome = await exchangeSubjectToken(await sign(), req); + + expect(outcome).toMatchObject({ + ok: true, + response: { + token: 'access-token', + refreshToken: 'refresh-token', + sessionId: CHILD, + sub: USER, + issued_token_type: 'urn:ietf:params:oauth:token-type:refresh_token', + }, + }); + const row = created(); + expect(row).toMatchObject({ + parentSessionId: PARENT, + exchangeClientId: CLIENT, + exchangeGrantType: 'authorization_code', + organizationId: 'org', + authTime, + stepUpVerifiedAt, + stepUpMethod: 'webauthn', + }); + expect((row.expiresAt as Date).getTime()).toBeLessThanOrEqual(Date.now() + 60_000); + expect(vi.mocked(AuthEventService.log).mock.calls[0][0]).toMatchObject({ + type: 'session_exchanged', + sessionId: CHILD, + }); + }); + + it('falls back to the chain start for a parent with no recorded auth time', async () => { + const chainStartedAt = new Date('2026-11-01T00:00:00.000Z'); + vi.mocked(resolveCurrentSession).mockResolvedValue( + parent({ authTime: null, chainStartedAt }) as never, + ); + + await exchangeSubjectToken(await sign(), req); + + expect(created().authTime).toEqual(chainStartedAt); + }); + + it('copies no assurance for a device grant', async () => { + vi.mocked(OidcPayload.findOne).mockResolvedValue( + record({ payload: { clientId: CLIENT, accountId: USER, grantType: DEVICE } }) as never, + ); + + await exchangeSubjectToken(await sign(), req); + + expect(created()).toMatchObject({ + exchangeGrantType: DEVICE, + authMethods: [], + authTime: null, + stepUpVerifiedAt: null, + stepUpMethod: null, + }); + }); + + it.each([ + ['another audience', { aud: 'https://api.example.com' }], + ['an audience list', { aud: [RESOURCE, 'https://api.example.com'] }], + ['an extra scope', { scope: 'session:exchange notices:send' }], + ['a client as subject', { sub: CLIENT }], + ['no client_id', { client_id: undefined }], + ['a numeric client_id', { client_id: 7 }], + ['no jti', { jti: undefined }], + ])('refuses a token with %s', async (_name, claims) => { + expect(await exchangeSubjectToken(await sign(claims), req)).toEqual({ + ok: false, + replayed: false, + }); + expect(OidcPayload.update).not.toHaveBeenCalled(); + }); + + it('refuses a token that is not at+jwt', async () => { + expect(await exchangeSubjectToken(await sign({}, 'JWT'), req)).toMatchObject({ ok: false }); + }); + + it('refuses when no provider is configured', async () => { + vi.mocked(getSystemConfig).mockResolvedValue({} as never); + + expect(await exchangeSubjectToken(await sign(), req)).toMatchObject({ ok: false }); + }); + + it('refuses when provider keys are unusable', async () => { + vi.mocked(getOidcPublicJwks).mockImplementation(() => { + throw new Error('no keys'); + }); + + expect(await exchangeSubjectToken(await sign(), req)).toMatchObject({ ok: false }); + }); + + it.each([ + ['no record', () => null], + ['another user', () => record({ payload: { clientId: CLIENT, accountId: randomUUID() } })], + ['another client', () => record({ payload: { clientId: 'x', accountId: USER } })], + ['an expired record', () => record({ expiresAt: new Date(Date.now() - 1) })], + [ + 'a grant that cannot exchange', + () => record({ payload: { clientId: CLIENT, accountId: USER, grantType: 'refresh_token' } }), + ], + ])('refuses %s', async (_name, found) => { + vi.mocked(OidcPayload.findOne).mockResolvedValue(found() as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + it.each([ + ['disabled', null], + ['with the flag off', client({ sessionExchangeEnabled: false })], + ['third-party', client({ firstParty: false })], + ])('refuses a client that is %s', async (_name, found) => { + vi.mocked(findActiveOidcClient).mockResolvedValue(found as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + it.each([ + ['gone', null], + ['another user', parent({ userId: randomUUID() })], + ['a recovery session', parent({ recovery: true })], + ['itself a child', parent({ parentSessionId: randomUUID() })], + ])('refuses a parent that is %s', async (_name, found) => { + vi.mocked(resolveCurrentSession).mockResolvedValue(found as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + it('refuses a record with no session', async () => { + vi.mocked(OidcPayload.findOne).mockResolvedValue(record({ sessionId: null }) as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + it('refuses a revoked user', async () => { + vi.mocked(User.findOne).mockResolvedValue(null as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + describe('a replay', () => { + const consumed = () => + record({ + consumedAt: new Date(), + payload: { clientId: CLIENT, accountId: USER, childSessionId: CHILD }, + }); + + it('revokes what the first use created', async () => { + vi.mocked(OidcPayload.findOne).mockResolvedValue(consumed() as never); + const child = { id: CHILD, parentSessionId: PARENT }; + vi.mocked(Session.findByPk).mockResolvedValue(child as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: true }); + + expect(revokeSessionChain).toHaveBeenCalledWith(child, 'session_exchange_replayed'); + expect(vi.mocked(AuthEventService.log).mock.calls[0][0]).toMatchObject({ + type: 'session_exchange_replayed', + sessionId: CHILD, + metadata: { clientId: CLIENT, parentSessionId: PARENT }, + }); + }); + + it('is still refused when the child is gone', async () => { + vi.mocked(OidcPayload.findOne).mockResolvedValue( + record({ + consumedAt: new Date(), + payload: { clientId: CLIENT, accountId: USER }, + }) as never, + ); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: true }); + expect(revokeSessionChain).not.toHaveBeenCalled(); + }); + + it('is caught when another presentation wins the consume', async () => { + vi.mocked(OidcPayload.update).mockResolvedValue([0] as never); + vi.mocked(OidcPayload.findOne) + .mockResolvedValueOnce(record() as never) + .mockResolvedValueOnce(consumed() as never); + vi.mocked(Session.findByPk).mockResolvedValue({ id: CHILD } as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: true }); + expect(Session.create).not.toHaveBeenCalled(); + }); + }); + + it('refuses without a replay when the record expires during the consume', async () => { + vi.mocked(OidcPayload.update).mockResolvedValue([0] as never); + vi.mocked(OidcPayload.findOne) + .mockResolvedValueOnce(record() as never) + .mockResolvedValueOnce(record() as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + }); + + it('revokes the child when the client is switched off during the exchange', async () => { + vi.mocked(findActiveOidcClient) + .mockResolvedValueOnce(client() as never) + .mockResolvedValueOnce(client({ sessionExchangeEnabled: false }) as never); + + expect(await exchangeSubjectToken(await sign(), req)).toEqual({ ok: false, replayed: false }); + + expect(revokeSessionChain).toHaveBeenCalledWith( + expect.objectContaining({ id: CHILD }), + 'session_exchange_disabled', + ); + expect(AuthEventService.log).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/services/sessionService.spec.ts b/tests/unit/services/sessionService.spec.ts index fd22763c..e456346e 100644 --- a/tests/unit/services/sessionService.spec.ts +++ b/tests/unit/services/sessionService.spec.ts @@ -45,6 +45,11 @@ vi.mock('jsonwebtoken', () => ({ }, })); +vi.mock('../../../src/services/oidcAuthContext', async (importOriginal) => ({ + ...(await importOriginal()), + resolveCurrentSession: vi.fn(), +})); + describe('sessionService', () => { beforeEach(() => { vi.resetModules(); @@ -842,3 +847,87 @@ describe('liftRecoveryRestriction', () => { expect(await liftRecoveryRestriction(undefined)).toBe(false); }); }); + +describe('exchanged sessions', () => { + const future = new Date(Date.now() + 60_000); + const childRow = { + id: 'child-1', + userId: 'user-1', + parentSessionId: 'parent-1', + expiresAt: future, + idleExpiresAt: future, + createdAt: new Date(), + }; + + beforeEach(async () => { + const { User } = await import('../../../src/models/users'); + vi.mocked(User.findOne).mockResolvedValue({ + id: 'user-1', + sessionsRevokedBefore: null, + } as never); + }); + + it('refuses a child whose parent no longer resolves to a live session', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { resolveCurrentSession } = await import('../../../src/services/oidcAuthContext'); + const { validateSessionRecord } = await import('../../../src/services/sessionService'); + vi.mocked(Session.findByPk).mockResolvedValue(childRow as never); + vi.mocked(resolveCurrentSession).mockResolvedValue(null); + + expect(await validateSessionRecord('child-1')).toBeNull(); + expect(resolveCurrentSession).toHaveBeenCalledWith('parent-1'); + }); + + it('accepts a child whose parent is live', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { resolveCurrentSession } = await import('../../../src/services/oidcAuthContext'); + const { validateSessionRecord } = await import('../../../src/services/sessionService'); + vi.mocked(Session.findByPk).mockResolvedValue(childRow as never); + vi.mocked(resolveCurrentSession).mockResolvedValue({ id: 'parent-now' } as never); + + expect(await validateSessionRecord('child-1')).toBe(childRow); + }); + + describe('revokeOrphanedChildSessions', () => { + it('ends the children whose parent is gone, and only those', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { resolveCurrentSession } = await import('../../../src/services/oidcAuthContext'); + const { revokeOrphanedChildSessions } = await import('../../../src/services/sessionService'); + vi.mocked(Session.findAll).mockResolvedValue([ + { id: 'orphan', parentSessionId: 'gone' }, + { id: 'kept', parentSessionId: 'live' }, + ] as never); + vi.mocked(resolveCurrentSession).mockImplementation((async (id: string) => + id === 'live' ? { id } : null) as never); + + await revokeOrphanedChildSessions({ userId: 'user-1', parentSessionId: null }); + + expect(Session.update).toHaveBeenCalledTimes(1); + expect(vi.mocked(Session.update).mock.calls[0][1]).toMatchObject({ + where: { id: 'orphan', revokedAt: null }, + }); + expect(vi.mocked(Session.update).mock.calls[0][0]).toMatchObject({ + revokedReason: 'parent_session_ended', + }); + }); + + it('does nothing for a session that is itself a child', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { revokeOrphanedChildSessions } = await import('../../../src/services/sessionService'); + + await revokeOrphanedChildSessions({ userId: 'user-1', parentSessionId: 'parent-1' }); + + expect(Session.findAll).not.toHaveBeenCalled(); + }); + + it('never fails the revoke that caused it', async () => { + const { Session } = await import('../../../src/models/sessions'); + const { revokeOrphanedChildSessions } = await import('../../../src/services/sessionService'); + vi.mocked(Session.findAll).mockRejectedValue(new Error('down')); + + await expect( + revokeOrphanedChildSessions({ userId: 'user-1', parentSessionId: null }), + ).resolves.toBeUndefined(); + }); + }); +});