feat(profile-B): OIDC profile+phone scopes, consent screen, claim emission (BUNYIP-140) #170

Merged
YousifShkara merged 1 commit from feat/bunyip-140-oidc-profile-scopes into main 2026-06-18 10:06:54 +02:00
Owner

Second slice of BUNYIP-103. Depends on BUNYIP-139's storage columns.
After this lands, bunyip emits given_name / family_name / phone_number
standard OIDC claims when an RP requests + the user consents to the
profile / phone scopes; the first time a (user, client) pair encounters
a new scope, a consent screen lists which fields each scope discloses.

Migrations:

  • 20260618073849: add profile + phone to allowed_scopes of mokosh-apps,
    drillmark, lets-chat-psa (idempotent guarded by @>).
  • 20260618073850: retroactively strip profile + phone from any existing
    user_application_access.granted_scopes rows so existing users re-consent
    the first time an RP requests these scopes after deploy. Without it, the
    prior JIT path's grant-everything posture would have silently auto-granted
    the new scopes for every existing user.

Bunyip-api:

  • OidcProvider::get_granted_scopes(user, client) reads the granted set;
    add_scopes_to_grant unions new scopes into the row (upsert).
  • /oauth2/authorize replaces the "grant every allowed_scope" JIT with a
    baseline-only grant of (openid, email, offline_access). Scopes outside
    the baseline must reach granted_scopes via the consent flow.
  • /oauth2/authorize gates on requested - granted; non-empty missing set
    redirects to bunyip-web /oauth2/consent with client_id + missing +
    continue URL preserved in the query string.
  • POST /v1/users/me/consents accepts { client_id, scopes } and unions
    the scopes into the user_application_access row.
  • /oauth2/userinfo emits given_name / family_name when the access token's
    scope set contains profile AND the column is non-NULL; phone_number
    on phone. NULL columns serialize as ABSENT keys (not empty strings).
  • id_token mint adds the same claims under the same gates via the existing
    IdTokenClaims.extra BTreeMap (BUNYIP-63 pattern).

Bunyip-web:

  • New handlers::consent module renders the Allow / Deny card listing each
    requested scope and the concrete fields it discloses. POST routes
    Allow -> POST /v1/users/me/consents + redirect to continue URL;
    Deny -> dashboard with flash. (Standard "Deny 302s the RP with
    error=access_denied" is a follow-up.)
  • /oauth2/consent route wired in main.rs.

Runtime sqlx queries (not the query! macro) so this slice does not
need a .sqlx/ regen.

#BUNYIP-140
#BUNYIP-103

Co-Authored-By: Claude Opus 4.7 noreply@anthropic.com

Second slice of BUNYIP-103. Depends on BUNYIP-139's storage columns. After this lands, bunyip emits given_name / family_name / phone_number standard OIDC claims when an RP requests + the user consents to the profile / phone scopes; the first time a (user, client) pair encounters a new scope, a consent screen lists which fields each scope discloses. Migrations: - 20260618073849: add `profile` + `phone` to allowed_scopes of mokosh-apps, drillmark, lets-chat-psa (idempotent guarded by @>). - 20260618073850: retroactively strip `profile` + `phone` from any existing user_application_access.granted_scopes rows so existing users re-consent the first time an RP requests these scopes after deploy. Without it, the prior JIT path's grant-everything posture would have silently auto-granted the new scopes for every existing user. Bunyip-api: - OidcProvider::get_granted_scopes(user, client) reads the granted set; add_scopes_to_grant unions new scopes into the row (upsert). - /oauth2/authorize replaces the "grant every allowed_scope" JIT with a baseline-only grant of (openid, email, offline_access). Scopes outside the baseline must reach granted_scopes via the consent flow. - /oauth2/authorize gates on requested - granted; non-empty missing set redirects to bunyip-web /oauth2/consent with client_id + missing + continue URL preserved in the query string. - POST /v1/users/me/consents accepts { client_id, scopes } and unions the scopes into the user_application_access row. - /oauth2/userinfo emits given_name / family_name when the access token's scope set contains `profile` AND the column is non-NULL; phone_number on `phone`. NULL columns serialize as ABSENT keys (not empty strings). - id_token mint adds the same claims under the same gates via the existing IdTokenClaims.extra BTreeMap (BUNYIP-63 pattern). Bunyip-web: - New handlers::consent module renders the Allow / Deny card listing each requested scope and the concrete fields it discloses. POST routes Allow -> POST /v1/users/me/consents + redirect to continue URL; Deny -> dashboard with flash. (Standard "Deny 302s the RP with error=access_denied" is a follow-up.) - /oauth2/consent route wired in main.rs. Runtime sqlx queries (not the `query!` macro) so this slice does not need a .sqlx/ regen. #BUNYIP-140 #BUNYIP-103 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
feat(profile-B): OIDC profile+phone scopes, consent screen, claim emission (BUNYIP-140)
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 1m6s
bf7a8fbbcc
Second slice of BUNYIP-103. Depends on BUNYIP-139's storage columns.
After this lands, bunyip emits given_name / family_name / phone_number
standard OIDC claims when an RP requests + the user consents to the
profile / phone scopes; the first time a (user, client) pair encounters
a new scope, a consent screen lists which fields each scope discloses.

Migrations:
- 20260618073849: add `profile` + `phone` to allowed_scopes of mokosh-apps,
  drillmark, lets-chat-psa (idempotent guarded by @>).
- 20260618073850: retroactively strip `profile` + `phone` from any existing
  user_application_access.granted_scopes rows so existing users re-consent
  the first time an RP requests these scopes after deploy. Without it, the
  prior JIT path's grant-everything posture would have silently auto-granted
  the new scopes for every existing user.

Bunyip-api:
- OidcProvider::get_granted_scopes(user, client) reads the granted set;
  add_scopes_to_grant unions new scopes into the row (upsert).
- /oauth2/authorize replaces the "grant every allowed_scope" JIT with a
  baseline-only grant of (openid, email, offline_access). Scopes outside
  the baseline must reach granted_scopes via the consent flow.
- /oauth2/authorize gates on requested - granted; non-empty missing set
  redirects to bunyip-web /oauth2/consent with client_id + missing +
  continue URL preserved in the query string.
- POST /v1/users/me/consents accepts { client_id, scopes } and unions
  the scopes into the user_application_access row.
- /oauth2/userinfo emits given_name / family_name when the access token's
  scope set contains `profile` AND the column is non-NULL; phone_number
  on `phone`. NULL columns serialize as ABSENT keys (not empty strings).
- id_token mint adds the same claims under the same gates via the existing
  IdTokenClaims.extra BTreeMap (BUNYIP-63 pattern).

Bunyip-web:
- New handlers::consent module renders the Allow / Deny card listing each
  requested scope and the concrete fields it discloses. POST routes
  Allow -> POST /v1/users/me/consents + redirect to continue URL;
  Deny -> dashboard with flash. (Standard "Deny 302s the RP with
  error=access_denied" is a follow-up.)
- /oauth2/consent route wired in main.rs.

Runtime sqlx queries (not the `query!` macro) so this slice does not
need a .sqlx/ regen.

#BUNYIP-140
#BUNYIP-103

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
YousifShkara deleted branch feat/bunyip-140-oidc-profile-scopes 2026-06-18 10:06:54 +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!170
No description provided.