fix(oauth): re-check user identity on code redemption#204
Conversation
- Reject device-code redemption when the authorizing user is disabled or deleted, before consuming the device code - Reject authorization-code redemption when the authorizing user is disabled or deleted, before marking the code used - Map the access_denied sentinel to the OAuth access_denied error on the authorization_code grant - Emit a SUSPICIOUS_ACTIVITY warning audit event on both rejection paths - Seed real active users in device/auth-code test helpers and add coverage for the disabled and deleted cases Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
…able - Emit the user-disabled/deleted rejection audit events via LogSync, matching the only other SUSPICIOUS_ACTIVITY emitter so a crash cannot lose the security event - Tag the device-code rejection event with ResourceDeviceCode and the device code ID for traceability, mirroring the device-authorized event - Record LogSync calls in the test audit spy Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
🔎 Max-effort code review (9 finder angles + verify + sweep)Correctness (angles A–E, line-by-line / removed-behavior / cross-file / Go-pitfalls): no confirmed bugs.
Fixed in
Skipped (with rationale):
✅ |
There was a problem hiding this comment.
Pull request overview
This PR strengthens OAuth code redemption by re-checking that the authorizing user still exists and is active before allowing device-code or authorization-code redemption to proceed.
Changes:
- Adds user existence/active checks and audit events in device-code and auth-code redemption paths.
- Maps auth-code
ErrAccessDeniedto OAuthaccess_denied. - Updates and adds tests for disabled/deleted users, audit events, and helper seeding.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| internal/services/token_exchange.go | Adds device-code redemption user re-check before consuming the device code. |
| internal/services/authorization.go | Adds authorization-code redemption user re-check before marking the code used. |
| internal/handlers/token.go | Maps auth-code access denial to OAuth access_denied. |
| internal/handlers/token_authcode_user_recheck_test.go | Adds handler coverage for disabled-user auth-code redemption. |
| internal/services/token_test.go | Adds device-code user re-check tests and supporting helpers. |
| internal/services/authorization_test.go | Adds auth-code user re-check tests and updates existing tests to seed users. |
| internal/services/token_resource_test.go | Seeds a real user for authorized device-code resource tests. |
| internal/services/token_uid_test.go | Updates UID-claim helper to mutate the seeded user instead of recreating it. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| user, err := s.store.GetUserByID(dc.UserID) | ||
| if err != nil || user == nil || !user.IsActive { |
| user, err := s.store.GetUserByID(record.UserID) | ||
| if err != nil || user == nil || !user.IsActive { |
Summary
A user could be disabled (
IsActive=false) or hard-deleted by an admin in the window after they authorize a device/auth code but before the client redeems it. The redemption path only checkedclient.IsActive()— never the user — so a disabled user still got a fresh access+refresh token pair, and a deleted user got tokens whosesub/user_idpointed at a non-existent account.This re-verifies the authorizing user exists and is active immediately before consuming the single-use code on both code-redemption grants, rejecting with
access_deniedand minting no tokens. It is a sibling to the existingclient.IsActive()checks already on all four grants.What changed (production)
ExchangeDeviceCode(token_exchange.go): re-check user beforeDeleteDeviceCodeByID, so a doomed redemption does not burn the device code. Recordsoauth_device_code_validation{result="invalid"}.AuthorizationService.ExchangeCode(authorization.go): re-check user beforeMarkAuthorizationCodeUsed, joining the existing RFC 8707 pre-consume guard block.token.go): mapservices.ErrAccessDenied→ OAuthaccess_deniedon theauthorization_codegrant (the device-code grant already mapped it; the auth-code switch did not, so it would have fallen through toinvalid_grant).SUSPICIOUS_ACTIVITY(WARNING) audit event withclient_id. Disabled vs. deleted both collapse toaccess_deniedso we never leak whether an account exists.The
refresh_tokenandclient_credentialsgrants are intentionally untouched: disabling a user already revokes their tokens, so the refresh grant fails its existing revoked/IsActive()check.AI Authorship
plan.md, then refined by an automated 4-agent/simplifyreview pass.Change classification
Plan reference
Plan goal: re-check user identity between authorization and token redemption (Batch C #6). Done = both redemption grants re-verify the user exists and is active immediately before consuming the single-use code; a missing/inactive user is rejected with
access_deniedand the code/token pair is never minted.Two disclosed deviations from the plan's "may modify" list (both intentional):
internal/handlers/token.go— the plan assumed the handler already mappedErrAccessDeniedtoaccess_deniedfor the auth-code grant; it did not. Adding the one-line case was explicitly authorized by the plan's own Risk-mitigation note and confirmed with the author.token_resource_test.go/token_uid_test.go— seeded real active users at their device-code call sites, exactly as the plan's Risk section instructed ("grep for other call sites … seed real users there too").Verification
authorization_codegrant returnsaccess_denied(HTTP 400) for a disabled usermake generate && make fmt && make lint && make test— all clean (lint 0 issues;internal/servicesandinternal/handlerspass with-count=1).Verifiability check
SUSPICIOUS_ACTIVITYaudit event and the device-codeinvalidmetricSecurity check
access_denied) — does not leak disabled vs. deletedRisk & rollback
GetUserByIDread per redemption (not a hot path; refresh is, and is untouched). No schema/migration change.Reviewer guide
internal/services/token_exchange.go(placement beforeDeleteDeviceCodeByID),internal/services/authorization.go(placement beforeMarkAuthorizationCodeUsed),internal/handlers/token.go(the newaccess_deniedcase).*_test.gofiles — test-helper seeding and the new test cases; signatures + names convey intent.