fix(oauth)!: authenticate and authorize clients on refresh grant#202
Merged
Conversation
- Require client_secret for confidential clients on the refresh_token grant, rejecting leaked tokens replayed with only a public client_id - Reject refresh attempts from disabled clients with invalid_client - Fail closed when the client cannot be loaded, instead of falling back to provider defaults - Apply the requested scope narrowing to the issued access token's signed claim while keeping the rotated refresh token at the original grant scope - Thread an accessScope override through the token provider so narrowing reaches the JWT, not just the persisted row - Add a shared util.ScopeOrDefault helper for the narrowing convention - Cover the grant with handler tests and document the breaking change BREAKING CHANGE: confidential clients must now send client_secret (HTTP Basic Auth or form body) on grant_type=refresh_token. Clients that previously refreshed without a secret will receive 401 invalid_client. The TokenProvider.RefreshAccessToken interface gains an accessScope parameter. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
Contributor
There was a problem hiding this comment.
Pull request overview
Hardens the OAuth refresh_token grant by adding the client authentication, active-status, and scope-narrowing enforcement that other grants already had. The refresh path now fails closed on client load and threads a per-request accessScope through the TokenProvider interface so a narrowed scope actually reaches the signed JWT, not just the DB row.
Changes:
- Service-side:
TokenService.RefreshAccessTokenrequiresclientSecret, swaps the best-effort cached lookup forGetClientWithSecret, rejects disabled clients, validates confidential secrets, and signs the narrowedaccessScopeinto the new access token while keeping the rotated refresh token's scope at the original grant. - Provider/interface:
core.TokenProvider.RefreshAccessTokenandLocalTokenProvider.RefreshAccessTokengain anaccessScopeparameter (mocks regenerated, all call sites updated); newutil.ScopeOrDefaulthelper. - Handler/docs/tests:
handleRefreshTokenGrantreads credentials via the sharedparseClientCredentials; newinternal/handlers/token_refresh_test.gocovers narrowing-to-JWT, rotation-keeps-original-scope, missing/wrong/disabled confidential cases, and a public-client regression;docs/SECURITY.mddocuments the breaking change.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
internal/services/token_refresh.go |
Adds client auth/active gate, fail-closed lookup, scope narrowing applied to JWT and persisted row. |
internal/handlers/token.go |
Refresh handler parses Basic-Auth/form client credentials and passes clientSecret through. |
internal/core/token.go, internal/token/local.go |
RefreshAccessToken interface/impl gain accessScope override; doc comments updated. |
internal/util/string.go |
New ScopeOrDefault helper for narrow-or-keep-original convention. |
internal/mocks/mock_token.go |
Regenerated mock for new signature. |
internal/handlers/token_refresh_test.go |
New handler-level tests for narrowing, rotation, missing/wrong secret, disabled client, public client. |
internal/services/token_*_test.go, internal/token/local*_test.go |
Test call sites updated for the new clientSecret/accessScope parameters and the new fail-closed expectation (ErrAccessDenied). |
docs/SECURITY.md |
Documents new refresh authentication, disabled-client rejection, scope narrowing, and the breaking change. |
Files not reviewed (1)
- internal/mocks/mock_token.go: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
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
Hardens the OAuth
refresh_tokengrant — previously the weakest token path — by adding the three client/authorization gates the other grants already enforce, and by making requested scope-narrowing actually take effect. Addresses backlog items #3, #5, and #12.client_secret, only the publicclient_id. A leaked refresh token was replayable using only the (public)client_id, violating RFC 6749 §6.client_secret(HTTP Basic Auth or form body), matchingauthorization_code/client_credentials. Public (PKCE) clients keep authenticating byclient_idonly.client.IsActive(), so a disabled/rejected client could refresh forever.invalid_client.scopeclaim. The rotated refresh token keeps the original grant scope, so a later refresh can re-request up to it (mirroring how RFC 8707resourceis already handled).The client lookup also changes from a best-effort cached fetch (fail-open) to a fail-closed
GetClientWithSecret— the secret and active status cannot be verified otherwise, so a missing/failed client load now rejects the refresh rather than silently falling back to provider defaults.client_secretongrant_type=refresh_token. Clients that previously refreshed without a secret will now receive401 invalid_client. Update them to send their secret via HTTP Basic Auth or theclient_secretform field. Public clients are unaffected.TokenProvider.RefreshAccessTokengains anaccessScopeparameter. Required so scope narrowing reaches the signed JWT rather than only the persisted row. Internal interface; affects customTokenProviderimplementations only.Implementation notes
internal/services/token_refresh.go— client authentication/authorization gate + scope narrowing.internal/token/local.go,internal/core/token.go—accessScopeoverride threaded through the provider and interface.internal/handlers/token.go— refresh handler reads client credentials via the sharedparseClientCredentials(Basic Auth + form).internal/util/string.go— small sharedScopeOrDefaulthelper for the "narrow-or-keep-original" convention, used by both the service and provider sites.internal/mocks/mock_token.go— regenerated for the new interface signature.Verification
internal/handlers/token_refresh_test.gocovers: scope-narrowing happy path (asserts the signed JWT scope, not just the DB row), rotation keeps original scope, missing/wrong secret →401 invalid_client, disabled client →401, and a public-client no-secret regression.make generate,make fmt,make lint(0 issues), andmake test(all 14 packages) pass.Follow-ups (out of scope, tracked in backlog)
authorization_code,client_credentials,refresh); extracting a shared gate is deferred to a separate refactor PR.AI authorship
This change was implemented with Claude Code (Opus 4.8). It was planned (
plan.md), implemented, reviewed by parallel cleanup agents, and verified against the test suite; the diff has been reviewed by a human before merge.🤖 Generated with Claude Code