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
This commit is contained in:
Jakub Zych
2026-09-23 20:28:07 +02:00
parent 5ca830beef
commit 4bd3b3db4f
2 changed files with 740 additions and 4 deletions

View File

@@ -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)
}
}