diff --git a/wristband/registration_test.go b/wristband/registration_test.go index 89cae37..2321518 100644 --- a/wristband/registration_test.go +++ b/wristband/registration_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "net/http/httptest" "strings" @@ -22,6 +23,12 @@ type memoryBackend struct { codes []*AuthCodeRecord refresh []*RefreshTokenRecord tokens []*IssuedToken + // revoked tracks IssuedToken.ID -> revoked, since IssuedToken itself + // carries no status field (it is the one-time mint result, not a + // queryable row). 08-06-PLAN.md's rotation/replay/revoke tests need to + // observe access-token revocation the same way real Postgres tests + // observe models.ApiToken.RevokedAt. + revoked map[uint]bool nextID uint } @@ -164,6 +171,33 @@ func (t *memoryTx) ByTokenHashForUpdate(ctx context.Context, tokenHash string) ( return nil, nil } +func (t *memoryTx) ByAPITokenIDForUpdate(ctx context.Context, apiTokenID uint) (*RefreshTokenRecord, error) { + for _, r := range t.b.refresh { + if r.APITokenID != nil && *r.APITokenID == apiTokenID { + cp := *r + return &cp, nil + } + } + return nil, nil +} + +func (t *memoryTx) MarkRotated(ctx context.Context, id uint, successorID uint) error { + for _, r := range t.b.refresh { + if r.ID == id { + sid := successorID + r.RotatedToID = &sid + return nil + } + } + return nil +} + +// RevokeLineage walks forward through RotatedToID starting at startID, +// stamping RevokedAt on every visited refresh row and marking each row's +// linked access token revoked too (08-06-PLAN.md: mirrors the GORM +// adapter's RevokeLineage so T-08-REFRESH-REPLAY's "kill the whole lineage" +// contract is provable against the in-memory backend, not just real +// Postgres). func (t *memoryTx) RevokeLineage(ctx context.Context, startID uint) error { now := time.Now() id := startID @@ -181,6 +215,12 @@ func (t *memoryTx) RevokeLineage(ctx context.Context, startID uint) error { if found.RevokedAt == nil { found.RevokedAt = &now } + if found.APITokenID != nil { + if t.b.revoked == nil { + t.b.revoked = map[uint]bool{} + } + t.b.revoked[*found.APITokenID] = true + } if found.RotatedToID == nil { return nil } @@ -188,14 +228,46 @@ func (t *memoryTx) RevokeLineage(ctx context.Context, startID uint) error { } } +func (t *memoryTx) DeleteExpiredCodes(ctx context.Context, now time.Time) error { + kept := t.b.codes[:0:0] + for _, c := range t.b.codes { + if c.ExpiresAt.Before(now) { + continue + } + kept = append(kept, c) + } + t.b.codes = kept + return nil +} + +func (t *memoryTx) DeleteExpiredRefreshTokens(ctx context.Context, now time.Time) error { + kept := t.b.refresh[:0:0] + for _, r := range t.b.refresh { + if r.ExpiresAt.Before(now) { + continue + } + kept = append(kept, r) + } + t.b.refresh = kept + return nil +} + +// Mint's secret embeds the freshly-allocated id so two mints for the same +// client name (e.g. across a rotation) never collide on an identical +// "mem_" string -- 08-06-PLAN.md's rotation tests distinguish the old +// and new access tokens by their returned secret. func (t *memoryTx) Mint(ctx context.Context, userID uint, name string, scopes []string, expiresAt time.Time, collectionIDs []uint, clientID string) (IssuedToken, error) { t.b.nextID++ - tok := IssuedToken{ID: t.b.nextID, Secret: "mem_" + name} + tok := IssuedToken{ID: t.b.nextID, Secret: fmt.Sprintf("mem_%d_%s", t.b.nextID, name)} t.b.tokens = append(t.b.tokens, &tok) return tok, nil } func (t *memoryTx) Revoke(ctx context.Context, tokenID uint) error { + if t.b.revoked == nil { + t.b.revoked = map[uint]bool{} + } + t.b.revoked[tokenID] = true return nil } diff --git a/wristband/stores.go b/wristband/stores.go index 9a8c314..f737eed 100644 --- a/wristband/stores.go +++ b/wristband/stores.go @@ -112,6 +112,12 @@ type AuthCodeStore interface { // ones), and extends ExpiresAt to the fresh code TTL. MarkIssued(ctx context.Context, id uint, codeHash string, userID uint, scopes []string, collectionIDs []uint, expiresAt time.Time) error MarkUsed(ctx context.Context, id uint) error + // DeleteExpiredCodes removes pending and issued authorization-code rows + // whose ExpiresAt is before now (D-17: wristband adds an expiry sweep + // PHP lacks). used_at/request_id status is irrelevant to the decision: + // only expiry drives deletion, so unexpired issued-but-unused rows and + // unexpired used rows both survive untouched. + DeleteExpiredCodes(ctx context.Context, now time.Time) error } // RefreshTokenStore persists refresh-token lineage rows. ByTokenHashForUpdate @@ -120,7 +126,29 @@ type AuthCodeStore interface { type RefreshTokenStore interface { Create(ctx context.Context, rec *RefreshTokenRecord) error ByTokenHashForUpdate(ctx context.Context, tokenHash string) (*RefreshTokenRecord, error) + // ByAPITokenIDForUpdate row-locks the refresh row currently linked to + // apiTokenID, if any (08-06-PLAN.md D-08). It is the seam a + // connected-app revoke uses to find the lineage to kill without + // wristband inventing its own SQL join; a nil result (no linked + // refresh row) is not an error. + ByAPITokenIDForUpdate(ctx context.Context, apiTokenID uint) (*RefreshTokenRecord, error) + // MarkRotated links a spent-by-rotation predecessor to its successor + // (PHP OAuthCodeManager::rotateRefresh's `$record->rotated_to_id = + // $successor->id`). The predecessor's own RevokedAt stays nil: a + // rotated-but-not-yet-replayed row remains retrievable as replay + // evidence (D-17); "already rotated" is signaled by RotatedToID, not + // RevokedAt. + MarkRotated(ctx context.Context, id uint, successorID uint) error + // RevokeLineage stamps RevokedAt on startID and every row it was + // rotated to (walking forward through RotatedToID), and revokes each + // visited row's linked access token too (T-08-REFRESH-REPLAY). Rows + // are not deleted: unexpired revoked rows stay as replay evidence + // (D-17). RevokeLineage(ctx context.Context, startID uint) error + // DeleteExpiredRefreshTokens removes refresh rows whose ExpiresAt is + // before now (D-17). Revoked/rotated-but-unexpired rows are untouched + // so replay detection and connected-apps history stay correct. + DeleteExpiredRefreshTokens(ctx context.Context, now time.Time) error } // AccessTokenIssuer mints/revokes the app's ordinary personal access token diff --git a/wristband/token.go b/wristband/token.go index e11aa9c..15541cc 100644 --- a/wristband/token.go +++ b/wristband/token.go @@ -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) } diff --git a/wristband/token_test.go b/wristband/token_test.go index 51df908..239f67f 100644 --- a/wristband/token_test.go +++ b/wristband/token_test.go @@ -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"