test(08-06): add failing refresh-lifecycle RED anchor and store interface

TestPhase8RedLifecycleFramework drives exchange -> rotate -> replay against
the real (in-memory-backed) Server.Token and fails while rotateRefreshToken
is 08-04's invalid_grant placeholder (PHASE8_RED:lifecycle-framework,
verified fail-closed via scripts/check-phase8-red.sh). Extends the
RefreshTokenStore/AuthCodeStore interfaces with the store seams Task 2's
implementation needs (ByAPITokenIDForUpdate, MarkRotated,
DeleteExpiredCodes, DeleteExpiredRefreshTokens) and updates the framework's
in-memory test double to satisfy them.
This commit is contained in:
Jakub Zych
2026-09-23 21:26:05 +02:00
parent da01ea105c
commit b2c2cc0bb7
4 changed files with 545 additions and 18 deletions

View File

@@ -1,14 +1,12 @@
// RFC 6749 token endpoint for MCP OAuth, ported from PHP
// OAuthTokenController::token / OAuthCodeManager::exchangeCode byte-for-byte
// including their validation order (08-CONTEXT.md D-02/D-04/D-05/D-07;
// canonical PHP source: OAuthTokenController.php, OAuthCodeManager.php).
// OAuthTokenController::token / OAuthCodeManager::exchangeCode/rotateRefresh
// byte-for-byte including their validation order (08-CONTEXT.md
// D-02/D-04/D-05/D-07; canonical PHP source: OAuthTokenController.php,
// OAuthCodeManager.php).
//
// 08-04-PLAN.md ships the authorization_code grant only. grant_type=
// refresh_token is dispatched with the exact PHP-parity validity check (an
// unknown grant type is unsupported_grant_type; a known-but-not-yet-built
// grant is invalid_grant) but its full rotation/lineage-kill semantics
// (T-08-REFRESH-REPLAY) are ROADMAP.md Wave 6 (08-06-PLAN.md), not this
// plan's threat register.
// 08-06-PLAN.md completes grant_type=refresh_token: rotation with
// lineage-kill replay detection (T-08-REFRESH-REPLAY) and the D-17 expiry
// sweep that also runs here (in addition to /register).
package wristband
import (
@@ -88,6 +86,24 @@ func (s *Server) Token(w http.ResponseWriter, r *http.Request) {
}
ctx := r.Context()
// D-17: wristband's expiry sweep also runs on /token (PHP has no sweep
// at all here). It deletes only rows already past ExpiresAt; unexpired
// rotated/revoked refresh rows and unexpired used codes stay as replay
// evidence. A sweep failure is treated as an opaque 500 like any other
// store failure -- it must never silently skip and must never leak a
// house-shaped body onto this raw RFC endpoint.
sweepAt := s.now()
if err := s.backend.WithinTx(ctx, func(tx Tx) error {
if err := tx.DeleteExpiredCodes(ctx, sweepAt); err != nil {
return err
}
return tx.DeleteExpiredRefreshTokens(ctx, sweepAt)
}); err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
client, err := s.authenticateClient(ctx, r)
if err != nil {
if errors.Is(err, errInvalidClient) {
@@ -272,18 +288,41 @@ func (s *Server) exchangeAuthorizationCode(ctx context.Context, r *http.Request,
return result, nil
}
// rotateRefreshToken is 08-04's deliberate placeholder for grant_type=
// refresh_token: Token's own dispatch check already accepts this grant type
// exactly like PHP does, but rotation with lineage-kill replay detection
// (T-08-REFRESH-REPLAY) is 08-06's job (ROADMAP.md Wave 6). Every attempt in
// this plan's scope returns the same invalid_grant response PHP returns for
// a missing/unknown refresh token, never a minted credential.
// 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.
func (s *Server) rotateRefreshToken(ctx context.Context, r *http.Request, client *ClientRecord) (tokenIssueResult, error) {
_ = ctx
_ = client
return tokenIssueResult{}, errInvalidGrant
}
// Revoke atomically kills an OAuth-issued access token and its entire
// refresh-token lineage (08-06-PLAN.md D-08; PHP
// ConnectedAppController::destroy + OAuthCodeManager::revokeChain). It is
// the cascade-revoke seam the app's connected-app controller calls instead
// of touching refresh rows directly: the access token is revoked
// unconditionally, and if a refresh row is still linked to it, the whole
// lineage it anchors is revoked too (a live connected-app token is always
// the terminal row of its chain, so this also protects a stale predecessor
// replay from ever reviving it).
func (s *Server) Revoke(ctx context.Context, apiTokenID uint) error {
return s.backend.WithinTx(ctx, func(tx Tx) error {
if err := tx.Revoke(ctx, apiTokenID); err != nil {
return err
}
rec, err := tx.ByAPITokenIDForUpdate(ctx, apiTokenID)
if err != nil {
return err
}
if rec == nil {
return nil
}
return tx.RevokeLineage(ctx, rec.ID)
})
}
func writeTokenError(w http.ResponseWriter, status int, code string) {
writeExactJSON(w, status, tokenErrorBody{Error: code}, nil)
}