feat(08-06): implement refresh rotation, replay lineage-kill, and token sweep

rotateRefreshToken ports OAuthCodeManager::rotateRefresh: a fresh refresh
token rotates atomically (revoke old access token, mint successor, link
rotated_to_id) while a replayed (already-rotated) token instead revokes the
whole lineage and commits that kill before Token maps it to invalid_grant
outside the transaction (T-08-REFRESH-REPLAY). Token also runs the D-17
expiry sweep (DeleteExpiredCodes/DeleteExpiredRefreshTokens) before grant
processing. Server.Revoke is the new cascade-revoke seam a connected-app
controller uses instead of touching refresh rows directly.
This commit is contained in:
Jakub Zych
2026-09-23 21:26:35 +02:00
parent b2c2cc0bb7
commit dab2b8f31a

View File

@@ -288,14 +288,99 @@ func (s *Server) exchangeAuthorizationCode(ctx context.Context, r *http.Request,
return result, nil
}
// rotateRefreshToken is a RED-verification placeholder (08-06-PLAN.md Task
// 1): it deliberately has not yet implemented rotation/replay-kill so
// TestPhase8RedLifecycleFramework fails fail-closed via
// scripts/check-phase8-red.sh before the real implementation lands.
// rotateRefreshToken ports OAuthCodeManager::rotateRefresh inside one
// WithinTx callback (D-04/D-05/D-07): the presented refresh secret is row-
// locked, validated (not expired, not revoked, bound to the authenticated
// client), and then either rotated (mint a new access token, revoke the old
// one, create and link a successor refresh row) or -- if it was already
// rotated once before -- treated as a replay: the entire lineage (every
// rotated-to successor and each linked access token) is revoked and the
// callback still returns nil so that revocation commits (T-08-REFRESH-
// REPLAY). Token then maps the recorded "replayed" outcome to invalid_grant
// outside the transaction, exactly mirroring PHP's own commit-then-throw
// shape: the security-relevant kill must land even though the protocol
// response is an error.
func (s *Server) rotateRefreshToken(ctx context.Context, r *http.Request, client *ClientRecord) (tokenIssueResult, error) {
_ = ctx
_ = client
return tokenIssueResult{}, errInvalidGrant
raw := r.Form.Get("refresh_token")
if raw == "" {
return tokenIssueResult{}, errInvalidGrant
}
var result tokenIssueResult
var replayed bool
err := s.backend.WithinTx(ctx, func(tx Tx) error {
rec, err := tx.ByTokenHashForUpdate(ctx, sha256Hex(raw))
if err != nil {
return err
}
if rec == nil ||
!rec.ExpiresAt.After(s.now()) ||
rec.RevokedAt != nil ||
rec.ClientID != client.ClientID {
return errInvalidGrant
}
if rec.RotatedToID != nil {
// T-08-REFRESH-REPLAY: a spent (already-rotated) refresh token
// was presented again. Kill the whole lineage and commit that
// kill; the caller maps replayed -> invalid_grant afterward.
if err := tx.RevokeLineage(ctx, rec.ID); err != nil {
return err
}
replayed = true
return nil
}
if rec.APITokenID != nil {
if err := tx.Revoke(ctx, *rec.APITokenID); err != nil {
return err
}
}
name := truncateRunes(client.ClientName, 120)
expiresAt := s.now().Add(s.opts.AccessTokenTTL)
minted, err := tx.Mint(ctx, rec.UserID, name, rec.Scopes, expiresAt, rec.CollectionIDs, client.ClientID)
if err != nil {
return err
}
rawNew, err := s.randomBytes(32)
if err != nil {
return err
}
accessTokenID := minted.ID
successor := &RefreshTokenRecord{
TokenHash: sha256Hex(rawNew),
APITokenID: &accessTokenID,
ClientID: client.ClientID,
UserID: rec.UserID,
Scopes: rec.Scopes,
CollectionIDs: rec.CollectionIDs,
ExpiresAt: s.now().Add(s.opts.RefreshTokenTTL),
OfflineAccess: rec.OfflineAccess,
}
if err := tx.Create(ctx, successor); err != nil {
return err
}
if err := tx.MarkRotated(ctx, rec.ID, successor.ID); err != nil {
return err
}
result = tokenIssueResult{
AccessToken: minted.Secret,
RefreshToken: rawNew,
Scopes: rec.Scopes,
OfflineAccess: rec.OfflineAccess,
}
return nil
})
if err != nil {
return tokenIssueResult{}, err
}
if replayed {
return tokenIssueResult{}, errInvalidGrant
}
return result, nil
}
// Revoke atomically kills an OAuth-issued access token and its entire