feat(oidc): /token mints configured tenant claim on at+jwt and id_token (BUNYIP-63) #128

Merged
YousifShkara merged 1 commit from feat/bunyip-63-token-mint-emits-tenant-claim into main 2026-06-12 05:46:38 +02:00
Owner

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_id but 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 on oauth_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_v2 gains a nullable selected_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):

  • AtClaims and IdTokenClaims gain #[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_if keeps the wire shape byte-identical to pre-BUNYIP-63 for any client whose tenant_claim_name is NULL. Verifiers consume well-known fields via named struct fields and ignore extras they do not recognise.
  • AtClaims::build and IdTokenClaims::build accept selected_tenant_id: Option<Uuid> and client.tenant_claim_name is read off the existing OAuthClient. 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):

  • Signature gains selected_tenant_id: Option<Uuid>. The token-endpoint and refresh-grant handlers in handlers/oidc.rs thread 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):

  • The new column rides on every insert; the rotation reads it off RefreshTokenRotationRow and persists it back on the next row. RotatedTokens carries it out so handle_refresh_grant can mint the rotated at+jwt with the original tenant.
  • The full rotation transaction now uses runtime sqlx::query_as::<_, RefreshTokenRotationRow> (struct local to this module, FromRow-derived) and runtime sqlx::query_scalar for the insert, so future per-RP column additions land without a .sqlx/ cache regen.

Auth-code consumption:

  • consume_authorization_code switches to runtime query and returns the new selected_tenant_id field on AuthCodeRow (FromRow-derived).

Test scaffolding (build_claims_for, build_id_claims_for) updated to pass None so 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_name is set non-null AND the assignment table has rows for the (user, client) pair.

Umbrella close-out:

  • BUNYIP-61: schema + admin API for oauth_client_user_tenants. Done.
  • BUNYIP-62: /authorize gates on assignments + tenant picker. Done.
  • BUNYIP-63: /token mints the configured tenant claim. This PR.
  • Remaining work belongs in mokosh-server (read mokosh_tenant_id claim, drop the OIDC_DEFAULT_TENANT_ID fallback) and is tracked under PMS.

just check-container clean (modulo the pre-existing test_config_defaults parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean).

#BUNYIP-63
#BUNYIP-60

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_id` but 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 on `oauth_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_v2` gains a nullable `selected_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`): - `AtClaims` and `IdTokenClaims` gain `#[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_if` keeps the wire shape byte-identical to pre-BUNYIP-63 for any client whose `tenant_claim_name` is NULL. Verifiers consume well-known fields via named struct fields and ignore extras they do not recognise. - `AtClaims::build` and `IdTokenClaims::build` accept `selected_tenant_id: Option<Uuid>` and `client.tenant_claim_name` is read off the existing `OAuthClient`. 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`): - Signature gains `selected_tenant_id: Option<Uuid>`. The token-endpoint and refresh-grant handlers in `handlers/oidc.rs` thread 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`): - The new column rides on every insert; the rotation reads it off `RefreshTokenRotationRow` and persists it back on the next row. `RotatedTokens` carries it out so `handle_refresh_grant` can mint the rotated at+jwt with the original tenant. - The full rotation transaction now uses runtime `sqlx::query_as::<_, RefreshTokenRotationRow>` (struct local to this module, FromRow-derived) and runtime `sqlx::query_scalar` for the insert, so future per-RP column additions land without a `.sqlx/` cache regen. Auth-code consumption: - `consume_authorization_code` switches to runtime query and returns the new `selected_tenant_id` field on `AuthCodeRow` (FromRow-derived). Test scaffolding (`build_claims_for`, `build_id_claims_for`) updated to pass `None` so 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_name` is set non-null AND the assignment table has rows for the (user, client) pair. Umbrella close-out: - BUNYIP-61: schema + admin API for `oauth_client_user_tenants`. Done. - BUNYIP-62: `/authorize` gates on assignments + tenant picker. Done. - BUNYIP-63: `/token` mints the configured tenant claim. This PR. - Remaining work belongs in mokosh-server (read `mokosh_tenant_id` claim, drop the OIDC_DEFAULT_TENANT_ID fallback) and is tracked under PMS. `just check-container` clean (modulo the pre-existing `test_config_defaults` parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean). #BUNYIP-63 #BUNYIP-60
feat(oidc): /token mints configured tenant claim on at+jwt and id_token (BUNYIP-63)
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt / clippy / build / test (pull_request) Successful in 1m14s
41a764890a
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_id` but 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 on `oauth_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_v2` gains a nullable `selected_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`):

- `AtClaims` and `IdTokenClaims` gain `#[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_if` keeps the wire shape byte-identical to pre-BUNYIP-63 for any client whose `tenant_claim_name` is NULL. Verifiers consume well-known fields via named struct fields and ignore extras they do not recognise.
- `AtClaims::build` and `IdTokenClaims::build` accept `selected_tenant_id: Option<Uuid>` and `client.tenant_claim_name` is read off the existing `OAuthClient`. 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`):

- Signature gains `selected_tenant_id: Option<Uuid>`. The token-endpoint and refresh-grant handlers in `handlers/oidc.rs` thread 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`):

- The new column rides on every insert; the rotation reads it off `RefreshTokenRotationRow` and persists it back on the next row. `RotatedTokens` carries it out so `handle_refresh_grant` can mint the rotated at+jwt with the original tenant.
- The full rotation transaction now uses runtime `sqlx::query_as::<_, RefreshTokenRotationRow>` (struct local to this module, FromRow-derived) and runtime `sqlx::query_scalar` for the insert, so future per-RP column additions land without a `.sqlx/` cache regen.

Auth-code consumption:

- `consume_authorization_code` switches to runtime query and returns the new `selected_tenant_id` field on `AuthCodeRow` (FromRow-derived).

Test scaffolding (`build_claims_for`, `build_id_claims_for`) updated to pass `None` so 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_name` is set non-null AND the assignment table has rows for the (user, client) pair.

Umbrella close-out:
- BUNYIP-61: schema + admin API for `oauth_client_user_tenants`. Done.
- BUNYIP-62: `/authorize` gates on assignments + tenant picker. Done.
- BUNYIP-63: `/token` mints the configured tenant claim. This PR.
- Remaining work belongs in mokosh-server (read `mokosh_tenant_id` claim, drop the OIDC_DEFAULT_TENANT_ID fallback) and is tracked under PMS.

`just check-container` clean (modulo the pre-existing `test_config_defaults` parallel-env-var flake on main; 188 other tests pass; fmt + clippy + build clean).

#BUNYIP-63
#BUNYIP-60
YousifShkara deleted branch feat/bunyip-63-token-mint-emits-tenant-claim 2026-06-12 05:46:39 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
psa-systems/bunyip!128
No description provided.