fix(oidc): atomic code redemption, cap refresh TTL, drop dead client fields (BUNYIP-73) #252

Merged
vas2000-work merged 2 commits from fix/bunyip-73-oidc-provider-hardening into main 2026-06-28 02:11:40 +02:00
Member

Harden the OIDC provider service against three defects flagged in the audit.

Auth-code double-spend: replace the SELECT-then-UPDATE in consume_authorization_code with a single atomic UPDATE ... WHERE consumed_at IS NULL AND revoked_at IS NULL ... RETURNING. The predicate is the redemption guard and the SET is the redemption, so check and mark happen in one round-trip with no TOCTOU window; concurrent /token requests race on the UPDATE and exactly one wins (RFC 6749 4.1.2). Zero rows are rejected as invalid_grant. A code that fails a binding check (expiry/client_id/redirect_uri/PKCE) is still consumed, so it is never replayable.

Refresh absolute-expiry cap: rotation set new_abs_exp = now + abs_ttl, which reset the absolute deadline on every rotation and let a token refreshed before each idle expiry live forever. Cap it with old.absolute_expires_at.min(now + abs_ttl) so rotation refreshes only the idle window.

Duplicate client SELECT: rotate_refresh_token reuses load_client() instead of an inline 18-column SELECT. The client config is read-only and not part of the rotation atomicity, so a single source of truth for the query is preferable.

Dead client fields: drop allowed_grant_types, token_endpoint_auth_method, require_pkce, and dpop_bound from OAuthClient and load_client. They were loaded but never read; PKCE and grant-type policy is enforced centrally in the authorize/token handlers (S256 + code_challenge always required, grant_type restricted to authorization_code/refresh_token), so the per-client loads were dead.

The auth.refresh_reuse_detected audit event TODO is already resolved: BUNYIP-88 writes a Critical audit-log row on replay; no dangling TODO remains.

#BUNYIP-73

Harden the OIDC provider service against three defects flagged in the audit. Auth-code double-spend: replace the SELECT-then-UPDATE in consume_authorization_code with a single atomic UPDATE ... WHERE consumed_at IS NULL AND revoked_at IS NULL ... RETURNING. The predicate is the redemption guard and the SET is the redemption, so check and mark happen in one round-trip with no TOCTOU window; concurrent /token requests race on the UPDATE and exactly one wins (RFC 6749 4.1.2). Zero rows are rejected as invalid_grant. A code that fails a binding check (expiry/client_id/redirect_uri/PKCE) is still consumed, so it is never replayable. Refresh absolute-expiry cap: rotation set new_abs_exp = now + abs_ttl, which reset the absolute deadline on every rotation and let a token refreshed before each idle expiry live forever. Cap it with old.absolute_expires_at.min(now + abs_ttl) so rotation refreshes only the idle window. Duplicate client SELECT: rotate_refresh_token reuses load_client() instead of an inline 18-column SELECT. The client config is read-only and not part of the rotation atomicity, so a single source of truth for the query is preferable. Dead client fields: drop allowed_grant_types, token_endpoint_auth_method, require_pkce, and dpop_bound from OAuthClient and load_client. They were loaded but never read; PKCE and grant-type policy is enforced centrally in the authorize/token handlers (S256 + code_challenge always required, grant_type restricted to authorization_code/refresh_token), so the per-client loads were dead. The auth.refresh_reuse_detected audit event TODO is already resolved: BUNYIP-88 writes a Critical audit-log row on replay; no dangling TODO remains. #BUNYIP-73
fix(oidc): atomic code redemption, cap refresh TTL, drop dead client fields (BUNYIP-73)
Some checks failed
E2E / Playwright against deployment (pull_request) Failing after 23s
Check / fmt + clippy + build + tests (pull_request) Successful in 10m39s
a7ebf03e5f
Harden the OIDC provider service against three defects flagged in the audit.

Auth-code double-spend: replace the SELECT-then-UPDATE in consume_authorization_code with a single atomic UPDATE ... WHERE consumed_at IS NULL AND revoked_at IS NULL ... RETURNING. The predicate is the redemption guard and the SET is the redemption, so check and mark happen in one round-trip with no TOCTOU window; concurrent /token requests race on the UPDATE and exactly one wins (RFC 6749 4.1.2). Zero rows are rejected as invalid_grant. A code that fails a binding check (expiry/client_id/redirect_uri/PKCE) is still consumed, so it is never replayable.

Refresh absolute-expiry cap: rotation set new_abs_exp = now + abs_ttl, which reset the absolute deadline on every rotation and let a token refreshed before each idle expiry live forever. Cap it with old.absolute_expires_at.min(now + abs_ttl) so rotation refreshes only the idle window.

Duplicate client SELECT: rotate_refresh_token reuses load_client() instead of an inline 18-column SELECT. The client config is read-only and not part of the rotation atomicity, so a single source of truth for the query is preferable.

Dead client fields: drop allowed_grant_types, token_endpoint_auth_method, require_pkce, and dpop_bound from OAuthClient and load_client. They were loaded but never read; PKCE and grant-type policy is enforced centrally in the authorize/token handlers (S256 + code_challenge always required, grant_type restricted to authorization_code/refresh_token), so the per-client loads were dead.

The auth.refresh_reuse_detected audit event TODO is already resolved: BUNYIP-88 writes a Critical audit-log row on replay; no dangling TODO remains.

#BUNYIP-73
Merge remote-tracking branch 'origin/main' into fix/bunyip-73-oidc-provider-hardening
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 23s
Check / fmt + clippy + build + tests (pull_request) Successful in 11m2s
Create release / Create release from merged PR (pull_request) Has been skipped
601d41137f
# Conflicts:
#	crates/bunyip-oidc/src/services/oidc_provider.rs
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
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!252
No description provided.