fix(token): re-check user is active on refresh_token grant#267
Open
appleboy wants to merge 1 commit into
Open
Conversation
- Reject refresh when the token owner is disabled or deleted - Block the grant after client auth, before issuing or rotating tokens - Log a SUSPICIOUS_ACTIVITY audit event for the blocked refresh - Surface a dedicated user-inactive error as OAuth access_denied - Keep client authentication failures mapped to invalid_client - Add service and handler tests covering disabled and deleted owners 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! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The
refresh_tokengrant never re-checked whether the token's owner still exists and is active before minting new tokens. Commit #204 added that guard to thedevice_codeandauthorization_coderedemption paths but missed refresh — the longest-lived, most frequently replayed credential. Any path that disables/deletes a user without fully revoking their tokens (e.g.SetUserActiveStatus, external auth-source sync) left their refresh token able to keep issuing fresh access tokens. This PR closes that gap and returns the correct OAuth error code.Architecture / flow
sequenceDiagram participant C as Client participant H as handleRefreshTokenGrant participant T as RefreshAccessToken participant S as Store participant A as AuditService participant P as TokenProvider C->>H: POST /oauth/token (grant_type=refresh_token) H->>T: RefreshAccessToken(...) Note over T: existing: token valid, client auth (active + secret) rect rgb(223,240,216) T->>S: GetUserByID(refreshToken.UserID) alt user missing or !IsActive T->>A: LogSync(SUSPICIOUS_ACTIVITY, WARNING, ResourceToken) T-->>H: ErrUserInactive H-->>C: 400 access_denied end end Note over T: compose claims, issue / rotate T->>P: RefreshAccessToken(...) T->>S: RunInTransaction(create/rotate token) T-->>H: new access (+ refresh) token H-->>C: 200 access_tokenThe green block is new. It runs after client authentication and before composing claims / calling the provider, so a rejected refresh never rotates or revokes the existing token.
AI Authorship
plan.mdinternal/services/token_refresh.go,internal/services/token.go,internal/handlers/token.go, and the three test files)Change classification
This is on the OAuth token-issuance / auth path. A mistake here could either let disabled users keep minting tokens (security gap) or wrongly reject legitimate refreshes (availability).
Plan reference
A
plan.mddrove the work (kept local, not committed). Goal: re-check the refresh token's owner is present and active before issuance, mirroring the device/auth-code paths from #204; on failure log aSUSPICIOUS_ACTIVITYevent and reject without rotating/revoking the token. The sharedassertUserStillActivehelper refactor was explicitly out of scope (separate issue).Two scope decisions were made with the human during execution:
ErrUserInactivesentinel mapped to OAuthaccess_denied(instead ofinvalid_client), which required touchinginternal/handlers/token.go(outside the plan's original "May modify" list) — approved, to match the plan's intent and the sibling grants.Verification
400 access_denied/admin/users/:id/disable, then attemptgrant_type=refresh_token→ expectaccess_denied; confirm aSUSPICIOUS_ACTIVITYentry in/admin/audit.make generate/make fmt/make lint(0 issues) /make test(full suite) all pass.Verifiability check
RefreshAccessToken, comments on the new sentinel + guard)RecordTokenRefresh(false)metric +SUSPICIOUS_ACTIVITYaudit log)Security check
refreshToken.UserID)/oauth/tokenrate limit unchanged)access_denied, no user state detail)Risk & rollback
GetUserByIDprimary-key lookup per refresh (refresh already does a token + client lookup; negligible). If a legitimate refresh token'sUserIDdid not resolve to an active user row it would now be rejected — guarded against by the happy-path test and by confirmingclient_credentialsissues no refresh tokens, so refresh tokens always carry a real user.Reviewer guide
internal/services/token_refresh.go(the new guard block and its placement relative to client auth and token rotation),internal/services/token.go(theErrUserInactivesentinel),internal/handlers/token.go(the newaccess_deniedmapping ordering before theinvalid_clientcase).token_refresh_test.go(services + handlers),token_resource_test.go(user-seeding additions).