fix(oidc): persist original auth_time on refresh family; re-intersect scope at rotation #287

Merged
YousifShkara merged 3 commits from fix/BUNYIP-262-refresh-auth-time-scope into main 2026-06-30 07:33:41 +02:00
Owner

BUNYIP-262: two related polish items on the refresh-token rotation hot path.

auth_time honesty. handle_refresh_grant previously stamped auth_time = Utc::now() on every rotated at+jwt, which violates OIDC Core §2 ("the time when the End-User authentication occurred"). A stale family that's been refreshed every hour for a year appeared "freshly authenticated" to any RP doing step-up auth on auth_time deltas. Add a refresh_token_families.auth_time column (migration 20260701000010), populate it on the initial code-to-token exchange via the new issue_refresh_token(auth_time, ...) parameter, JOIN it into the rotation's RefreshTokenRotationRow (FOR UPDATE OF rt, not the family), carry it on RotatedTokens, and feed it back into mint_access_token at handle_refresh_grant. Existing rows backfill to NOW() because the original auth_time is unrecoverable; the next rotation onwards reports the truthful value.

Scope re-intersection. The rotation previously kept the original scope set (narrowed only by requested_scope when present) and ignored the user's current user_application_access.granted_scopes. If an admin revoked a scope between the original code redemption and the refresh, the rotated at+jwt still carried the wider scope set until the family expired naturally; the audit named this medium-severity. Add a SELECT inside the rotation transaction that re-intersects effective_scope with the user's still-granted set (revoked_at IS NULL). When the intersection is empty the rotation fails with invalid_grant; when it narrowed, the at+jwt is reminted with the narrower set.

Both changes are wholly internal to the OIDC vertical; no RP integration changes, no .sqlx/ offline-cache regeneration (the new column lands behind runtime queries), no schema impact on refresh_tokens_v2. Reuse detection and family revocation paths untouched.

#BUNYIP-262

BUNYIP-262: two related polish items on the refresh-token rotation hot path. auth_time honesty. `handle_refresh_grant` previously stamped `auth_time = Utc::now()` on every rotated at+jwt, which violates OIDC Core §2 ("the time when the End-User authentication occurred"). A stale family that's been refreshed every hour for a year appeared "freshly authenticated" to any RP doing step-up auth on `auth_time` deltas. Add a `refresh_token_families.auth_time` column (migration `20260701000010`), populate it on the initial code-to-token exchange via the new `issue_refresh_token(auth_time, ...)` parameter, JOIN it into the rotation's `RefreshTokenRotationRow` (FOR UPDATE OF rt, not the family), carry it on `RotatedTokens`, and feed it back into `mint_access_token` at `handle_refresh_grant`. Existing rows backfill to NOW() because the original auth_time is unrecoverable; the next rotation onwards reports the truthful value. Scope re-intersection. The rotation previously kept the original scope set (narrowed only by `requested_scope` when present) and ignored the user's current `user_application_access.granted_scopes`. If an admin revoked a scope between the original code redemption and the refresh, the rotated at+jwt still carried the wider scope set until the family expired naturally; the audit named this medium-severity. Add a SELECT inside the rotation transaction that re-intersects `effective_scope` with the user's still-granted set (`revoked_at IS NULL`). When the intersection is empty the rotation fails with `invalid_grant`; when it narrowed, the at+jwt is reminted with the narrower set. Both changes are wholly internal to the OIDC vertical; no RP integration changes, no `.sqlx/` offline-cache regeneration (the new column lands behind runtime queries), no schema impact on `refresh_tokens_v2`. Reuse detection and family revocation paths untouched. #BUNYIP-262
fix(oidc): persist original auth_time on refresh family; re-intersect scope at rotation
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 26s
Check / fmt + clippy + build + tests (pull_request) Failing after 31m39s
d55b60189a
BUNYIP-262: two related polish items on the refresh-token rotation hot path.

auth_time honesty. `handle_refresh_grant` previously stamped `auth_time = Utc::now()` on every rotated at+jwt, which violates OIDC Core §2 ("the time when the End-User authentication occurred"). A stale family that's been refreshed every hour for a year appeared "freshly authenticated" to any RP doing step-up auth on `auth_time` deltas. Add a `refresh_token_families.auth_time` column (migration `20260701000010`), populate it on the initial code-to-token exchange via the new `issue_refresh_token(auth_time, ...)` parameter, JOIN it into the rotation's `RefreshTokenRotationRow` (FOR UPDATE OF rt, not the family), carry it on `RotatedTokens`, and feed it back into `mint_access_token` at `handle_refresh_grant`. Existing rows backfill to NOW() because the original auth_time is unrecoverable; the next rotation onwards reports the truthful value.

Scope re-intersection. The rotation previously kept the original scope set (narrowed only by `requested_scope` when present) and ignored the user's current `user_application_access.granted_scopes`. If an admin revoked a scope between the original code redemption and the refresh, the rotated at+jwt still carried the wider scope set until the family expired naturally; the audit named this medium-severity. Add a SELECT inside the rotation transaction that re-intersects `effective_scope` with the user's still-granted set (`revoked_at IS NULL`). When the intersection is empty the rotation fails with `invalid_grant`; when it narrowed, the at+jwt is reminted with the narrower set.

Both changes are wholly internal to the OIDC vertical; no RP integration changes, no `.sqlx/` offline-cache regeneration (the new column lands behind runtime queries), no schema impact on `refresh_tokens_v2`. Reuse detection and family revocation paths untouched.

#BUNYIP-262
Merge remote-tracking branch 'origin/main' into fix/BUNYIP-262-refresh-auth-time-scope
Some checks failed
E2E / Playwright against deployment (pull_request) Successful in 24s
Check / fmt + clippy + build + tests (pull_request) Has been cancelled
91d9a832d9
Merge remote-tracking branch 'origin/main' into fix/BUNYIP-262-refresh-auth-time-scope
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 22s
Check / fmt + clippy + build + tests (pull_request) Successful in 9m40s
Create release / Create release from merged PR (pull_request) Has been skipped
4fa6bbdc86
# Conflicts:
#	crates/bunyip-oidc/src/handlers/oidc.rs
#	crates/bunyip-oidc/src/services/oidc_provider.rs
YousifShkara deleted branch fix/BUNYIP-262-refresh-auth-time-scope 2026-06-30 07:33:41 +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!287
No description provided.