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