package wristband import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" ) const tokenTestRedirect = "https://chatgpt.com/connector/oauth/cb" // insertTokenTestClient inserts an already-usable ClientRecord directly into // backend (bypassing CreateWithCap's cap/sweep policy, which this plan's // tests do not exercise) and returns it. func insertTokenTestClient(backend *memoryBackend, clientID, authMethod string, secretHash *string, ceiling []string) *ClientRecord { backend.mu.Lock() defer backend.mu.Unlock() backend.nextID++ rec := &ClientRecord{ ID: backend.nextID, ClientID: clientID, ClientSecretHash: secretHash, ClientName: "Test Client", RedirectURIs: []string{tokenTestRedirect}, GrantTypes: []string{"authorization_code", "refresh_token"}, TokenEndpointAuthMethod: authMethod, ScopeCeiling: ceiling, CreatedAt: time.Now(), } backend.clients = append(backend.clients, rec) return rec } // insertTokenTestCode seeds an already-issued (post-consent) code row // directly into backend, matching the shape 08-05's consent flow will // produce via AuthCodeStore.MarkIssued: CodeHash set, RequestID nil, UserID // set. mutate, when non-nil, is applied to the record before it is stored so // individual tests can adjust ExpiresAt/UsedAt/ClientID/etc. func insertTokenTestCode(t *testing.T, backend *memoryBackend, clientID string, challenge string, mutate func(*AuthCodeRecord)) (rawCode string, rec *AuthCodeRecord) { t.Helper() raw, err := randomBase64URL(32) if err != nil { t.Fatal(err) } backend.mu.Lock() defer backend.mu.Unlock() backend.nextID++ userID := uint(1) hash := sha256Hex(raw) rec = &AuthCodeRecord{ ID: backend.nextID, CodeHash: &hash, ClientID: clientID, UserID: &userID, RedirectURI: tokenTestRedirect, Scopes: []string{"read", "write"}, CodeChallenge: challenge, CodeChallengeMethod: "S256", ExpiresAt: time.Now().Add(5 * time.Minute), } if mutate != nil { mutate(rec) } backend.codes = append(backend.codes, rec) return raw, rec } // tokenRequest builds a POST /oauth/mcp/token request from form (encoded as // the body) with an optional Authorization header, matching the D-02 body // parser every test in this file exercises. func tokenRequest(form url.Values, contentType string) *http.Request { if contentType == "" { contentType = "application/x-www-form-urlencoded" } req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/token", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", contentType) return req } // TestPhase8RedCodeExchange is the Phase 8 Wave 4 RED anchor (08-04-PLAN.md // Task 1, D-02/D-04/D-05/D-07). It drives one valid S256 authorization-code // exchange through the real (in-memory-backed) Server.Token and asserts the // exact RFC 6749 success contract. It fails with the // PHASE8_RED:code-exchange sentinel while Token is the 501 stub; // scripts/check-phase8-red.sh verifies this failure is fail-closed. func TestPhase8RedCodeExchange(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) insertTokenTestClient(backend, "cli-red", "none", nil, nil) verifier, challenge := s256Pair(t) rawCode, _ := insertTokenTestCode(t, backend, "cli-red", challenge, nil) req := tokenRequest(url.Values{ "grant_type": {"authorization_code"}, "code": {rawCode}, "code_verifier": {verifier}, "redirect_uri": {tokenTestRedirect}, "client_id": {"cli-red"}, }, "") rec := httptest.NewRecorder() srv.Token(rec, req) if rec.Code != http.StatusOK { t.Fatalf("PHASE8_RED:code-exchange: status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String()) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("PHASE8_RED:code-exchange: decode response: %v", err) } access, _ := got["access_token"].(string) refresh, _ := got["refresh_token"].(string) if access == "" || refresh == "" { t.Fatalf("PHASE8_RED:code-exchange: access_token/refresh_token empty in %v", got) } if got["token_type"] != "Bearer" { t.Fatalf("PHASE8_RED:code-exchange: token_type = %v, want Bearer", got["token_type"]) } if got["scope"] != "read write" { t.Fatalf("PHASE8_RED:code-exchange: scope = %v, want %q", got["scope"], "read write") } if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" { t.Fatalf("PHASE8_RED:code-exchange: Cache-Control = %q, want \"no-store, private\"", cc) } } // assertTokenError decodes rec as the exact PHP token error body ({"error": // code}, no error_description) and asserts status/code. func assertTokenError(t *testing.T, rec *httptest.ResponseRecorder, status int, code string) map[string]any { t.Helper() if rec.Code != status { t.Fatalf("status = %d, want %d (body=%s)", rec.Code, status, rec.Body.String()) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatalf("decode error body: %v (body=%s)", err, rec.Body.String()) } if got["error"] != code { t.Fatalf("error = %v, want %q", got["error"], code) } if _, has := got["error_description"]; has { t.Fatalf("body = %s carries error_description, PHP token errors never do", rec.Body.String()) } return got } // newTokenExchangeFixture seeds one usable public client and one valid // pending-issued code bound to it, returning everything a caller needs to // build a successful exchange request (and to mutate before breaking it). func newTokenExchangeFixture(t *testing.T) (srv *Server, backend *memoryBackend, verifier string, rawCode string, code *AuthCodeRecord) { t.Helper() backend = newMemoryBackend() srv = newTestServer(backend) insertTokenTestClient(backend, "cli-tok", "none", nil, nil) var challenge string verifier, challenge = s256Pair(t) rawCode, code = insertTokenTestCode(t, backend, "cli-tok", challenge, nil) return } func validExchangeForm(rawCode, verifier, clientID string) url.Values { return url.Values{ "grant_type": {"authorization_code"}, "code": {rawCode}, "code_verifier": {verifier}, "redirect_uri": {tokenTestRedirect}, "client_id": {clientID}, } } // TestTokenRejectsJSONBodyEvenWithValidQueryParams proves D-02/Pitfall 4: a // JSON content type is rejected before ParseForm ever runs, so a valid // grant cannot be smuggled through the query string of a JSON-labeled // request. func TestTokenRejectsJSONBodyEvenWithValidQueryParams(t *testing.T) { srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t) q := validExchangeForm(rawCode, verifier, "cli-tok") req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/token?"+q.Encode(), strings.NewReader(`{"grant_type":"authorization_code"}`)) req.Header.Set("Content-Type", "application/json") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusBadRequest, "invalid_request") } // TestTokenBodyOverQueryPrecedence proves D-02: when the same key appears in // both the form body and the query string, the body value wins (matching // net/http's own documented ParseForm precedence, verified against the // stdlib source for this plan). func TestTokenBodyOverQueryPrecedence(t *testing.T) { srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t) form := validExchangeForm(rawCode, verifier, "cli-tok") req := httptest.NewRequest(http.MethodPost, "/oauth/mcp/token?grant_type=refresh_token", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") rec := httptest.NewRecorder() srv.Token(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d (body=%s): body grant_type must win over query grant_type", rec.Code, http.StatusOK, rec.Body.String()) } } // TestTokenBasicCredentialsOverrideFormCredentials proves the confidential // client's Basic header wins over (wrong) form client_id/client_secret // values. func TestTokenBasicCredentialsOverrideFormCredentials(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) hash := sha256Hex("correct-secret") insertTokenTestClient(backend, "cli-basic", "client_secret_basic", &hash, nil) verifier, challenge := s256Pair(t) rawCode, _ := insertTokenTestCode(t, backend, "cli-basic", challenge, nil) form := url.Values{ "grant_type": {"authorization_code"}, "code": {rawCode}, "code_verifier": {verifier}, "redirect_uri": {tokenTestRedirect}, "client_id": {"cli-basic"}, "client_secret": {"wrong-form-secret"}, } req := tokenRequest(form, "") req.SetBasicAuth("cli-basic", "correct-secret") rec := httptest.NewRecorder() srv.Token(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d (body=%s): Basic header must override form client_secret", rec.Code, http.StatusOK, rec.Body.String()) } } func TestTokenMissingGrantTypeIsInvalidRequest(t *testing.T) { srv, _, _, _, _ := newTokenExchangeFixture(t) req := tokenRequest(url.Values{}, "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusBadRequest, "invalid_request") } func TestTokenUnsupportedGrantTypeIsRejected(t *testing.T) { srv, _, _, _, _ := newTokenExchangeFixture(t) req := tokenRequest(url.Values{"grant_type": {"client_credentials"}}, "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusBadRequest, "unsupported_grant_type") } func TestTokenUnknownClientIsInvalidClientWithBasicChallenge(t *testing.T) { srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t) form := validExchangeForm(rawCode, verifier, "does-not-exist") req := tokenRequest(form, "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusUnauthorized, "invalid_client") if wa := rec.Header().Get("WWW-Authenticate"); wa != `Basic realm="OAuth"` { t.Fatalf("WWW-Authenticate = %q, want %q", wa, `Basic realm="OAuth"`) } } func TestTokenRevokedClientIsInvalidClient(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) client := insertTokenTestClient(backend, "cli-revoked", "none", nil, nil) now := time.Now() client.RevokedAt = &now verifier, challenge := s256Pair(t) rawCode, _ := insertTokenTestCode(t, backend, "cli-revoked", challenge, nil) req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-revoked"), "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusUnauthorized, "invalid_client") } func TestTokenConfidentialClientMissingSecretIsInvalidClient(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) hash := sha256Hex("s3cret") insertTokenTestClient(backend, "cli-conf-missing", "client_secret_post", &hash, nil) verifier, challenge := s256Pair(t) rawCode, _ := insertTokenTestCode(t, backend, "cli-conf-missing", challenge, nil) req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-conf-missing"), "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusUnauthorized, "invalid_client") if wa := rec.Header().Get("WWW-Authenticate"); wa != `Basic realm="OAuth"` { t.Fatalf("WWW-Authenticate = %q, want %q", wa, `Basic realm="OAuth"`) } } // TestTokenConfidentialClientWrongSecretIsInvalidClient is the plan's named // "invalid confidential client" case: exact status/body/no-newline, // Cache-Control absent (only success responses carry it), and the Basic // realm="OAuth" challenge (T-08-SECRET-TIMING: comparison goes through // constantEqual/sha256Hex, never a direct string compare). func TestTokenConfidentialClientWrongSecretIsInvalidClient(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) hash := sha256Hex("correct-secret") insertTokenTestClient(backend, "cli-conf-wrong", "client_secret_post", &hash, nil) verifier, challenge := s256Pair(t) rawCode, _ := insertTokenTestCode(t, backend, "cli-conf-wrong", challenge, nil) form := validExchangeForm(rawCode, verifier, "cli-conf-wrong") form.Set("client_secret", "wrong-secret") req := tokenRequest(form, "") rec := httptest.NewRecorder() srv.Token(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("status = %d, want %d", rec.Code, http.StatusUnauthorized) } if body := rec.Body.String(); body != `{"error":"invalid_client"}` { t.Fatalf("body = %q, want exact %q", body, `{"error":"invalid_client"}`) } if strings.HasSuffix(rec.Body.String(), "\n") { t.Fatal("body has a trailing newline") } if wa := rec.Header().Get("WWW-Authenticate"); wa != `Basic realm="OAuth"` { t.Fatalf("WWW-Authenticate = %q, want %q", wa, `Basic realm="OAuth"`) } if cc := rec.Header().Get("Cache-Control"); cc != "no-cache, private" { t.Fatalf("Cache-Control = %q, want %q", cc, "no-cache, private") } } func TestTokenPublicClientIgnoresSuppliedSecret(t *testing.T) { srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t) form := validExchangeForm(rawCode, verifier, "cli-tok") form.Set("client_secret", "irrelevant-for-a-public-client") req := tokenRequest(form, "") rec := httptest.NewRecorder() srv.Token(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String()) } } func TestTokenWrongVerifierIsInvalidGrant(t *testing.T) { srv, backend, _, rawCode, _ := newTokenExchangeFixture(t) form := validExchangeForm(rawCode, "wrong-verifier-entirely", "cli-tok") req := tokenRequest(form, "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant") backend.mu.Lock() defer backend.mu.Unlock() if len(backend.tokens) != 0 { t.Fatal("a wrong-verifier exchange must not mint an access token") } } func TestTokenClientMismatchIsInvalidGrant(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) insertTokenTestClient(backend, "cli-owner", "none", nil, nil) insertTokenTestClient(backend, "cli-other", "none", nil, nil) verifier, challenge := s256Pair(t) rawCode, _ := insertTokenTestCode(t, backend, "cli-owner", challenge, nil) req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-other"), "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant") } func TestTokenRedirectURIMismatchIsInvalidGrant(t *testing.T) { srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t) form := validExchangeForm(rawCode, verifier, "cli-tok") form.Set("redirect_uri", "https://evil.example.test/cb") req := tokenRequest(form, "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant") } func TestTokenResourceMismatchIsInvalidGrant(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) insertTokenTestClient(backend, "cli-res", "none", nil, nil) verifier, challenge := s256Pair(t) res := "https://mcp.plytarium.com/mcp" rawCode, _ := insertTokenTestCode(t, backend, "cli-res", challenge, func(rec *AuthCodeRecord) { rec.Resource = &res }) form := validExchangeForm(rawCode, verifier, "cli-res") form.Set("resource", "https://wrong.example.test/mcp") req := tokenRequest(form, "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant") } func TestTokenResourceOmittedIsAccepted(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) insertTokenTestClient(backend, "cli-res-omit", "none", nil, nil) verifier, challenge := s256Pair(t) res := "https://mcp.plytarium.com/mcp" rawCode, _ := insertTokenTestCode(t, backend, "cli-res-omit", challenge, func(rec *AuthCodeRecord) { rec.Resource = &res }) req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-res-omit"), "") rec := httptest.NewRecorder() srv.Token(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String()) } } func TestTokenExpiredCodeIsInvalidGrant(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) insertTokenTestClient(backend, "cli-expired", "none", nil, nil) verifier, challenge := s256Pair(t) rawCode, _ := insertTokenTestCode(t, backend, "cli-expired", challenge, func(rec *AuthCodeRecord) { rec.ExpiresAt = time.Now().Add(-1 * time.Second) }) req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-expired"), "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant") } func TestTokenMissingRequiredFieldsAreInvalidGrant(t *testing.T) { cases := []struct { name string strip func(url.Values) }{ {"missing-code", func(v url.Values) { v.Del("code") }}, {"missing-redirect-uri", func(v url.Values) { v.Del("redirect_uri") }}, {"missing-code-verifier", func(v url.Values) { v.Del("code_verifier") }}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t) form := validExchangeForm(rawCode, verifier, "cli-tok") tc.strip(form) req := tokenRequest(form, "") rec := httptest.NewRecorder() srv.Token(rec, req) assertTokenError(t, rec, http.StatusBadRequest, "invalid_grant") }) } } // TestTokenCodeSequentialReplayIsInvalidGrantSecondTime is the sequential // half of T-08-CODE-REPLAY: exchanging the same code twice succeeds exactly // once. func TestTokenCodeSequentialReplayIsInvalidGrantSecondTime(t *testing.T) { srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t) form := validExchangeForm(rawCode, verifier, "cli-tok") first := httptest.NewRecorder() srv.Token(first, tokenRequest(form, "")) if first.Code != http.StatusOK { t.Fatalf("first exchange status = %d, want %d (body=%s)", first.Code, http.StatusOK, first.Body.String()) } second := httptest.NewRecorder() srv.Token(second, tokenRequest(form, "")) assertTokenError(t, second, http.StatusBadRequest, "invalid_grant") } // TestTokenCodeConcurrentReplayHasExactlyOneWinner is the concurrent half of // T-08-CODE-REPLAY (Pitfall 8): two synchronized goroutines racing to // exchange the same code must produce exactly one 200 and one invalid_grant, // never two successes. memoryBackend serializes the whole WithinTx closure // behind one mutex (08-PATTERNS.md), which is exactly the seam this test // exercises; the real-Postgres row-lock proof lives in fonoteka.go's // classes/auth package. func TestTokenCodeConcurrentReplayHasExactlyOneWinner(t *testing.T) { srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t) form := validExchangeForm(rawCode, verifier, "cli-tok") 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, tokenRequest(form, "")) 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) } } func TestTokenOfflineAccessAppendedToScope(t *testing.T) { backend := newMemoryBackend() srv := newTestServer(backend) insertTokenTestClient(backend, "cli-offline", "none", nil, nil) verifier, challenge := s256Pair(t) rawCode, _ := insertTokenTestCode(t, backend, "cli-offline", challenge, func(rec *AuthCodeRecord) { rec.OfflineAccess = true }) req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-offline"), "") rec := httptest.NewRecorder() srv.Token(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusOK, rec.Body.String()) } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } if got["scope"] != "read write offline_access" { t.Fatalf("scope = %v, want %q", got["scope"], "read write offline_access") } } func TestTokenSuccessResponseHasNoEnvelopeAndNoTrailingNewline(t *testing.T) { srv, _, verifier, rawCode, _ := newTokenExchangeFixture(t) req := tokenRequest(validExchangeForm(rawCode, verifier, "cli-tok"), "") rec := httptest.NewRecorder() srv.Token(rec, req) if strings.HasSuffix(rec.Body.String(), "\n") { t.Fatal("body has a trailing newline") } var got map[string]any if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil { t.Fatal(err) } if _, hasData := got["data"]; hasData { t.Fatal("body has a house \"data\" envelope") } if got["expires_in"] != float64(3600) { t.Fatalf("expires_in = %v, want 3600", got["expires_in"]) } if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" { t.Fatalf("Cache-Control = %q, want \"no-store, private\"", cc) } if p := rec.Header().Get("Pragma"); p != "no-cache" { t.Fatalf("Pragma = %q, want \"no-cache\"", p) } } // TestTokenRefreshGrantDispatchIsAcceptedButNotYetImplemented proves Token's // own grant-type validity check accepts "refresh_token" exactly like PHP // 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{ "grant_type": {"refresh_token"}, "refresh_token": {"whatever"}, "client_id": {"cli-tok"}, }, "") rec := httptest.NewRecorder() srv.Token(rec, req) 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" srv := NewServer(opts) req := tokenRequest(url.Values{"grant_type": {"authorization_code"}}, "") rec := httptest.NewRecorder() srv.Token(rec, req) if rec.Code != http.StatusInternalServerError { t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError) } }