feat(oidc): /token mints configured tenant claim on at+jwt and id_token (BUNYIP-63) #128
No reviewers
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
psa-systems/bunyip!128
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/bunyip-63-token-mint-emits-tenant-claim"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes BUNYIP-63 and the BUNYIP-60 umbrella. Third and final child under BUNYIP-60. Stacks on BUNYIP-62.
After BUNYIP-61 (assignment table + admin API) and BUNYIP-62 (authorize gate + picker), the auth-code row carries
selected_tenant_idbut the token endpoint does not emit any tenant claim. This PR completes the chain: at+jwt and id_token now carry the per-client tenant claim under the name configured onoauth_clients.tenant_claim_name, and the refresh-token family carries the same tenant_id forward through every rotation so a rotated access token from a refresh emits the same tenant claim.Schema (migration
20260612000030_refresh_tokens_selected_tenant_id.sql):refresh_tokens_v2gains a nullableselected_tenant_id UUID. Mirrored from the auth-code row at exchange time and copied forward on every rotation, so tenant switching is by design a fresh /authorize round-trip and never a refresh.Claims (
crates/bunyip-oidc/src/services/oidc_provider.rs):AtClaimsandIdTokenClaimsgain#[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")] extra: BTreeMap<String, serde_json::Value>. Flatten lifts each map entry to a top-level JWT claim;skip_serializing_ifkeeps the wire shape byte-identical to pre-BUNYIP-63 for any client whosetenant_claim_nameis NULL. Verifiers consume well-known fields via named struct fields and ignore extras they do not recognise.AtClaims::buildandIdTokenClaims::buildacceptselected_tenant_id: Option<Uuid>andclient.tenant_claim_nameis read off the existingOAuthClient. Both Some => insert into extras under the configured name (e.g.mokosh_tenant_id). Either None => extras stays empty.Mint (
mint_access_token,mint_id_token):selected_tenant_id: Option<Uuid>. The token-endpoint and refresh-grant handlers inhandlers/oidc.rsthread the value through from the consumed auth code (initial exchange) or the rotated refresh row (refresh exchange).Rotation (
rotate_refresh_token,insert_refresh_token,issue_refresh_token):RefreshTokenRotationRowand persists it back on the next row.RotatedTokenscarries it out sohandle_refresh_grantcan mint the rotated at+jwt with the original tenant.sqlx::query_as::<_, RefreshTokenRotationRow>(struct local to this module, FromRow-derived) and runtimesqlx::query_scalarfor the insert, so future per-RP column additions land without a.sqlx/cache regen.Auth-code consumption:
consume_authorization_codeswitches to runtime query and returns the newselected_tenant_idfield onAuthCodeRow(FromRow-derived).Test scaffolding (
build_claims_for,build_id_claims_for) updated to passNoneso the existing BUNYIP-66 bunyip_role tests still pass.No new dependencies. No changes to the wire shape of legacy single-tenant clients - the entire feature lights up only when
oauth_clients.tenant_claim_nameis set non-null AND the assignment table has rows for the (user, client) pair.Umbrella close-out:
oauth_client_user_tenants. Done./authorizegates on assignments + tenant picker. Done./tokenmints the configured tenant claim. This PR.mokosh_tenant_idclaim, drop the OIDC_DEFAULT_TENANT_ID fallback) and is tracked under PMS.just check-containerclean (modulo the pre-existingtest_config_defaultsparallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean).#BUNYIP-63
#BUNYIP-60