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

@@ -568,8 +568,9 @@ func TestTokenSuccessResponseHasNoEnvelopeAndNoTrailingNewline(t *testing.T) {
// TestTokenRefreshGrantDispatchIsAcceptedButNotYetImplemented proves Token's
// own grant-type validity check accepts "refresh_token" exactly like PHP
// does (it is not unsupported_grant_type), while full rotation is 08-06's
// job in this plan's scope (see rotateRefreshToken).
// does (it is not unsupported_grant_type): a syntactically well-formed but
// never-issued refresh secret reaches rotateRefreshToken's real lookup and
// is rejected as invalid_grant, not unsupported_grant_type.
func TestTokenRefreshGrantDispatchIsAcceptedButNotYetImplemented(t *testing.T) {
srv, _, _, _, _ := newTokenExchangeFixture(t)
req := tokenRequest(url.Values{
@@ -582,6 +583,393 @@ func TestTokenRefreshGrantDispatchIsAcceptedButNotYetImplemented(t *testing.T) {
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
}
// TestPhase8RedLifecycleFramework is the Phase 8 Wave 6 RED anchor
// (08-06-PLAN.md Task 1, D-04/D-17). It drives a full refresh-rotation and
// replay lifecycle against the real (in-memory-backed) Server.Token:
// exchange a code, rotate the resulting refresh token, replay the spent
// original, and prove the whole lineage -- both access tokens and both
// refresh rows -- ends up dead. It fails with the
// PHASE8_RED:lifecycle-framework sentinel while rotateRefreshToken is
// 08-04's invalid_grant placeholder (the rotate step below expects 200 but
// gets 400); scripts/check-phase8-red.sh verifies this failure is
// fail-closed.
func TestPhase8RedLifecycleFramework(t *testing.T) {
backend := newMemoryBackend()
srv := newTestServer(backend)
insertTokenTestClient(backend, "cli-lifecycle", "none", nil, nil)
verifier, challenge := s256Pair(t)
rawCode, _ := insertTokenTestCode(t, backend, "cli-lifecycle", challenge, nil)
first := httptest.NewRecorder()
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-lifecycle"), ""))
if first.Code != http.StatusOK {
t.Fatalf("PHASE8_RED:lifecycle-framework: exchange status = %d, want %d (body=%s)", first.Code, http.StatusOK, first.Body.String())
}
firstAccess, firstRefresh := decodeTokenPair(t, first)
rotate := httptest.NewRecorder()
srv.Token(rotate, refreshRequest(firstRefresh, "cli-lifecycle"))
if rotate.Code != http.StatusOK {
t.Fatalf("PHASE8_RED:lifecycle-framework: rotation status = %d, want %d (body=%s)", rotate.Code, http.StatusOK, rotate.Body.String())
}
secondAccess, secondRefresh := decodeTokenPair(t, rotate)
if secondRefresh == firstRefresh {
t.Fatalf("PHASE8_RED:lifecycle-framework: rotation must issue a new refresh secret")
}
replay := httptest.NewRecorder()
srv.Token(replay, refreshRequest(firstRefresh, "cli-lifecycle"))
if replay.Code != http.StatusBadRequest {
t.Fatalf("PHASE8_RED:lifecycle-framework: replay status = %d, want %d (body=%s)", replay.Code, http.StatusBadRequest, replay.Body.String())
}
backend.mu.Lock()
defer backend.mu.Unlock()
if !refreshRowRevoked(backend, firstRefresh) {
t.Fatal("PHASE8_RED:lifecycle-framework: original refresh row must be revoked after replay")
}
if !refreshRowRevoked(backend, secondRefresh) {
t.Fatal("PHASE8_RED:lifecycle-framework: rotated successor refresh row must be revoked after replay of its predecessor")
}
if !accessTokenRevoked(backend, firstAccess) {
t.Fatal("PHASE8_RED:lifecycle-framework: original access token must be revoked")
}
if !accessTokenRevoked(backend, secondAccess) {
t.Fatal("PHASE8_RED:lifecycle-framework: rotated access token must be revoked after replay")
}
}
// decodeTokenPair extracts access_token/refresh_token from a successful
// Token response body.
func decodeTokenPair(t *testing.T, rec *httptest.ResponseRecorder) (access, refresh string) {
t.Helper()
var got map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode token body: %v (body=%s)", err, rec.Body.String())
}
access, _ = got["access_token"].(string)
refresh, _ = got["refresh_token"].(string)
if access == "" || refresh == "" {
t.Fatalf("access_token/refresh_token empty in %v", got)
}
return access, refresh
}
// refreshRequest builds a POST /oauth/mcp/token grant_type=refresh_token
// request.
func refreshRequest(rawRefresh, clientID string) *http.Request {
return tokenRequest(url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {rawRefresh},
"client_id": {clientID},
}, "")
}
// refreshRowRevoked reports whether the refresh row matching rawRefresh has
// a non-nil RevokedAt. The caller must already hold backend.mu.
func refreshRowRevoked(backend *memoryBackend, rawRefresh string) bool {
hash := sha256Hex(rawRefresh)
for _, r := range backend.refresh {
if r.TokenHash == hash {
return r.RevokedAt != nil
}
}
return false
}
// accessTokenRevoked reports whether the IssuedToken matching rawAccess is
// marked revoked in backend.revoked. The caller must already hold
// backend.mu.
func accessTokenRevoked(backend *memoryBackend, rawAccess string) bool {
for _, tok := range backend.tokens {
if tok.Secret == rawAccess {
return backend.revoked[tok.ID]
}
}
return false
}
// TestRefreshRotationIssuesNewPairAndKeepsPredecessorAsEvidence proves D-04's
// normal-path rotation: a fresh (not-yet-rotated) refresh token succeeds
// exactly once, mints a new access/refresh pair with the same scopes, and
// leaves the predecessor row retrievable (not deleted) with RotatedToID set
// but RevokedAt nil -- a rotated-but-unreplayed row is not itself "revoked"
// (D-17 evidence retention).
func TestRefreshRotationIssuesNewPairAndKeepsPredecessorAsEvidence(t *testing.T) {
backend := newMemoryBackend()
srv := newTestServer(backend)
insertTokenTestClient(backend, "cli-rotate", "none", nil, nil)
verifier, challenge := s256Pair(t)
rawCode, _ := insertTokenTestCode(t, backend, "cli-rotate", challenge, nil)
first := httptest.NewRecorder()
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-rotate"), ""))
firstAccess, firstRefresh := decodeTokenPair(t, first)
rotate := httptest.NewRecorder()
srv.Token(rotate, refreshRequest(firstRefresh, "cli-rotate"))
if rotate.Code != http.StatusOK {
t.Fatalf("status = %d, want %d (body=%s)", rotate.Code, http.StatusOK, rotate.Body.String())
}
var got map[string]any
if err := json.Unmarshal(rotate.Body.Bytes(), &got); err != nil {
t.Fatal(err)
}
if got["scope"] != "read write" {
t.Fatalf("scope = %v, want %q", got["scope"], "read write")
}
secondAccess, secondRefresh := decodeTokenPair(t, rotate)
if secondAccess == firstAccess || secondRefresh == firstRefresh {
t.Fatal("rotation must mint a brand new access/refresh pair")
}
backend.mu.Lock()
defer backend.mu.Unlock()
hash := sha256Hex(firstRefresh)
for _, r := range backend.refresh {
if r.TokenHash == hash {
if r.RevokedAt != nil {
t.Fatal("a rotated-but-unreplayed predecessor must not itself be revoked")
}
if r.RotatedToID == nil {
t.Fatal("predecessor must have RotatedToID set to its successor")
}
return
}
}
t.Fatal("predecessor refresh row not found (must not be deleted)")
}
// TestRefreshWrongClientIsInvalidGrant proves the client-binding check: a
// refresh token issued to one client cannot be redeemed by another.
func TestRefreshWrongClientIsInvalidGrant(t *testing.T) {
backend := newMemoryBackend()
srv := newTestServer(backend)
insertTokenTestClient(backend, "cli-owner-r", "none", nil, nil)
insertTokenTestClient(backend, "cli-other-r", "none", nil, nil)
verifier, challenge := s256Pair(t)
rawCode, _ := insertTokenTestCode(t, backend, "cli-owner-r", challenge, nil)
first := httptest.NewRecorder()
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-owner-r"), ""))
_, firstRefresh := decodeTokenPair(t, first)
rec := httptest.NewRecorder()
srv.Token(rec, refreshRequest(firstRefresh, "cli-other-r"))
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
}
// TestRefreshExpiredIsInvalidGrant proves an expired refresh row is
// rejected even though it was never rotated or revoked.
func TestRefreshExpiredIsInvalidGrant(t *testing.T) {
backend := newMemoryBackend()
srv := newTestServer(backend)
insertTokenTestClient(backend, "cli-refresh-expired", "none", nil, nil)
verifier, challenge := s256Pair(t)
rawCode, _ := insertTokenTestCode(t, backend, "cli-refresh-expired", challenge, nil)
first := httptest.NewRecorder()
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-refresh-expired"), ""))
_, firstRefresh := decodeTokenPair(t, first)
backend.mu.Lock()
hash := sha256Hex(firstRefresh)
for _, r := range backend.refresh {
if r.TokenHash == hash {
r.ExpiresAt = time.Now().Add(-1 * time.Minute)
}
}
backend.mu.Unlock()
rec := httptest.NewRecorder()
srv.Token(rec, refreshRequest(firstRefresh, "cli-refresh-expired"))
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
}
// TestRefreshUnknownTokenIsInvalidGrant proves a syntactically valid but
// never-issued refresh secret is rejected exactly like PHP's missing-record
// case, not distinguished from any other invalid_grant.
func TestRefreshUnknownTokenIsInvalidGrant(t *testing.T) {
srv, _, _, _, _ := newTokenExchangeFixture(t)
rec := httptest.NewRecorder()
srv.Token(rec, refreshRequest("does-not-exist-at-all", "cli-tok"))
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
}
// TestRefreshMissingTokenIsInvalidGrant proves an empty refresh_token value
// is rejected before any store lookup.
func TestRefreshMissingTokenIsInvalidGrant(t *testing.T) {
srv, _, _, _, _ := newTokenExchangeFixture(t)
rec := httptest.NewRecorder()
srv.Token(rec, tokenRequest(url.Values{
"grant_type": {"refresh_token"},
"client_id": {"cli-tok"},
}, ""))
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
}
// TestRefreshConcurrentReplayHasExactlyOneWinner is the concurrent half of
// T-08-REFRESH-REPLAY: two synchronized goroutines racing to rotate the same
// refresh token must produce exactly one 200 and one invalid_grant, never
// two successors sharing one predecessor.
func TestRefreshConcurrentReplayHasExactlyOneWinner(t *testing.T) {
backend := newMemoryBackend()
srv := newTestServer(backend)
insertTokenTestClient(backend, "cli-refresh-race", "none", nil, nil)
verifier, challenge := s256Pair(t)
rawCode, _ := insertTokenTestCode(t, backend, "cli-refresh-race", challenge, nil)
first := httptest.NewRecorder()
srv.Token(first, tokenRequest(validExchangeForm(rawCode, verifier, "cli-refresh-race"), ""))
_, firstRefresh := decodeTokenPair(t, first)
results := make([]int, 2)
start := make(chan struct{})
done := make(chan struct{})
for i := range 2 {
go func(i int) {
<-start
rec := httptest.NewRecorder()
srv.Token(rec, refreshRequest(firstRefresh, "cli-refresh-race"))
results[i] = rec.Code
done <- struct{}{}
}(i)
}
close(start)
<-done
<-done
successCount, grantErrCount := 0, 0
for _, code := range results {
switch code {
case http.StatusOK:
successCount++
case http.StatusBadRequest:
grantErrCount++
default:
t.Fatalf("unexpected status %d", code)
}
}
if successCount != 1 || grantErrCount != 1 {
t.Fatalf("successCount=%d grantErrCount=%d, want 1 and 1 (results=%v)", successCount, grantErrCount, results)
}
}
// TestTokenSweepDeletesExpiredRowsButKeepsUnexpiredEvidence proves D-17: an
// expired pending/code row and an expired refresh row are gone after the
// next /token call's sweep, while an unexpired-but-revoked refresh row (real
// replay evidence) survives untouched.
func TestTokenSweepDeletesExpiredRowsButKeepsUnexpiredEvidence(t *testing.T) {
backend := newMemoryBackend()
srv := newTestServer(backend)
insertTokenTestClient(backend, "cli-sweep", "none", nil, nil)
_, expiredCode := insertTokenTestCode(t, backend, "cli-sweep", "unused-challenge", func(rec *AuthCodeRecord) {
rec.ExpiresAt = time.Now().Add(-1 * time.Hour)
})
expiredCodeID := expiredCode.ID
backend.mu.Lock()
backend.nextID++
expiredRefreshID := backend.nextID
backend.refresh = append(backend.refresh, &RefreshTokenRecord{
ID: expiredRefreshID,
TokenHash: sha256Hex("expired-refresh-secret"),
ClientID: "cli-sweep",
UserID: 1,
Scopes: []string{"read"},
ExpiresAt: time.Now().Add(-1 * time.Hour),
})
now := time.Now()
backend.nextID++
evidenceRefreshID := backend.nextID
backend.refresh = append(backend.refresh, &RefreshTokenRecord{
ID: evidenceRefreshID,
TokenHash: sha256Hex("revoked-but-unexpired-refresh-secret"),
ClientID: "cli-sweep",
UserID: 1,
Scopes: []string{"read"},
ExpiresAt: time.Now().Add(24 * time.Hour),
RevokedAt: &now,
})
backend.mu.Unlock()
// Any /token call runs the sweep; use a deliberately-broken grant so no
// mutation beyond the sweep happens.
rec := httptest.NewRecorder()
srv.Token(rec, tokenRequest(url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {"does-not-exist"},
"client_id": {"cli-sweep"},
}, ""))
backend.mu.Lock()
defer backend.mu.Unlock()
for _, c := range backend.codes {
if c.ID == expiredCodeID {
t.Fatal("expired code row must be swept")
}
}
for _, r := range backend.refresh {
if r.ID == expiredRefreshID {
t.Fatal("expired refresh row must be swept")
}
}
found := false
for _, r := range backend.refresh {
if r.ID == evidenceRefreshID {
found = true
}
}
if !found {
t.Fatal("unexpired revoked refresh row must survive the sweep as replay evidence")
}
}
// TestServerRevokeKillsAccessAndLineage proves Server.Revoke (the seam the
// app's connected-app controller calls, 08-06-PLAN.md D-08): revoking a live
// OAuth access token also revokes its linked refresh row.
func TestServerRevokeKillsAccessAndLineage(t *testing.T) {
backend := newMemoryBackend()
srv := newTestServer(backend)
insertTokenTestClient(backend, "cli-revoke", "none", nil, nil)
verifier, challenge := s256Pair(t)
rawCode, _ := insertTokenTestCode(t, backend, "cli-revoke", challenge, nil)
exchange := httptest.NewRecorder()
srv.Token(exchange, tokenRequest(validExchangeForm(rawCode, verifier, "cli-revoke"), ""))
access, refresh := decodeTokenPair(t, exchange)
backend.mu.Lock()
var tokenID uint
for _, tok := range backend.tokens {
if tok.Secret == access {
tokenID = tok.ID
}
}
backend.mu.Unlock()
if tokenID == 0 {
t.Fatal("minted access token not found in backend")
}
if err := srv.Revoke(t.Context(), tokenID); err != nil {
t.Fatalf("Revoke: %v", err)
}
backend.mu.Lock()
if !backend.revoked[tokenID] {
t.Fatal("access token must be revoked")
}
if !refreshRowRevoked(backend, refresh) {
t.Fatal("linked refresh row must be revoked")
}
backend.mu.Unlock()
rec := httptest.NewRecorder()
srv.Token(rec, refreshRequest(refresh, "cli-revoke"))
assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant")
}
func TestTokenBackendUnavailableIsOpaque500(t *testing.T) {
opts := DefaultOptions()
opts.Issuer = "https://plytarium.com"