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
This commit is contained in:
25
wristband/authorize.go
Normal file
25
wristband/authorize.go
Normal file
@@ -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)
|
||||
}
|
||||
119
wristband/authorize_test.go
Normal file
119
wristband/authorize_test.go
Normal file
@@ -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 <issuer>/connect?request=<opaque> 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user