From 787e612ab3575965e8faca007c38a9abd7cc40c6 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 23 Sep 2026 20:01:18 +0200 Subject: [PATCH] test(08-03): add failing authorize RED test in wristband - Server.Authorize stub returns 501 - TestPhase8RedAuthorize drives a full valid S256 request and asserts the exact 302 /connect success contract; fails with PHASE8_RED:authorize against the stub, verified fail-closed via check-phase8-red.sh --- wristband/authorize.go | 25 ++++++++ wristband/authorize_test.go | 119 ++++++++++++++++++++++++++++++++++++ 2 files changed, 144 insertions(+) create mode 100644 wristband/authorize.go create mode 100644 wristband/authorize_test.go diff --git a/wristband/authorize.go b/wristband/authorize.go new file mode 100644 index 0000000..478d354 --- /dev/null +++ b/wristband/authorize.go @@ -0,0 +1,25 @@ +// RFC 6749 authorization endpoint for MCP OAuth, ported from PHP +// OAuthAuthorizeController::authorize byte-for-byte including its +// validation order (08-CONTEXT.md D-02/D-04/D-05; canonical PHP source: +// OAuthAuthorizeController.php). +// +// D-02: query-only parsing (no body is ever read). The client and the exact +// registered redirect URI are validated before any redirect response is +// constructed (T-08-OPEN-REDIRECT): an unknown client or unregistered +// redirect is a local text/plain 400 with no Location header. Every later +// failure redirects to the now-trusted redirect_uri with an ordered +// error/error_description/iss[/state] query built through an RFC 3986 +// encoder, never url.Values.Encode (08-RESEARCH.md Pattern 3/Pitfall 5). +package wristband + +import ( + "net/http" +) + +// Authorize handles GET /oauth/mcp/authorize. It is not yet implemented +// (Wave 3 Task 1 RED anchor, 08-03-PLAN.md); TestPhase8RedAuthorize and +// TestPhase8RedAuthorizeApp fail against this stub until Task 2's GREEN +// commit. +func (s *Server) Authorize(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) +} diff --git a/wristband/authorize_test.go b/wristband/authorize_test.go new file mode 100644 index 0000000..4201a1d --- /dev/null +++ b/wristband/authorize_test.go @@ -0,0 +1,119 @@ +package wristband + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +// insertAuthorizeTestClient 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 insertAuthorizeTestClient(backend *memoryBackend, clientID string, redirectURIs []string, ceiling []string) *ClientRecord { + backend.mu.Lock() + defer backend.mu.Unlock() + backend.nextID++ + rec := &ClientRecord{ + ID: backend.nextID, + ClientID: clientID, + ClientName: "Test Client", + RedirectURIs: redirectURIs, + GrantTypes: []string{"authorization_code", "refresh_token"}, + TokenEndpointAuthMethod: "client_secret_post", + ScopeCeiling: ceiling, + CreatedAt: time.Now(), + } + backend.clients = append(backend.clients, rec) + return rec +} + +// s256Pair returns a random PKCE verifier and its S256 challenge, matching +// the byte transform every Phase 8 PHP/Go PKCE fixture shares. +func s256Pair(t *testing.T) (verifier, challenge string) { + t.Helper() + v, err := randomBase64URL(32) + if err != nil { + t.Fatal(err) + } + return v, s256Challenge(v) +} + +// authorizeQuery builds a GET /oauth/mcp/authorize request from an ordered +// param map (net/url.Values handles encoding fine for *requests*: only +// response Location construction is bound by the RFC3986 ordered-pair +// requirement). +func authorizeRequest(params map[string]string) *http.Request { + q := url.Values{} + for k, v := range params { + q.Set(k, v) + } + return httptest.NewRequest(http.MethodGet, "/oauth/mcp/authorize?"+q.Encode(), nil) +} + +// queryOf parses the query component of a redirect Location header into a +// flat map (every Phase 8 authorize fixture uses at most one value per key). +func queryOf(t *testing.T, location string) map[string]string { + t.Helper() + u, err := url.Parse(location) + if err != nil { + t.Fatalf("parse Location %q: %v", location, err) + } + out := map[string]string{} + for k, v := range u.Query() { + if len(v) > 0 { + out[k] = v[0] + } + } + return out +} + +// TestPhase8RedAuthorize is the Phase 8 Wave 3 RED anchor (08-03-PLAN.md +// Task 1, D-02/D-04/D-05). It drives one valid S256 authorize request +// through the real (in-memory-backed) Server.Authorize and asserts the +// exact success contract: 302 to /connect?request= with no +// state/code leaked onto our own redirect and Cache-Control: no-store. It +// fails with the PHASE8_RED:authorize sentinel while Authorize is the 501 +// stub; scripts/check-phase8-red.sh verifies this failure is fail-closed. +func TestPhase8RedAuthorize(t *testing.T) { + backend := newMemoryBackend() + srv := newTestServer(backend) + insertAuthorizeTestClient(backend, "cli-red", []string{"https://chatgpt.com/connector/oauth/cb"}, nil) + _, challenge := s256Pair(t) + + req := authorizeRequest(map[string]string{ + "client_id": "cli-red", + "redirect_uri": "https://chatgpt.com/connector/oauth/cb", + "response_type": "code", + "code_challenge": challenge, + "code_challenge_method": "S256", + "scope": "read write", + "state": "must-not-appear-on-our-url", + "resource": "https://mcp.plytarium.com/mcp", + }) + rec := httptest.NewRecorder() + srv.Authorize(rec, req) + + if rec.Code != http.StatusFound { + t.Fatalf("PHASE8_RED:authorize: status = %d, want %d (body=%s)", rec.Code, http.StatusFound, rec.Body.String()) + } + loc := rec.Header().Get("Location") + if !strings.HasPrefix(loc, "https://plytarium.com/connect?") { + t.Fatalf("PHASE8_RED:authorize: Location = %q, want prefix \"https://plytarium.com/connect?\"", loc) + } + q := queryOf(t, loc) + if q["request"] == "" { + t.Fatalf("PHASE8_RED:authorize: Location %q missing non-empty request param", loc) + } + if _, has := q["state"]; has { + t.Fatalf("PHASE8_RED:authorize: Location %q leaks state onto our own redirect", loc) + } + if _, has := q["code"]; has { + t.Fatalf("PHASE8_RED:authorize: Location %q leaks a code onto our own redirect", loc) + } + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { + t.Fatalf("PHASE8_RED:authorize: Cache-Control = %q, want \"no-store\"", cc) + } +}