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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user