Recording the full mcp-lifecycle fixture against real isolated PHP (08-09-PLAN.md Task 2) uncovered three byte-level gaps between wristband's assumed contract and actual production PHP behavior: - Every explicit "Cache-Control: no-store" PHP sets is actually delivered as "no-store, private" (Laravel's session-cookie default merges "private" onto any explicit value); wristband's own default for unheadered JSON error responses is "no-cache, private" (matching the house convention already used elsewhere), not empty. - PHP's redirect responses (authorize success and every error redirect) render Symfony's default HTML redirect body with Content-Type "text/html; charset=utf-8"; Go's bare 302 with no body never matched. wristband/redirect_html.go ports that exact byte template, including PHP's htmlspecialchars(ENT_QUOTES) escaping (Go's html.EscapeString uses different quote entities). tide/normalize.go: isIDKey now also masks "_ids" plural array fields (e.g. collection_ids), a latent parity-corpus gap no prior fixture had exercised with a literal, non-empty, non-placeholder array value.
731 lines
25 KiB
Go
731 lines
25 KiB
Go
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, private" {
|
|
t.Fatalf("PHASE8_RED:authorize: Cache-Control = %q, want \"no-store, private\"", cc)
|
|
}
|
|
}
|
|
|
|
const authorizeTestRedirect = "https://chatgpt.com/connector/oauth/cb"
|
|
|
|
func newAuthorizeTestServer() (*Server, *memoryBackend) {
|
|
backend := newMemoryBackend()
|
|
return newTestServer(backend), backend
|
|
}
|
|
|
|
func pendingCount(backend *memoryBackend) int {
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
return len(backend.codes)
|
|
}
|
|
|
|
func TestAuthorizeUnknownClientReturnsLocal400NoLocation(t *testing.T) {
|
|
srv, _ := newAuthorizeTestServer()
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "does-not-exist",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d (body=%s)", rec.Code, http.StatusBadRequest, rec.Body.String())
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "" {
|
|
t.Fatalf("Location = %q, want none", loc)
|
|
}
|
|
if ct := rec.Header().Get("Content-Type"); ct != "text/plain; charset=UTF-8" {
|
|
t.Fatalf("Content-Type = %q, want text/plain; charset=UTF-8", ct)
|
|
}
|
|
if body := rec.Body.String(); body != "Unknown client." {
|
|
t.Fatalf("body = %q, want %q", body, "Unknown client.")
|
|
}
|
|
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
|
t.Fatalf("Cache-Control = %q, want no-store, private", cc)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeRevokedClientIsUnknown(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
rec := insertAuthorizeTestClient(backend, "cli-revoked", []string{authorizeTestRedirect}, nil)
|
|
now := time.Now()
|
|
rec.RevokedAt = &now
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-revoked",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
})
|
|
w := httptest.NewRecorder()
|
|
srv.Authorize(w, req)
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeUnregisteredRedirectURIReturnsLocal400NoLocation(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-redirect", []string{authorizeTestRedirect}, nil)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-redirect",
|
|
"redirect_uri": "https://evil.test/cb",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "" {
|
|
t.Fatalf("Location = %q, want none", loc)
|
|
}
|
|
if strings.Contains(rec.Body.String(), "https://evil.test/cb") {
|
|
t.Fatal("body echoes the untrusted redirect URI")
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeTrailingSlashMismatchIsUnregistered(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-slash", []string{authorizeTestRedirect}, nil)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-slash",
|
|
"redirect_uri": authorizeTestRedirect + "/",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
|
}
|
|
if loc := rec.Header().Get("Location"); loc != "" {
|
|
t.Fatalf("Location = %q, want none", loc)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeUnsupportedResponseTypeRedirectsWithIssAndState(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-resptype", []string{authorizeTestRedirect}, nil)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-resptype",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "token",
|
|
"code_challenge": strings.Repeat("b", 43),
|
|
"code_challenge_method": "S256",
|
|
"state": "iss-state",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusFound)
|
|
}
|
|
loc := rec.Header().Get("Location")
|
|
if !strings.HasPrefix(loc, authorizeTestRedirect) {
|
|
t.Fatalf("Location = %q, want prefix %q", loc, authorizeTestRedirect)
|
|
}
|
|
q := queryOf(t, loc)
|
|
if q["error"] != "unsupported_response_type" {
|
|
t.Fatalf("error = %q, want unsupported_response_type", q["error"])
|
|
}
|
|
if q["iss"] != "https://plytarium.com" {
|
|
t.Fatalf("iss = %q, want https://plytarium.com", q["iss"])
|
|
}
|
|
if q["state"] != "iss-state" {
|
|
t.Fatalf("state = %q, want iss-state", q["state"])
|
|
}
|
|
}
|
|
|
|
func TestPKCEChallengeMethodMustBeS256(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-plain", []string{authorizeTestRedirect}, nil)
|
|
state := "state-plain"
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-plain",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": strings.Repeat("a", 43),
|
|
"code_challenge_method": "plain",
|
|
"state": state,
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusFound)
|
|
}
|
|
q := queryOf(t, rec.Header().Get("Location"))
|
|
if q["error"] != "invalid_request" {
|
|
t.Fatalf("error = %q, want invalid_request", q["error"])
|
|
}
|
|
if q["state"] != state {
|
|
t.Fatalf("state = %q, want %q", q["state"], state)
|
|
}
|
|
if q["iss"] != "https://plytarium.com" {
|
|
t.Fatalf("iss = %q, want https://plytarium.com", q["iss"])
|
|
}
|
|
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
|
t.Fatalf("Cache-Control = %q, want no-store, private", cc)
|
|
}
|
|
}
|
|
|
|
func TestPKCEChallengeLengthBounds(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
challenge string
|
|
}{
|
|
{"too-short", strings.Repeat("a", 42)},
|
|
{"too-long", strings.Repeat("a", 129)},
|
|
{"empty", ""},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-len-"+tc.name, []string{authorizeTestRedirect}, nil)
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-len-" + tc.name,
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": tc.challenge,
|
|
"code_challenge_method": "S256",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusFound)
|
|
}
|
|
q := queryOf(t, rec.Header().Get("Location"))
|
|
if q["error"] != "invalid_request" {
|
|
t.Fatalf("error = %q, want invalid_request", q["error"])
|
|
}
|
|
if q["error_description"] != "code_challenge is required" {
|
|
t.Fatalf("error_description = %q, want %q", q["error_description"], "code_challenge is required")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeValidRequestRedirectsToConnectWithOpaqueHandleOnly(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-valid", []string{authorizeTestRedirect}, nil)
|
|
_, challenge := s256Pair(t)
|
|
state := "must-not-appear-on-our-url"
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-valid",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"scope": "read write",
|
|
"state": state,
|
|
"resource": "https://mcp.plytarium.com/mcp",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("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("Location = %q, want prefix https://plytarium.com/connect?", loc)
|
|
}
|
|
q := queryOf(t, loc)
|
|
if q["request"] == "" {
|
|
t.Fatal("Location missing non-empty request param")
|
|
}
|
|
if _, has := q["code"]; has {
|
|
t.Fatal("Location leaks a code")
|
|
}
|
|
if _, has := q["state"]; has {
|
|
t.Fatal("Location leaks state")
|
|
}
|
|
if _, has := q["client_secret"]; has {
|
|
t.Fatal("Location leaks client_secret")
|
|
}
|
|
if cc := rec.Header().Get("Cache-Control"); cc != "no-store, private" {
|
|
t.Fatalf("Cache-Control = %q, want no-store, private", cc)
|
|
}
|
|
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
if len(backend.codes) != 1 {
|
|
t.Fatalf("pending rows = %d, want 1", len(backend.codes))
|
|
}
|
|
pending := backend.codes[0]
|
|
if pending.RequestID == nil || *pending.RequestID != q["request"] {
|
|
t.Fatalf("pending.RequestID = %v, want %q", pending.RequestID, q["request"])
|
|
}
|
|
if pending.CodeHash != nil {
|
|
t.Fatal("pending row already has a code hash")
|
|
}
|
|
if pending.UserID != nil {
|
|
t.Fatal("pending row already has a user id")
|
|
}
|
|
if pending.State == nil || *pending.State != state {
|
|
t.Fatalf("pending.State = %v, want %q", pending.State, state)
|
|
}
|
|
if pending.ExpiresAt.Before(time.Now().Add(500*time.Second)) || pending.ExpiresAt.After(time.Now().Add(700*time.Second)) {
|
|
t.Fatalf("pending.ExpiresAt = %v, want ~600s from now", pending.ExpiresAt)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeIssIsPresentOnEveryErrorRedirect(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-iss", []string{authorizeTestRedirect}, nil)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-iss",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "token",
|
|
"code_challenge": strings.Repeat("b", 43),
|
|
"code_challenge_method": "S256",
|
|
"state": "iss-state",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
q := queryOf(t, rec.Header().Get("Location"))
|
|
if q["iss"] != "https://plytarium.com" {
|
|
t.Fatalf("iss = %q, want https://plytarium.com", q["iss"])
|
|
}
|
|
if q["state"] != "iss-state" {
|
|
t.Fatalf("state = %q, want iss-state", q["state"])
|
|
}
|
|
if q["error"] != "unsupported_response_type" {
|
|
t.Fatalf("error = %q, want unsupported_response_type", q["error"])
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeScopeDefaultsToRead(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-default-scope", []string{authorizeTestRedirect}, nil)
|
|
_, challenge := s256Pair(t)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-default-scope",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusFound)
|
|
}
|
|
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
if len(backend.codes) != 1 {
|
|
t.Fatalf("pending rows = %d, want 1", len(backend.codes))
|
|
}
|
|
if got := backend.codes[0].Scopes; len(got) != 1 || got[0] != "read" {
|
|
t.Fatalf("scopes = %v, want [read]", got)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeScopeInvalidValueRedirectsInvalidScope(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-bad-scope", []string{authorizeTestRedirect}, nil)
|
|
_, challenge := s256Pair(t)
|
|
before := pendingCount(backend)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-bad-scope",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"scope": "read bogus",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusFound)
|
|
}
|
|
q := queryOf(t, rec.Header().Get("Location"))
|
|
if q["error"] != "invalid_scope" {
|
|
t.Fatalf("error = %q, want invalid_scope", q["error"])
|
|
}
|
|
if q["error_description"] != "scope contains an unsupported value" {
|
|
t.Fatalf("error_description = %q", q["error_description"])
|
|
}
|
|
if got := pendingCount(backend); got != before {
|
|
t.Fatalf("pending rows = %d, want unchanged %d", got, before)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeNullCeilingPreservesAiScope(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-null-ceiling", []string{authorizeTestRedirect}, nil)
|
|
_, challenge := s256Pair(t)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-null-ceiling",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"scope": "read write ai",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
got := backend.codes[len(backend.codes)-1].Scopes
|
|
want := []string{"read", "write", "ai"}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("scopes = %v, want %v", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("scopes = %v, want %v", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeCeilingTruncatesAiWithoutError(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-ceiling-trunc", []string{authorizeTestRedirect}, []string{"read", "write"})
|
|
_, challenge := s256Pair(t)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-ceiling-trunc",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"scope": "read write ai",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
got := backend.codes[len(backend.codes)-1].Scopes
|
|
if stringSliceContains(got, "ai") {
|
|
t.Fatalf("scopes = %v, still contains ai", got)
|
|
}
|
|
want := []string{"read", "write"}
|
|
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
|
|
t.Fatalf("scopes = %v, want %v", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeCeilingPreservesOfflineAccessFlag(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-ceiling-offline", []string{authorizeTestRedirect}, []string{"read", "write"})
|
|
_, challenge := s256Pair(t)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-ceiling-offline",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"scope": "read write ai offline_access",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
backend.mu.Lock()
|
|
defer backend.mu.Unlock()
|
|
pending := backend.codes[len(backend.codes)-1]
|
|
want := []string{"read", "write"}
|
|
if len(pending.Scopes) != len(want) || pending.Scopes[0] != want[0] || pending.Scopes[1] != want[1] {
|
|
t.Fatalf("scopes = %v, want %v", pending.Scopes, want)
|
|
}
|
|
if !pending.OfflineAccess {
|
|
t.Fatal("OfflineAccess = false, want true")
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeCeilingRejectsAiOnlyAsInvalidScopeOnTrustedRedirect(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-ceiling-ai-only", []string{authorizeTestRedirect}, []string{"read", "write"})
|
|
_, challenge := s256Pair(t)
|
|
state := "state-ai-only"
|
|
before := pendingCount(backend)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-ceiling-ai-only",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"scope": "ai",
|
|
"state": state,
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
loc := rec.Header().Get("Location")
|
|
if !strings.HasPrefix(loc, authorizeTestRedirect) {
|
|
t.Fatalf("Location = %q, want prefix %q", loc, authorizeTestRedirect)
|
|
}
|
|
q := queryOf(t, loc)
|
|
if q["error"] != "invalid_scope" {
|
|
t.Fatalf("error = %q, want invalid_scope", q["error"])
|
|
}
|
|
if q["state"] != state {
|
|
t.Fatalf("state = %q, want %q", q["state"], state)
|
|
}
|
|
if q["iss"] != "https://plytarium.com" {
|
|
t.Fatalf("iss = %q, want https://plytarium.com", q["iss"])
|
|
}
|
|
if got := pendingCount(backend); got != before {
|
|
t.Fatalf("pending rows = %d, want unchanged %d", got, before)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeCeilingRejectsAiPlusOfflineAccessAsInvalidScope(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-ceiling-ai-offline", []string{authorizeTestRedirect}, []string{"read", "write"})
|
|
_, challenge := s256Pair(t)
|
|
state := "state-ai-offline"
|
|
before := pendingCount(backend)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-ceiling-ai-offline",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"scope": "ai offline_access",
|
|
"state": state,
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
q := queryOf(t, rec.Header().Get("Location"))
|
|
if q["error"] != "invalid_scope" {
|
|
t.Fatalf("error = %q, want invalid_scope", q["error"])
|
|
}
|
|
if q["state"] != state {
|
|
t.Fatalf("state = %q, want %q", q["state"], state)
|
|
}
|
|
if got := pendingCount(backend); got != before {
|
|
t.Fatalf("pending rows = %d, want unchanged %d", got, before)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeResourceMismatchRedirectsInvalidTarget(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-resource", []string{authorizeTestRedirect}, nil)
|
|
_, challenge := s256Pair(t)
|
|
before := pendingCount(backend)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-resource",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
"resource": "https://wrong.example.test/mcp",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
q := queryOf(t, rec.Header().Get("Location"))
|
|
if q["error"] != "invalid_target" {
|
|
t.Fatalf("error = %q, want invalid_target", q["error"])
|
|
}
|
|
if q["error_description"] != "resource does not match this server" {
|
|
t.Fatalf("error_description = %q", q["error_description"])
|
|
}
|
|
if got := pendingCount(backend); got != before {
|
|
t.Fatalf("pending rows = %d, want unchanged %d", got, before)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeResourceOmittedIsAccepted(t *testing.T) {
|
|
srv, backend := newAuthorizeTestServer()
|
|
insertAuthorizeTestClient(backend, "cli-resource-omitted", []string{authorizeTestRedirect}, nil)
|
|
_, challenge := s256Pair(t)
|
|
|
|
req := authorizeRequest(map[string]string{
|
|
"client_id": "cli-resource-omitted",
|
|
"redirect_uri": authorizeTestRedirect,
|
|
"response_type": "code",
|
|
"code_challenge": challenge,
|
|
"code_challenge_method": "S256",
|
|
})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusFound {
|
|
t.Fatalf("status = %d, body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
}
|
|
|
|
// TestOrderedRedirectQueryEncodingIsRFC3986 proves the redirect query
|
|
// encoder differs from net/url.Values.Encode exactly where PHP
|
|
// http_build_query(..., PHP_QUERY_RFC3986) does: spaces become %20 (not
|
|
// '+'), reserved characters are percent-encoded, and key order is
|
|
// preserved (not sorted) — Pitfall 5.
|
|
func TestOrderedRedirectQueryEncodingIsRFC3986(t *testing.T) {
|
|
got := buildOrderedQuery([][2]string{
|
|
{"error", "invalid_scope"},
|
|
{"error_description", "requested scope is outside this client's ceiling"},
|
|
{"iss", "https://plytarium.com"},
|
|
{"state", "a b&c=d"},
|
|
})
|
|
want := "error=invalid_scope&error_description=requested%20scope%20is%20outside%20this%20client%27s%20ceiling&iss=https%3A%2F%2Fplytarium.com&state=a%20b%26c%3Dd"
|
|
if got != want {
|
|
t.Fatalf("got: %s\nwant: %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestOrderedRedirectAppendsToExistingQuery(t *testing.T) {
|
|
got := appendOrderedQuery("https://client.example.test/cb?already=here", [][2]string{
|
|
{"error", "invalid_request"},
|
|
{"iss", "https://plytarium.com"},
|
|
})
|
|
want := "https://client.example.test/cb?already=here&error=invalid_request&iss=https%3A%2F%2Fplytarium.com"
|
|
if got != want {
|
|
t.Fatalf("got: %s\nwant: %s", got, want)
|
|
}
|
|
}
|
|
|
|
func TestAuthorizeBackendUnavailableIsOpaque500(t *testing.T) {
|
|
opts := DefaultOptions()
|
|
opts.Issuer = "https://plytarium.com"
|
|
srv := NewServer(opts)
|
|
req := authorizeRequest(map[string]string{"client_id": "anything"})
|
|
rec := httptest.NewRecorder()
|
|
srv.Authorize(rec, req)
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
}
|