Repository navigation
feat(oidc): sign roles, permissions and custom claims into provider tokens - #585
Merged
Merged
Conversation
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.
…dc-provider-claims
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.
…dc-provider-claims
…idc-provider-claims # Conflicts: # docs/adr/0005-extensibility-callouts.md # openapi.json # src/generated/api.ts # src/services/oidcClientRegistry.ts # tests/integration/oidc/oidcEdge.spec.ts # tests/unit/services/oidcClientRegistry.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)(newsrc/services/oidcAccountClaims.ts) computes what a provider token may carry the waymintAccessTokendoes for a first-party token:resolveAuthorizationfor the session's active organization,resolveCustomClaimswith the client'soidc_clientset added after instance and organization role sets and before the session'spre_tokenhook claims (ADR 0005 E6), andauthorizationClaimsfor the shared 1 KiB budget (permissions omitted first, custom claims dropped whole last). Claim names follow ADR 0004.GET /oidc/accounts/:idreturns those claims for the session and client the lookup names:permissions,org_roles,org_permissions(orpermissions_omitted) andcustomClaims. An omission writescustom_claims_omittedwith the client id. The route uses the sharedOidcAccountClaimsSchemafrom@seamless-auth/types0.40.0.org_idfor 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/signno 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:seamless_/org_prefixes, anything outside the pattern) is refused asmalformed_signing_inputbefore any account read;nullvalue), or a permission or custom claim set carried in part is refused withclaims_mismatch(403), new in the shared refusal codes in@seamless-auth/types0.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);subject_mismatch, and client credentials tokens still carry none of these.permissionsandcustom_claims, and the retry, are recorded as Brandon's decisions of 2026-11-07 in ADR 0005 (Found while building, provider tokens).resolveCustomClaimstakes an optionalclientId.Tests
tests/unit/services/oidcSigning.spec.ts: reproducing tests that a provider cannot get a forged value (refused asclaims_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 403claims_mismatchthrough 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 isclaims_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/types0.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: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.jsonandsrc/generated/api.tsregenerated from the shared schemas and committed; no further diff.Not done here
pre_tokenhook call still sendsclient: null: the hook runs at first-party sign-in, before any provider client is known. Provider tokens use the session's stored hook claims.