From 4bd3b3db4ff8d7d804c31dbe9daab3f0c293e9df Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 23 Sep 2026 20:28:07 +0200 Subject: [PATCH] feat(08-04): implement atomic PKCE-bound code exchange in wristband - Server.Token: JSON rejection before ParseForm, body-over-query precedence, Basic-over-form client auth, exact invalid_request/unsupported_grant_type/ invalid_client/invalid_grant bodies, Cache-Control/Pragma on success only - authenticateClient: public/confidential dispatch, constant-time secret compare (T-08-SECRET-TIMING) - exchangeAuthorizationCode: single WithinTx lock/consume/mint/refresh-create covering code/client/redirect/resource/PKCE binding and single-use replay (T-08-CODE-REPLAY), sequential and concurrent proofs - rotateRefreshToken: grant_type=refresh_token dispatches per PHP validity but is a deliberate invalid_grant placeholder; full rotation is 08-06 - full token_test.go behavior matrix appended alongside the RED anchor --- wristband/token.go | 276 +++++++++++++++++++++++- wristband/token_test.go | 468 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 740 insertions(+), 4 deletions(-) diff --git a/wristband/token.go b/wristband/token.go index 2fbcff6..e11aa9c 100644 --- a/wristband/token.go +++ b/wristband/token.go @@ -11,11 +11,279 @@ // plan's threat register. package wristband -import "net/http" +import ( + "context" + "encoding/base64" + "errors" + "net/http" + "strings" +) + +// errInvalidClient is Token's internal signal that client authentication +// failed (T-08-SECRET-TIMING). It never reaches a response body directly: +// Token translates it into the exact 401 invalid_client body plus +// WWW-Authenticate: Basic realm="OAuth" (D-04/D-06). +var errInvalidClient = errors.New("wristband: invalid client") + +// errInvalidGrant is Token's internal signal for every exact PHP +// OAuthInvalidGrantException case (missing/wrong code, expired code, PKCE +// mismatch, client/redirect/resource binding failure, replay). Token +// translates it into the exact 400 invalid_grant body with no description. +var errInvalidGrant = errors.New("wristband: invalid grant") + +// tokenIssueResult is what a successful grant produces: the two raw secrets +// plus the response's scope/offline_access ingredients. +type tokenIssueResult struct { + AccessToken string + RefreshToken string + Scopes []string + OfflineAccess bool +} + +type tokenSuccessBody struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +type tokenErrorBody struct { + // Error is the sole field: PHP's rfcError() writes {"error": code} with + // no error_description, unlike Register's richer error body. + Error string `json:"error"` +} // Token handles POST /oauth/mcp/token (D-09: raw route, no middleware). -// This is the Phase 8 Wave 4 RED stub (08-04-PLAN.md Task 1): it always -// responds 501 until Task 2 implements the real handler. +// +// D-02: a JSON request body is rejected before any form parsing, so query +// parameters on a JSON call can never smuggle a grant through (Pitfall 4). +// Otherwise every parameter comes from the merged r.Form (net/http's own +// ParseForm precedence puts body values ahead of query values — verified +// against the Go 1.27 stdlib source, not assumed). Basic credentials, when +// present, always override client_id/client_secret form values. func (s *Server) Token(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusNotImplemented) + if isJSONContentType(r.Header.Get("Content-Type")) { + writeTokenError(w, http.StatusBadRequest, "invalid_request") + return + } + if s.backend == nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + // Errors from ParseForm on a malformed urlencoded body are not + // distinguished from "no fields at all": the grant_type-empty check + // below already produces the exact invalid_request PHP would give for + // an unparseable/empty request. + _ = r.ParseForm() + + grantType := r.Form.Get("grant_type") + if grantType == "" { + writeTokenError(w, http.StatusBadRequest, "invalid_request") + return + } + if grantType != "authorization_code" && grantType != "refresh_token" { + writeTokenError(w, http.StatusBadRequest, "unsupported_grant_type") + return + } + + ctx := r.Context() + client, err := s.authenticateClient(ctx, r) + if err != nil { + if errors.Is(err, errInvalidClient) { + w.Header().Set("WWW-Authenticate", `Basic realm="OAuth"`) + writeTokenError(w, http.StatusUnauthorized, "invalid_client") + return + } + w.WriteHeader(http.StatusInternalServerError) + return + } + + var issued tokenIssueResult + if grantType == "authorization_code" { + issued, err = s.exchangeAuthorizationCode(ctx, r, client) + } else { + issued, err = s.rotateRefreshToken(ctx, r, client) + } + if err != nil { + if errors.Is(err, errInvalidGrant) { + writeTokenError(w, http.StatusBadRequest, "invalid_grant") + return + } + w.WriteHeader(http.StatusInternalServerError) + return + } + + scope := strings.Join(issued.Scopes, " ") + if issued.OfflineAccess && !stringSliceContains(issued.Scopes, "offline_access") { + scope = strings.TrimSpace(scope + " offline_access") + } + body := tokenSuccessBody{ + AccessToken: issued.AccessToken, + TokenType: "Bearer", + ExpiresIn: int64(s.opts.AccessTokenTTL.Seconds()), + RefreshToken: issued.RefreshToken, + Scope: scope, + } + writeExactJSON(w, http.StatusOK, body, map[string]string{ + "Cache-Control": "no-store", + "Pragma": "no-cache", + }) +} + +// authenticateClient ports OAuthTokenController::authenticateClient. Basic +// credentials override form credentials; a public client (auth method +// "none") needs no secret at all, but a confidential client with a missing +// or wrong secret is errInvalidClient (T-08-SECRET-TIMING: the secret +// comparison is a fixed-length sha256-hex constant-time compare, never a +// direct string compare of variable-length secrets). +func (s *Server) authenticateClient(ctx context.Context, r *http.Request) (*ClientRecord, error) { + clientID := r.Form.Get("client_id") + secret := r.Form.Get("client_secret") + + if authz := r.Header.Get("Authorization"); strings.HasPrefix(authz, "Basic ") { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(authz, "Basic ")) + if err != nil { + return nil, errInvalidClient + } + idx := strings.IndexByte(string(decoded), ':') + if idx < 0 { + return nil, errInvalidClient + } + clientID = string(decoded[:idx]) + secret = string(decoded[idx+1:]) + } + if clientID == "" { + return nil, errInvalidClient + } + + var client *ClientRecord + err := s.backend.WithinTx(ctx, func(tx Tx) error { + c, err := tx.ByClientID(ctx, clientID) + if err != nil { + return err + } + client = c + return nil + }) + if err != nil { + return nil, err + } + if client == nil || client.RevokedAt != nil { + return nil, errInvalidClient + } + if client.TokenEndpointAuthMethod == "none" { + return client, nil + } + if secret == "" || client.ClientSecretHash == nil { + return nil, errInvalidClient + } + if !constantEqual(*client.ClientSecretHash, sha256Hex(secret)) { + return nil, errInvalidClient + } + return client, nil +} + +// verifyPkce ports OAuthCodeManager::verifyPkce: method must be S256, and +// the comparison between the stored challenge and the verifier's S256 +// transform is constant-time (T-08-PKCE/D-04). +func verifyPkce(verifier, challenge, method string) bool { + if method != "S256" { + return false + } + return constantEqual(challenge, s256Challenge(verifier)) +} + +// exchangeAuthorizationCode ports OAuthCodeManager::exchangeCode inside one +// WithinTx callback: lock the code row, validate every binding, consume it, +// mint the inv_ access token, and create its refresh-token successor +// atomically (D-07/T-08-CODE-REPLAY). A validation failure returns +// errInvalidGrant before any mutation, so the surrounding transaction has +// nothing to roll back; a second exchange attempt against an already-used +// row always loses (single-use row lock via ByCodeHashForUpdate). +func (s *Server) exchangeAuthorizationCode(ctx context.Context, r *http.Request, client *ClientRecord) (tokenIssueResult, error) { + code := r.Form.Get("code") + redirectURI := r.Form.Get("redirect_uri") + verifier := r.Form.Get("code_verifier") + resource := r.Form.Get("resource") + + if code == "" || redirectURI == "" || verifier == "" { + return tokenIssueResult{}, errInvalidGrant + } + + var result tokenIssueResult + err := s.backend.WithinTx(ctx, func(tx Tx) error { + rec, err := tx.ByCodeHashForUpdate(ctx, sha256Hex(code)) + if err != nil { + return err + } + if rec == nil || + rec.UsedAt != nil || + !rec.ExpiresAt.After(s.now()) || + rec.UserID == nil || + rec.ClientID != client.ClientID || + rec.RedirectURI != redirectURI || + (resource != "" && rec.Resource != nil && resource != *rec.Resource) || + !verifyPkce(verifier, rec.CodeChallenge, rec.CodeChallengeMethod) { + return errInvalidGrant + } + + if err := tx.MarkUsed(ctx, rec.ID); err != nil { + return err + } + + name := truncateRunes(client.ClientName, 120) + expiresAt := s.now().Add(s.opts.AccessTokenTTL) + minted, err := tx.Mint(ctx, *rec.UserID, name, rec.Scopes, expiresAt, rec.CollectionIDs, client.ClientID) + if err != nil { + return err + } + + rawRefresh, err := s.randomBytes(32) + if err != nil { + return err + } + accessTokenID := minted.ID + refreshRec := &RefreshTokenRecord{ + TokenHash: sha256Hex(rawRefresh), + APITokenID: &accessTokenID, + ClientID: client.ClientID, + UserID: *rec.UserID, + Scopes: rec.Scopes, + CollectionIDs: rec.CollectionIDs, + ExpiresAt: s.now().Add(s.opts.RefreshTokenTTL), + OfflineAccess: rec.OfflineAccess, + } + if err := tx.Create(ctx, refreshRec); err != nil { + return err + } + + result = tokenIssueResult{ + AccessToken: minted.Secret, + RefreshToken: rawRefresh, + Scopes: rec.Scopes, + OfflineAccess: rec.OfflineAccess, + } + return nil + }) + if err != nil { + return tokenIssueResult{}, err + } + 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. +func (s *Server) rotateRefreshToken(ctx context.Context, r *http.Request, client *ClientRecord) (tokenIssueResult, error) { + _ = ctx + _ = client + return tokenIssueResult{}, errInvalidGrant +} + +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 604a768..51df908 100644 --- a/wristband/token_test.go +++ b/wristband/token_test.go @@ -125,3 +125,471 @@ func TestPhase8RedCodeExchange(t *testing.T) { t.Fatalf("PHASE8_RED:code-exchange: Cache-Control = %q, want \"no-store\"", 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 != "" { + t.Fatalf("Cache-Control = %q, want none on an error response", cc) + } +} + +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" { + t.Fatalf("Cache-Control = %q, want \"no-store\"", 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), while full rotation is 08-06's +// job in this plan's scope (see rotateRefreshToken). +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") +} + +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) + } +}