Files
summercms/modules/wristband/authorize_test.go
Jakub Zych aba678b196 fix(wristband): drop OIDC identity scopes at MCP authorize
Clients that also read the issuer's OpenID Connect discovery document
add openid, profile or email to their MCP authorize request, and the
MCP flavour refused those requests with invalid_scope ("scope contains
an unsupported value"), so they could not connect.

The three identity scopes are now dropped before the allow-list check,
as RFC 6749 section 3.3 lets a server grant fewer scopes than requested.
A request left with no other scope gets the read default. Any other
unknown scope, including a differently cased identity name, is still
refused. The MCP flavour still never grants identity scopes or issues
an id_token, and the metadata document is unchanged.
2026-11-06 14:17:43 +01:00

835 lines
29 KiB
Go

package wristband
import (
"net/http"
"net/http/httptest"
"net/url"
"slices"
"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://resource.example.test/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://issuer.example.test/connect?") {
t.Fatalf("PHASE8_RED:authorize: Location = %q, want prefix \"https://issuer.example.test/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://issuer.example.test" {
t.Fatalf("iss = %q, want https://issuer.example.test", 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://issuer.example.test" {
t.Fatalf("iss = %q, want https://issuer.example.test", 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://resource.example.test/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://issuer.example.test/connect?") {
t.Fatalf("Location = %q, want prefix https://issuer.example.test/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://issuer.example.test" {
t.Fatalf("iss = %q, want https://issuer.example.test", 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)
}
}
// TestAuthorizeDropsOIDCIdentityScopes pins the MCP flavour's tolerance for
// the OpenID Connect identity scopes: openid, profile and email are dropped
// from the request before the allow-list check (RFC 6749 section 3.3 lets
// the server grant fewer scopes than requested), never persisted, and an
// identity-only request falls back to the read default. Any other unknown
// token, including a differently cased identity name, is still refused.
func TestAuthorizeDropsOIDCIdentityScopes(t *testing.T) {
cases := []struct {
name string
scope string
ceiling []string
extra map[string]string
want []string // nil: refused with invalid_scope
}{
{
// A client that also reads the issuer's OpenID Connect discovery
// document adds openid and email to its MCP request.
name: "discovery-reading client request",
scope: "openid email read",
extra: map[string]string{"resource": "https://resource.example.test/mcp", "ui_locales": "en-US"},
want: []string{"read"},
},
{name: "identity only defaults to read", scope: "openid profile", want: []string{"read"}},
{name: "openid alone defaults to read", scope: "openid", want: []string{"read"}},
{name: "order preserved", scope: "openid profile email read write", want: []string{"read", "write"}},
{name: "ceiling applies after the drop", scope: "openid email read ai", ceiling: []string{"read", "write"}, want: []string{"read"}},
{name: "unknown scope beside openid refused", scope: "openid bogus"},
{name: "unknown scope refused", scope: "read bogus"},
{name: "case differs refused", scope: "OPENID read"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
srv, backend := newAuthorizeTestServer()
insertAuthorizeTestClient(backend, "cli-identity", []string{authorizeTestRedirect}, tc.ceiling)
_, challenge := s256Pair(t)
before := pendingCount(backend)
params := map[string]string{
"client_id": "cli-identity",
"redirect_uri": authorizeTestRedirect,
"response_type": "code",
"code_challenge": challenge,
"code_challenge_method": "S256",
"scope": tc.scope,
"state": "xyz",
}
for k, v := range tc.extra {
params[k] = v
}
rec := httptest.NewRecorder()
srv.Authorize(rec, authorizeRequest(params))
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")
q := queryOf(t, loc)
if tc.want == nil {
if q["error"] != "invalid_scope" {
t.Fatalf("error = %q, want invalid_scope (Location=%q)", q["error"], loc)
}
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)
}
return
}
if !strings.HasPrefix(loc, srv.opts.Issuer+"/connect?request=") {
t.Fatalf("Location = %q, want prefix %q", loc, srv.opts.Issuer+"/connect?request=")
}
if _, has := q["error"]; has {
t.Fatalf("Location %q carries an error", loc)
}
backend.mu.Lock()
defer backend.mu.Unlock()
if got := len(backend.codes); got != before+1 {
t.Fatalf("pending rows = %d, want %d", got, before+1)
}
row := backend.codes[len(backend.codes)-1]
if !slices.Equal(row.Scopes, tc.want) {
t.Fatalf("scopes = %v, want %v", row.Scopes, tc.want)
}
if row.OfflineAccess {
t.Fatal("OfflineAccess = true, want false")
}
if row.State == nil || *row.State != "xyz" {
t.Fatalf("state = %v, want xyz", row.State)
}
if res, ok := tc.extra["resource"]; ok {
if res != srv.opts.Resource {
t.Fatalf("fixture resource %q differs from server resource %q", res, srv.opts.Resource)
}
if row.Resource == nil || *row.Resource != srv.opts.Resource {
t.Fatalf("resource = %v, want %q", row.Resource, srv.opts.Resource)
}
}
})
}
}
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://issuer.example.test" {
t.Fatalf("iss = %q, want https://issuer.example.test", 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://issuer.example.test"},
{"state", "a b&c=d"},
})
want := "error=invalid_scope&error_description=requested%20scope%20is%20outside%20this%20client%27s%20ceiling&iss=https%3A%2F%2Fissuer.example.test&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://issuer.example.test"},
})
want := "https://client.example.test/cb?already=here&error=invalid_request&iss=https%3A%2F%2Fissuer.example.test"
if got != want {
t.Fatalf("got: %s\nwant: %s", got, want)
}
}
func TestAuthorizeBackendUnavailableIsOpaque500(t *testing.T) {
opts := DefaultOptions()
opts.Issuer = "https://issuer.example.test"
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)
}
}