Repository navigation
fix(oauth2): enforce the refresh-token rotation the profile requires - #89
Merged
Merged
Conversation
Profile20BCP, the zero value, and Profile21Draft advertise mandatory refresh-token rotation, yet the refresh_token grant rotated only when grant.Config.RotateRefreshTokens was set and a RefreshTokens generator was present; Profile.RequiresRefreshRotation had no caller outside a test. The zero configuration therefore answered a refresh with a new access token, no replacement refresh token, and a presented token that stayed live, so it could be replayed until it expired. With the flag set but no generator, even Profile20 dropped the requested rotation silently. The grant now decides rotation before minting anything, the way authorization_code applies the profile to PKCE: the profile can only tighten RotateRefreshTokens. Under BCP and 2.1 every exchange consumes the presented token and returns a new one in the same family, and a replayed token is refused with invalid_grant and revokes the family (RFC 9700 §4.14.2). A rotation that cannot be honored, for want of a RefreshTokens generator, fails with server_error carrying the new grant.ErrRotationRequiresGenerator as cause, before any token is generated or persisted. The guard runs after request validation, so a misconfigured server still answers invalid or replayed tokens precisely and still revokes a reused family. Non-rotating refresh tokens remain an explicit Profile20 choice. The example drops its now-redundant RotateRefreshTokens, and the godoc, the security considerations and the CHANGELOG describe the mandate. Refs #78.
The refresh_token grant now fails closed when it has to rotate without a RefreshTokens generator, but a server wired that way still booted and only broke on the first refresh, answering server_error to every client. The mismatch between the grant and the profile is a configuration error, knowable at construction. oauth2.ProfileValidator is a new optional Grant capability, following the SecretMatcher pattern: NewServer calls ValidateProfile on every registered grant implementing it, under every profile, and refuses to build the server on error. The refresh_token grant implements it with the very rotation decision it applies at runtime, so the policy lives in one place, and a compile-time assertion keeps a renamed method from silently disabling the check. NewServer therefore rejects the grant without a generator under Profile20BCP and Profile21Draft, and under Profile20 when RotateRefreshTokens is set, wrapping grant.ErrRotationRequiresGenerator; the legacy-grant ban is unchanged. A grant hidden behind a decorator that does not forward ValidateProfile is not checked at construction; the grant's runtime guard still keeps the exchange closed, and the godoc asks decorators to forward the method. Refs #78.
RFC 6749 §6 requires the scope of a newly issued refresh token to be identical to that of the refresh token the client presented. The refresh_token grant minted the replacement with the scope of the request instead, so a client that narrowed one refresh permanently shrank its grant: the next exchange inherited the narrowed scope, and asking for the original scope again was refused with invalid_scope. Rotation being now the default under Profile20BCP and Profile21Draft, every narrowed refresh hit this path. The replacement token now carries the presented token's scope; narrowing only affects the access token issued by the exchange. Refs #78.
Two behaviors of the rotation change had no test that would notice a regression. An explicit RotateRefreshTokens opt-in under Profile20 must still rotate at runtime, yet the suite kept passing when rotation followed the profile alone or when Profile20 skipped it: the opt-in was only exercised without a generator, where it fails closed. NewServer must check every registered grant, yet the suite kept passing when the check stopped after the first grant or returned on the first validator that accepted the profile. New tests rotate under Profile20 with the opt-in, both at the grant and through the token endpoint: the replacement is returned, the presented token is consumed, and replaying it is refused as reuse. Another builds servers whose offending grant comes second, a legacy grant after a refresh grant that can rotate and a refresh grant that cannot rotate after client_credentials, and expects NewServer to refuse both. The CHANGELOG now names sender-constrained refresh tokens as the alternative RFC 9700 §2.2.2 allows for public clients, and the security considerations say that NewServer also refuses the grant under Profile20 when RotateRefreshTokens is set. Refs #78.
Coverage Report for CI Build 38007901732Coverage increased (+0.04%) to 91.812%Details
Uncovered ChangesNo uncovered changes found. Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
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.
Closes #78.
Problem
Profile20BCP(the zero value, hence the default) andProfile21Draftadvertise mandatory refresh-token rotation, and
Profile.RequiresRefreshRotation()says so, but nothing enforced it: itsonly caller was a test. The
refresh_tokengrant rotated only when both theflag and a generator were set:
With the zero configuration a refresh therefore returned a new access token,
no replacement refresh token, and left the presented token live: it could be
replayed until it expired, under the very profile that promises rotation
and reuse detection (RFC 9700 §4.14.2).
TestRefreshTokenWithoutRotationcodified the behavior (it set no profile, i.e. BCP), and every end-to-end
wiring — tests, integrations, the example — hid it by setting
RotateRefreshTokens: trueexplicitly.Two related defects on the same path:
Profile20withRotateRefreshTokens: truebut no generator silentlydropped the requested rotation.
MUST be identical to that of the refresh token included by the client in
the request." The rotated token inherited the narrowed request scope, so
narrowing one refresh shrank the grant for good (asking for the original
scope again got
invalid_scope). Pre-existing, but this PR makes rotationthe default path, so it is fixed here (own commit).
Fix
One private predicate decides rotation. The profile can only tighten the
configuration, never relax it — the rule
authorization_codealreadyapplies to PKCE:
It is enforced twice:
issueRotated: after requestvalidation (missing parameter, lookup, reuse detection with family
revocation, expiry, client binding, scope) and before any access token is
generated or persisted. A rotation that cannot be honored fails with
server_errorcarrying the newgrant.ErrRotationRequiresGeneratorascause; the wire only gets
{"error":"server_error","error_description":"internal server error"},the cause reaches
ServerConfig.OnErroronly. Since the guard followsvalidation, a misconfigured server still answers invalid or replayed
tokens precisely and still revokes a reused family.
oauth2.ProfileValidator(
ValidateProfile(Profile) error), following theSecretMatcherpattern.NewServercalls it on every registered grant implementing it, under everyprofile (the legacy-grant ban is unchanged), and refuses to build the
server:
oauth2: NewServer: grant "refresh_token" is inconsistent with profile oauth2.0-bcp: oauth2/grant: refresh-token rotation requires a RefreshTokens generator.*grant.RefreshTokenimplements it with the samerotatespredicate, sothe policy lives in one place, and a compile-time assertion keeps a rename
from silently disabling the check.
RotateRefreshTokensNewServerinvalid_grant+ family revokedserver_errorbefore minting; token not consumedProfile20Profile20Profile20server_errorbefore minting (used to skip silently)The scope fix makes the replacement refresh token keep the presented token's
scope; narrowing a refresh only narrows the access token it issues.
Design notes:
ProfileValidatoronly moves the failure to boot: a grant wrapped by adecorator that does not forward
ValidateProfileis not checked atconstruction (the godoc asks decorators to forward it) and still fails
closed at runtime. The "no optional-interface fallbacks" rule agreed for
this series is about the
Storageguarantees (oauth2/store: make refresh-family revocation reliable across races and storage failures #82, oauth2: retain authorization-code replay metadata to revoke previously issued tokens #80), not thisboot-time check.
Profile20+RotateRefreshTokens: true+ no generator goesslightly beyond what was decided for this issue (rotation forced under
BCP / 2.1, non-rotation kept as an explicit
Profile20choice), whilestaying consistent with it: an explicit rotation request is a security
opt-in and must not be dropped silently.
returns a new refresh token. Clients MUST keep it (RFC 6749 §6) and must
not refresh concurrently with the same token, or they trip reuse detection
and lose the family. Documented in
docs/security-considerations.md.Docs: godoc (
grant.Config.RefreshTokens/RotateRefreshTokens/RequirePKCE,Profile,GrantRequest.Profile, package docs, and the staleAccessToken.FamilyID/TokenPaircomments),CHANGELOG.md(Added,Security, Fixed),
docs/security-considerations.md,docs/architecture.md.The canonical example drops its now-redundant
RotateRefreshTokens: true.Tests
New
oauth2/grant/refresh_token_test.goandoauth2/refresh_rotation_test.go(the pre-existing gofmt-dirty test files are untouched), plus extensions of
grant_more_test.goandexamples/oauth2/main_test.go.Grant level:
TestRefreshTokenRotationMandatedByProfile(BCP, 2.1; flag false,generator set) — rotates in the same family and consumes the presented
token; a replay gets
ErrRefreshTokenReused(invalid_grant) and revokesthe family (replacement consumed, its access token gone).
TestRefreshTokenRotationOptInUnderProfile20— the explicit opt-inrotates; a replay is refused as reuse.
TestRefreshTokenRotationWithoutGeneratorFailsBeforeIssuance(BCP, 2.1,Profile20+ opt-in) —server_errorwith the sentinel as cause,zero access-token generations, presented token untouched.
TestRefreshTokenReuseDetectedWithoutGenerator(pin) — a server thatcannot rotate still answers reuse with
invalid_grantand revokes thefamily.
TestRefreshTokenWithoutRotationUnderProfile20(pin, replacesTestRefreshTokenWithoutRotation) — legacy non-rotation, with andwithout a generator: reusable, never consumed.
TestRefreshTokenValidateProfile— 12-row truth table{2.0, BCP, 2.1} × flag × generator, plus an unknown profile that fails
closed.
TestRefreshTokenNarrowsScope(extended) — the rotated token keepsread:mail write:mail, returned and persisted; the next exchange withoutscopegets the full scope back.HTTP level:
TestTokenEndpointRefreshRotationMandatedByProfile(BCP, 2.1) — 200 witha new
refresh_token; replay → 400invalid_grant; the replacement andits access token are revoked.
TestTokenEndpointRefreshRotationOptInUnderProfile20andTestTokenEndpointRefreshWithoutRotationUnderProfile20(pin).TestTokenEndpointRefreshFailsClosedWhenRotationUnavailable— the granthidden behind a decorator: 500 with the exact generic body, the cause on
OnErroronly, zero generations, token not consumed.TestNewServerRefusesRefreshGrantThatCannotRotateandTestNewServerChecksEveryGrantAgainstProfile— refusal wrapping thesentinel, whatever the grant's position among the registered grants.
TestExampleOAuth2AuthorizationCodeFlow(extended) — the canonicalexample rotates end to end without the flag; replaying the old token
→ 400.
Red phase, before each fix:
the replay was accepted.
error.
OnError.ValidateProfiletruth table andNewServerrefusal: the 7 and 3 errorrows were accepted (nil stub, then unwired server).
read:mail(returned, persisted, and onthe next exchange).
TestRefreshTokenWithoutRotation(zero profile) fails oncerotation is enforced, as the issue predicted; the pin tests pass before
and after.
Mutation testing on a scratch copy — each mutant fails at least one test:
Profile20opt-in dropped (inrotatesor inissueRotated)SaveAccessTokenHandleerror_descriptionprofileConstraintsreverted,ValidateProfilereturning nilNewServerrefusal tests (+ truth table)Profile20NewServerrefusal testreturn v.ValidateProfile(p)TestRefreshTokenNarrowsScopeChecks
make build— OK.make test— every package passes with-race;oauth2/grantcoverage 91.2% → 91.7%,
oauth2unchanged at 91.1%. New code is 100%covered (
rotates,ValidateProfile, the guard,profileConstraints);the lines of
issueRotatedstill uncovered are the pre-existinggenerator/store error branches.
make lint— 0 issues.Notes for the maintainer
CLAUDE.mdis not edited here. Proposed replacement for theProfilebullet of the "OAuth2 server" section:
Left out of scope: an access token can still be orphaned when the refresh
generator or
RotateRefreshTokenfails afterSaveAccessToken(pre-existing, oauth2/store: make refresh-family revocation reliable across races and storage failures #82 makes issuance atomic — the up-front rotation decision
is what it needs); a zero
RefreshTTLwith forced rotation mintsimmediately-expiring refresh tokens (fails closed); the PKCE / Profiles
bullets of
docs/security-considerations.mdand the "BCP §8.10"citations elsewhere are stale.