diff --git a/wristband/token.go b/wristband/token.go index 15541cc..bad6969 100644 --- a/wristband/token.go +++ b/wristband/token.go @@ -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