Skip to content

feat(sessions): exchange a provider sign-in for a first-party session (ADR 0003) - #572

Merged
Bccorb merged 20 commits into
mainfrom
feat/session-exchange
Nov 5, 2026
Merged

Bccorb merged 20 commits into
mainfrom
feat/session-exchange

Conversation

@Bccorb

@Bccorb Bccorb commented Nov 5, 2026

Copy link
Copy Markdown
Contributor

Implements ADR 0003: a first-party native client signed in through the OpenID Provider reaches the admin API by exchanging a single-use provider token for a child first-party session.

Refs #544, #551. Unblocks fells-code/seamless-cli#316.

Draft until @seamless-auth/types with the new schemas is released. Then run npm i @seamless-auth/types@^0.36.0 and commit package.json and the lock. Until then CI fails at typecheck; local checks used a packed tarball.

What it does

  • Flag: sessionExchangeEnabled for first-party public native clients, with step-up and audit. Turning it off, or disabling or deleting the client, revokes its exchanged sessions.
  • Token: /oidc/sign signs session:exchange only with the exact scope, the built-in audience, a jti and at most 120 seconds, and writes the record the exchange consumes.
  • Exchange: POST /sessions/exchange consumes the token once in one transaction. A replay answers 400 invalid_request and revokes what the first use created.
  • Child session: its own refresh chain and step-up state, capped at the parent's lifetime, ended when the parent ends. Child refreshes count as parent activity.
  • Step-up copy: an authorization code child copies a parent step-up from the last five minutes. A device child copies nothing.

Device sessions (added during the build)

The record's first version gated only admin routes. A device session could enroll its own TOTP and then pass that check, and could replace a victim's TOTP; both were run against real Postgres. requireDeviceSessionStepUp now answers 403 step_up_required without a fresh step-up on:

  • /admin/* and everything behind requireAdmin
  • /totp/enroll/*, /totp/disable, /webauthn/register/*, POST /recovery-codes, DELETE /users/credentials
  • /registration/phone, /registration/phone/verify, DELETE /recovery/requests/:id
  • every organization route except POST /organizations/:id/switch, for global admins and ordinary members alike

A device sign-in with no TOTP cannot enroll one; the user does it from a browser. Signing out other sessions stays open, because it gains no access; the known cost is that it could slow the owner's response. A real passkey assertion is accepted as a step-up on a device session. All recorded in the ADR.

For review

Choices the ADR left open:

  • Children do not count toward max_concurrent_sessions and are never evicted.
  • The exchange refuses a parent that is itself a child.
  • /oidc/sign refuses session:exchange on a refresh grant.
  • Signing records are insert-only.
  • Deleting a client revokes its exchanged sessions.

Ship with fells-code/seamless-auth-oidc-provider#61. X8 (no silent sign-in, and the confirmation names the account) is enforced at the edge. Without it, a local program using the CLI's client id could get a silent sign-in for a user with a live browser session.

Security review

A static /security-review of this branch reported nothing at or above its bar. It traced token verification, single use, grant type, the gates, revocation, client eligibility and data exposure. It ran no exploits and did not read the edge repo. It noted, below the bar: the X8 dependency above; isSessionExchangeClient accepts a client with both native and https redirects (admin-configured only); and a phished device child can keep the approver's browser session alive up to its absolute lifetime (accepted as X4).

Checks

From the implementer's final run: lint, typecheck, format and generate:api clean; unit and coverage 2913 passed, 99.07% lines; TEST_DB=postgres on a cold cache 3133 passed, 188 of 188 files, 158 in sessionExchange.spec.ts. Re-run in review: unit tests under tests/unit (1810) and typecheck; the Postgres suite was not repeated. Test commits precede their implementations; each piece's Postgres tests were seen failing first. 8f2ff8f also seeds system_config in oidcApplications.spec.ts, which failed whenever it ran first.

Bccorb added 20 commits November 4, 2026 20:57
An admin can turn token exchange on for a first-party public native client.
Such a client gets the built-in resource with session:exchange and a 60
second token lifetime. Turning it off, or disabling or deleting the client,
revokes the sessions it exchanged for.

Refs #544
/oidc/sign now signs a session:exchange token only when the scope is exact,
the audience is the built-in resource, jti is present and it lives at most
120 seconds. It writes the SessionExchangeToken record the exchange consumes.

Refs #544
Replay sequential and concurrent, wrong audience, extra scope, expired record,
disabled client, recovery parent, and the X5 assurance copy.

Refs #544
A first-party native client presents the single-use provider access token and
gets a first-party session linked to the provider sign-in. The record is
consumed and the child created in one transaction, so of concurrent
presentations one wins and the rest revoke what it created. An authorization
code grant copies the parent's assurance and step-up, a device grant nothing.

Refs #544
Parent check on validate and refresh, the lifetime cap, parent activity, and
each revoke path ending the child and not the reverse.

Refs #544
validateSessionRecord and the refresh path refuse a child whose parent no longer
resolves to a live session. Rotation carries the link and is capped at the
parent's lifetime, a child refresh counts as activity on the parent, and every
revoke path cascades. A child is not counted against the concurrent session
limit, since evicting a parent to make room for its own child would end both.

Refs #544
requireAdmin answers 403 step_up_required to a session exchanged from a device
grant until it has a fresh step-up of its own, reads included (ADR 0003, X7).
Session lists show the parent, client and grant of a child session.

Refs #544
The coverage gate runs without Postgres, so the exchange, signing, lifecycle and
admin paths need coverage there too.

Refs #544
A device child without a fresh step-up could enroll its own TOTP, then step up
with it and pass X7. These fail until factor changes are gated.

Refs #544
requireDeviceSessionStepUp answers 403 step_up_required on routes that change
how an account signs in or recovers, unless a device child has a fresh step-up
of its own: TOTP enrollment and disable, passkey registration, recovery codes,
credential deletion, phone registration and cancelling account recovery. X7
shares the check. A device sign-in with no TOTP cannot enroll one, and the user
does it from a browser. defineRoute now documents this 403 wherever the check
applies, admin reads included. ADR 0003 is amended.

Refs #544
It relied on an earlier spec having seeded the table, so it failed whenever it
ran first.

Refs #544
A device child read member emails and changed roles with no step-up, as a global
admin or as an ordinary owner. These fail until the routes are gated.

Refs #544
Organization routes check roles without requireAdmin, so a device child with no
step-up could read member emails and change roles. Every organization route now
uses requireDeviceSessionStepUp, reads included, as a global admin or an ordinary
member or owner. Switching stays open: it keeps the same session and grant, and
X7 still applies afterwards.

Refs #544
Accepted from the registered key on the relying party's origin, refused from
another origin or another key.

Refs #544
A device sign-in steps up with the user's own factor, and the CLI uses TOTP.
Organization routes need the step-up. Signing out other sessions stays open.

Refs #544
@Bccorb
Bccorb marked this pull request as ready for review November 5, 2026 13:26
@Bccorb
Bccorb merged commit 52ff3c3 into main Nov 5, 2026
9 checks passed
@Bccorb
Bccorb deleted the feat/session-exchange branch November 5, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant