Skip to content

feat(oidc): sign roles, permissions and custom claims into provider tokens - #585

Merged
Bccorb merged 24 commits into
mainfrom
feat/oidc-provider-claims
Nov 8, 2026
Merged

Bccorb merged 24 commits into
mainfrom
feat/oidc-provider-claims

Conversation

@Bccorb

@Bccorb Bccorb commented Nov 8, 2026 •

Copy link
Copy Markdown
Contributor

What

The API side of fells-code/seamless-auth-oidc-provider#47: OpenID Provider tokens can carry roles, permissions and custom claims, and the API stays the authority over every value.

  • getOidcAccountClaims(session, clientId) (new src/services/oidcAccountClaims.ts) computes what a provider token may carry the way mintAccessToken does for a first-party token: resolveAuthorization for the session's active organization, resolveCustomClaims with the client's oidc_client set added after instance and organization role sets and before the session's pre_token hook claims (ADR 0005 E6), and authorizationClaims for the shared 1 KiB budget (permissions omitted first, custom claims dropped whole last). Claim names follow ADR 0004.
  • GET /oidc/accounts/:id returns those claims for the session and client the lookup names: permissions, org_roles, org_permissions (or permissions_omitted) and customClaims. An omission writes custom_claims_omitted with the client id. The route uses the shared OidcAccountClaimsSchema from @seamless-auth/types 0.40.0.
  • org_id for provider tokens is now the session's active organization, while the user is still a member, not the user's oldest membership. Behavior change, in the changeset.
  • /oidc/sign no longer trusts what the edge puts in a user token beyond protocol claims. For every account claim present it computes the claims again for that session and client and signs only on an exact match:
    • a name outside the protocol, identity and permission claims that is not a valid custom claim name (reserved names, seamless_/org_ prefixes, anything outside the pattern) is refused as malformed_signing_input before any account read;
    • a value that differs (JSON compared without regard to object key order), a custom claim name the API did not compute (even with a null value), or a permission or custom claim set carried in part is refused with claims_mismatch (403), new in the shared refusal codes in @seamless-auth/types 0.40.0. A role or membership change between the edge's account read and its sign looks the same as a forgery, so the edge reads the account again and retries once (seamless-auth-oidc-provider#63);
    • an account that no longer exists is still subject_mismatch, and client credentials tokens still carry none of these.
  • Scope names permissions and custom_claims, and the retry, are recorded as Brandon's decisions of 2026-11-07 in ADR 0005 (Found while building, provider tokens).
  • resolveCustomClaims takes an optional clientId.

Tests

  • tests/unit/services/oidcSigning.spec.ts: reproducing tests that a provider cannot get a forged value (refused as claims_mismatch) (custom, nested custom, extra permission, other organization, raised organization roles, unearned omission marker), an uncomputed custom name, a reserved name (act, may_act, name, recovery, seamless_admin, org_tier, Tier) or a partial set signed. 15 of them fail with the check disabled.
  • tests/unit/services/oidcAccountClaims.spec.ts, customClaimService.spec.ts (client set precedence), tests/integration/oidc/oidcEdge.spec.ts (the route computes for the bound session and client, and answers 403 claims_mismatch through the response schema).
  • tests/postgres/providerClaims.spec.ts: on the migrated schema, the four sources merge in order, real values sign into ID and access tokens, forged and unlisted ones are refused, and a client set deleted or an organization left after the read is claims_mismatch, the race the edge retries.

Depends on

Stacked on #584 (feat/custom-claims), on #583 and #580. All unreleased; the API release is held (#566). The provider PR that consumes this is fells-code/seamless-auth-oidc-provider#63.

Checks (at beaa58f, the merge of #584 at e32e823, on @seamless-auth/types 0.40.0)

  • npm run coverage (pre-commit hook): 191 files, 3349 passed, 1 todo. All files 99.06 statements, 95.72 branches, 99.36 functions, 99.25 lines.
  • Postgres project (own container, postgres:18-alpine, no SQL touched): 17 files, 271 passed.
  • npm run lint, npm run format, npm run typecheck, npm run build (pre-commit hook): clean.
  • npm run generate:api: openapi.json and src/generated/api.ts regenerated from the shared schemas and committed; no further diff.

Not done here

  • The pre_token hook call still sends client: null: the hook runs at first-party sign-in, before any provider client is known. Provider tokens use the session's stored hook claims.

DELETE /users/credentials removed a passkey without writing an audit
event, so a change to what an account can sign in with left no trace.
It now writes credentials_deleted naming the passkey.
Adds webhook endpoints and deliveries behind an audited admin API with
step-up on every write. Payloads use the Standard Webhooks signature
scheme, are built from committed audit rows past a cursor, and are
retried eight times over just under 42 hours before they are dead
lettered. An endpoint that answers 410 or fails for three days is
disabled.

Every callout to an adopter URL goes through one transport that checks
the address it connects to, never reaches link-local, multicast or the
API itself, never follows redirects, and in production refuses private
addresses unless CALLOUT_PRIVATE_HOSTS lists the host. Signing secrets
are encrypted under CALLOUT_SECRET_ENCRYPTION_KEY.

Writes session_created once per completed sign-in, which user.signed_in
is built from, since login_success precedes any factor.

Records the design in ADR 0005. Closes #418.
Adds the two login hooks from ADR 0005 (E5), signed with the Standard
Webhooks scheme like webhooks and reached through the shared callout
transport.

- post_login runs once the factor is proven and before the session
  exists, and may deny. pre_token runs for a new session and on
  organization switch, and may add claims or deny.
- pre_token claims are kept on the session (sessions.hookClaims), copied
  on refresh and to an exchanged session, so neither calls a hook. They
  are cleared when the user leaves the organization they were decided
  for.
- One attempt with the hook's timeout. A failure follows the hook's
  failure mode. A deny answers 403 login_denied with the hook's
  userMessage; a closed failure answers 503 login_hook_unavailable.
- Reserved claim names are stripped and audited, and filtered again at
  mint. Break-glass administrators and recovery sessions never call a
  hook.
- Admin routes under /admin/login-hooks/:trigger, with step-up and an
  audit event on every write, and an invocation log pruned after 30 days.

Closes #419.
Adds custom claims from ADR 0005 (E6).

- custom_claims holds one set per instance role, organization role or
  OpenID Provider client, behind /admin/custom-claims with step-up and
  an audit event (names only) on every write. Writes are validated with
  the shared schema and the source must exist.
- mintAccessToken merges instance role sets, then organization role
  sets while the membership exists, then the session's pre_token hook
  claims, a later source winning per name, and filters reserved names
  again. Recovery tokens carry none.
- When the sources together exceed 1 KiB the token carries no custom
  claims and the mint writes custom_claims_omitted. Nothing is trimmed.
- Retiring a role or deleting a client deletes its set.
- The cookie budget test now pins that a token at both budgets
  overflows the adapter cookie, which is an open decision.

Closes #417.
…laims

Separate 1 KiB budgets for permission claims and custom claims made the
server adapter's session cookie 5670 bytes at worst, over the 4096
limit. They now share AUTHORIZATION_CLAIMS_BUDGET_BYTES (1 KiB), which
replaces PERMISSION_CLAIMS_BUDGET_BYTES. CUSTOM_CLAIMS_BUDGET_BYTES stays
the per-set write limit.

- If the permission claims and the merged custom claims fit together,
  the token carries both.
- Otherwise the permission claims give way first, replaced with
  permissions_omitted, since a guard can read GET /users/me/permissions
  and custom claims have no fallback.
- Only when the custom claims do not fit beside the marker are they all
  dropped, and the mint writes custom_claims_omitted.
- Nothing is trimmed partially. Recovery tokens carry neither.

The worst-case adapter cookie at the shared budget measures 3868 bytes.
ADR 0004 P4, ADR 0005 E6 and the API contract describe the rule.
Among roles of one kind, custom claim sets apply in role-name order. It
is deterministic and does not depend on grant order, which an
administrator cannot see. ADR 0005 E6 records the decision, and a test
now pins the order for organization roles as well as instance roles.
A disabled endpoint was given no deliveries, so events that happened
while it was off were never sent, including after the API disabled it
for a 410 or three days of failures. Its deliveries are now queued on
hold, and turning it back on releases them, back as far as the 30 days
deliveries are kept. The release uses the database clock, which the due
check compares against.
@Bccorb
Bccorb changed the base branch from feat/custom-claims to main November 8, 2026 03:52
@Bccorb
Bccorb marked this pull request as ready for review November 8, 2026 04:04
@Bccorb
Bccorb merged commit 1bbeeff into main Nov 8, 2026
9 of 17 checks passed
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