mcp connection
This commit is contained in:
501
go-api/internal/oauth/consent_test.go
Normal file
501
go-api/internal/oauth/consent_test.go
Normal file
@@ -0,0 +1,501 @@
|
||||
package oauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
/* ── Consent is required ────────────────────────────────────────────────── */
|
||||
|
||||
// A GET must ASK, not grant. This is the Phase 4 behaviour change, asserted
|
||||
// directly: before, a signed-in user's authorization was approved on sight.
|
||||
func TestAuthorizeRendersConsentRatherThanIssuingACode(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clientID := h.register()
|
||||
|
||||
rec := h.authorize(authorizeParamsFor(clientID, verifier43))
|
||||
|
||||
if rec.Code == http.StatusFound {
|
||||
t.Fatalf("a GET issued a code without asking: %s", rec.Header().Get("Location"))
|
||||
}
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want 200 with a consent page", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
|
||||
t.Errorf("Content-Type = %q, want text/html", ct)
|
||||
}
|
||||
|
||||
// No grant row may exist yet: rendering a question must not spend anything.
|
||||
var codes int
|
||||
if err := h.h.Pool.QueryRow(context.Background(),
|
||||
`SELECT count(*) FROM oauth_grants`).Scan(&codes); err != nil {
|
||||
t.Fatalf("count grants: %v", err)
|
||||
}
|
||||
if codes != 0 {
|
||||
t.Errorf("%d authorization codes exist after merely rendering consent", codes)
|
||||
}
|
||||
}
|
||||
|
||||
// The page must tell a person what they are agreeing to, in their terms.
|
||||
func TestConsentPageShowsWhatIsBeingGranted(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clientID := h.register()
|
||||
rec, _ := h.consent(authorizeParamsFor(clientID, verifier43))
|
||||
body := rec.Body.String()
|
||||
|
||||
for name, want := range map[string]string{
|
||||
"client name": "Test Client",
|
||||
"signed-in user": "oauth-user@example.test",
|
||||
"organisation": "OAuth Test",
|
||||
"resource": testResource,
|
||||
"approve control": "approve",
|
||||
"deny control": "deny",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("the consent page does not show the %s (%q)", name, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A person asked to approve "krow.read" has not been asked anything.
|
||||
if strings.Contains(body, ScopeRead) && !strings.Contains(body, "Read workforce activity") {
|
||||
t.Error("the page shows a raw scope identifier without explaining it")
|
||||
}
|
||||
// krow.write must never appear on a screen for a flow that cannot grant it.
|
||||
if strings.Contains(body, ScopeWrite) {
|
||||
t.Error("the consent page mentions krow.write")
|
||||
}
|
||||
}
|
||||
|
||||
// The client name is attacker-controlled: anyone may register a client called
|
||||
// <script>. It must be escaped, not rendered.
|
||||
func TestConsentPageEscapesTheClientName(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
const payload = `<script>alert('xss')</script>`
|
||||
body, _ := jsonMarshal(registrationRequest{
|
||||
ClientName: payload, RedirectURIs: []string{testRedirect},
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h.server.RegisterHandler().ServeHTTP(rec,
|
||||
httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(body)))
|
||||
var reg registrationResponse
|
||||
_ = jsonUnmarshal(rec.Body.Bytes(), ®)
|
||||
|
||||
page, _ := h.consent(authorizeParamsFor(reg.ClientID, verifier43))
|
||||
if strings.Contains(page.Body.String(), "<script>alert") {
|
||||
t.Fatal("a registered client name was rendered as live HTML")
|
||||
}
|
||||
if !strings.Contains(page.Body.String(), "<script>") {
|
||||
t.Error("the client name does not appear escaped; check it is shown at all")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Approve and deny ───────────────────────────────────────────────────── */
|
||||
|
||||
func TestConsentApproveIssuesACode(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clientID := h.register()
|
||||
params := authorizeParamsFor(clientID, verifier43)
|
||||
_, csrf := h.consent(params)
|
||||
|
||||
rec := h.decide(params, "approve", csrf)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", rec.Code)
|
||||
}
|
||||
loc, _ := url.Parse(rec.Header().Get("Location"))
|
||||
if loc.Query().Get("code") == "" {
|
||||
t.Error("approve issued no code")
|
||||
}
|
||||
if loc.Query().Get("state") != "xyz" {
|
||||
t.Errorf("state = %q, want xyz", loc.Query().Get("state"))
|
||||
}
|
||||
}
|
||||
|
||||
// Denial must reach the client as access_denied, at its registered redirect,
|
||||
// with state intact and NO code.
|
||||
func TestConsentDenyReturnsAccessDeniedAndNoCode(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clientID := h.register()
|
||||
params := authorizeParamsFor(clientID, verifier43)
|
||||
_, csrf := h.consent(params)
|
||||
|
||||
rec := h.decide(params, "deny", csrf)
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want 302", rec.Code)
|
||||
}
|
||||
|
||||
loc, err := url.Parse(rec.Header().Get("Location"))
|
||||
if err != nil {
|
||||
t.Fatalf("Location: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(loc.String(), testRedirect) {
|
||||
t.Fatalf("denial went to %q, not the registered redirect", loc)
|
||||
}
|
||||
if got := loc.Query().Get("error"); got != "access_denied" {
|
||||
t.Errorf("error = %q, want access_denied", got)
|
||||
}
|
||||
if got := loc.Query().Get("state"); got != "xyz" {
|
||||
t.Errorf("state = %q, want xyz — the client needs it to match the response", got)
|
||||
}
|
||||
if loc.Query().Get("code") != "" {
|
||||
t.Error("a denial returned an authorization code")
|
||||
}
|
||||
|
||||
// And nothing was written.
|
||||
var codes int
|
||||
_ = h.h.Pool.QueryRow(context.Background(), `SELECT count(*) FROM oauth_grants`).Scan(&codes)
|
||||
if codes != 0 {
|
||||
t.Errorf("%d authorization codes exist after a denial", codes)
|
||||
}
|
||||
}
|
||||
|
||||
// A POST with no decision must re-ask, never infer approval.
|
||||
func TestConsentWithNoDecisionDoesNotApprove(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clientID := h.register()
|
||||
params := authorizeParamsFor(clientID, verifier43)
|
||||
_, csrf := h.consent(params)
|
||||
|
||||
rec := h.decide(params, "", csrf)
|
||||
if rec.Code == http.StatusFound {
|
||||
t.Fatalf("a decision-less POST was treated as a decision: %s", rec.Header().Get("Location"))
|
||||
}
|
||||
var codes int
|
||||
_ = h.h.Pool.QueryRow(context.Background(), `SELECT count(*) FROM oauth_grants`).Scan(&codes)
|
||||
if codes != 0 {
|
||||
t.Error("a decision-less POST issued a code")
|
||||
}
|
||||
}
|
||||
|
||||
/* ── CSRF ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
// Without the form's token, a cross-site POST must not be able to approve.
|
||||
// This is what stops a page on the internet connecting a client to somebody's
|
||||
// workspace while they are signed in.
|
||||
func TestConsentRequiresTheFormCSRFToken(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clientID := h.register()
|
||||
params := authorizeParamsFor(clientID, verifier43)
|
||||
_, valid := h.consent(params)
|
||||
|
||||
for name, token := range map[string]string{
|
||||
"absent": "",
|
||||
"garbage": "not-the-token",
|
||||
"flipped": strings.Repeat("0", len(valid)),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
rec := h.decide(params, "approve", token)
|
||||
if rec.Code == http.StatusFound {
|
||||
t.Fatalf("approval succeeded without a valid CSRF token: %s",
|
||||
rec.Header().Get("Location"))
|
||||
}
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("status = %d, want 403", rec.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The real token still works, or the test above would pass vacuously.
|
||||
if rec := h.decide(params, "approve", valid); rec.Code != http.StatusFound {
|
||||
t.Errorf("the valid CSRF token was rejected: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Validation still applies on the POST ───────────────────────────────── */
|
||||
|
||||
// The POST must be validated as strictly as the GET. Trusting the form's
|
||||
// hidden fields would let a tampered POST change the redirect, the resource or
|
||||
// the PKCE challenge after the person read the page.
|
||||
func TestConsentPostRevalidatesEveryParameter(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clientID := h.register()
|
||||
params := authorizeParamsFor(clientID, verifier43)
|
||||
_, csrf := h.consent(params)
|
||||
|
||||
tamper := func(k, v string) map[string]string {
|
||||
out := map[string]string{}
|
||||
for key, val := range params {
|
||||
out[key] = val
|
||||
}
|
||||
out[k] = v
|
||||
return out
|
||||
}
|
||||
|
||||
t.Run("redirect swapped", func(t *testing.T) {
|
||||
rec := h.decide(tamper("redirect_uri", "https://attacker.example/steal"), "approve", csrf)
|
||||
if rec.Code == http.StatusFound {
|
||||
t.Fatalf("a tampered redirect_uri was honoured: %s", rec.Header().Get("Location"))
|
||||
}
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Errorf("status = %d, want 400", rec.Code)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("resource swapped", func(t *testing.T) {
|
||||
rec := h.decide(tamper("resource", "https://elsewhere.test/mcp"), "approve", csrf)
|
||||
loc, _ := url.Parse(rec.Header().Get("Location"))
|
||||
if loc.Query().Get("code") != "" {
|
||||
t.Error("a tampered resource still produced a code")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pkce removed", func(t *testing.T) {
|
||||
rec := h.decide(tamper("code_challenge", ""), "approve", csrf)
|
||||
loc, _ := url.Parse(rec.Header().Get("Location"))
|
||||
if loc.Query().Get("code") != "" {
|
||||
t.Error("PKCE was dropped at the consent POST")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("scope escalated", func(t *testing.T) {
|
||||
rec := h.decide(tamper("scope", ScopeWrite), "approve", csrf)
|
||||
loc, _ := url.Parse(rec.Header().Get("Location"))
|
||||
if loc.Query().Get("code") != "" {
|
||||
t.Error("krow.write was granted through the consent POST")
|
||||
}
|
||||
if got := loc.Query().Get("error"); got != errInvalidScope {
|
||||
t.Errorf("error = %q, want %q", got, errInvalidScope)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// An anonymous visitor must be sent to the existing login, not shown a consent
|
||||
// screen for nobody.
|
||||
func TestConsentRequiresAuthentication(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clientID := h.register()
|
||||
h.session.signedIn = false
|
||||
|
||||
rec := h.authorize(authorizeParamsFor(clientID, verifier43))
|
||||
if rec.Code != http.StatusFound {
|
||||
t.Fatalf("status = %d, want a redirect to login", rec.Code)
|
||||
}
|
||||
if !strings.HasPrefix(rec.Header().Get("Location"), "/login?") {
|
||||
t.Errorf("Location = %q, want the existing login", rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// The consent page must never be cached: it names a client and an organisation,
|
||||
// and the next person on a shared machine must not see it.
|
||||
func TestConsentPageIsNotCacheable(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clientID := h.register()
|
||||
rec, _ := h.consent(authorizeParamsFor(clientID, verifier43))
|
||||
|
||||
if got := rec.Header().Get("Cache-Control"); !strings.Contains(got, "no-store") {
|
||||
t.Errorf("Cache-Control = %q, want no-store", got)
|
||||
}
|
||||
if got := rec.Header().Get("X-Frame-Options"); got != "DENY" {
|
||||
t.Errorf("X-Frame-Options = %q, want DENY — a consent screen must not be framed", got)
|
||||
}
|
||||
if !strings.Contains(rec.Header().Get("Content-Security-Policy"), "frame-ancestors 'none'") {
|
||||
t.Error("the CSP does not forbid framing")
|
||||
}
|
||||
}
|
||||
|
||||
// Thin wrappers so the test reads as prose rather than as error handling.
|
||||
func jsonMarshal(v any) (string, error) {
|
||||
b, err := json.Marshal(v)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
/* ── The consent page's CSP ─────────────────────────────────────────────── */
|
||||
|
||||
// The regression test for the bug a real browser found and httptest could not.
|
||||
//
|
||||
// `form-action 'self'` blocked the consent form before it could submit, because
|
||||
// a consent form's successful submission ends at the client's registered
|
||||
// callback — always cross-origin. These tests assert the policy admits exactly
|
||||
// that callback and nothing else.
|
||||
|
||||
func TestConsentCSPAllowsTheRegisteredRedirectOrigin(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
body, _ := jsonMarshal(registrationRequest{
|
||||
ClientName: "Claude", RedirectURIs: []string{"https://claude.ai/api/mcp/auth_callback"},
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
h.server.RegisterHandler().ServeHTTP(rec,
|
||||
httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(body)))
|
||||
var reg registrationResponse
|
||||
_ = jsonUnmarshal(rec.Body.Bytes(), ®)
|
||||
|
||||
page, _ := h.consent(authorizeParamsForClient(reg.ClientID, verifier43, "https://claude.ai/api/mcp/auth_callback"))
|
||||
csp := page.Header().Get("Content-Security-Policy")
|
||||
|
||||
// The registered ORIGIN — not the full URI. A CSP source is an origin; a
|
||||
// path would be matched as a prefix and is not what a navigation is
|
||||
// checked against.
|
||||
if !strings.Contains(csp, "https://claude.ai") {
|
||||
t.Errorf("CSP does not permit the registered redirect origin:\n %s", csp)
|
||||
}
|
||||
if strings.Contains(csp, "/api/mcp/auth_callback") {
|
||||
t.Errorf("CSP carries a path rather than an origin:\n %s", csp)
|
||||
}
|
||||
// Everything that must survive the change.
|
||||
for _, required := range []string{
|
||||
"default-src 'none'",
|
||||
"style-src 'unsafe-inline'",
|
||||
"frame-ancestors 'none'",
|
||||
"form-action 'self'",
|
||||
} {
|
||||
if !strings.Contains(csp, required) {
|
||||
t.Errorf("CSP lost %q:\n %s", required, csp)
|
||||
}
|
||||
}
|
||||
// And everything that must never appear.
|
||||
for _, forbidden := range []string{"form-action *", "'unsafe-eval'", "'unsafe-inline' 'unsafe", "*;", " *"} {
|
||||
if strings.Contains(csp, forbidden) {
|
||||
t.Errorf("CSP contains a broad source %q:\n %s", forbidden, csp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// One client's callback must never appear in another client's policy.
|
||||
func TestConsentCSPDoesNotLeakBetweenClients(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
|
||||
register := func(name, redirect string) string {
|
||||
t.Helper()
|
||||
body, _ := jsonMarshal(registrationRequest{ClientName: name, RedirectURIs: []string{redirect}})
|
||||
rec := httptest.NewRecorder()
|
||||
h.server.RegisterHandler().ServeHTTP(rec,
|
||||
httptest.NewRequest(http.MethodPost, "/oauth/register", strings.NewReader(body)))
|
||||
var reg registrationResponse
|
||||
_ = jsonUnmarshal(rec.Body.Bytes(), ®)
|
||||
return reg.ClientID
|
||||
}
|
||||
|
||||
first := register("First", "https://first.example.test/cb")
|
||||
second := register("Second", "https://second.example.test/cb")
|
||||
|
||||
firstPage, _ := h.consent(authorizeParamsForClient(first, verifier43, "https://first.example.test/cb"))
|
||||
firstCSP := firstPage.Header().Get("Content-Security-Policy")
|
||||
|
||||
if !strings.Contains(firstCSP, "https://first.example.test") {
|
||||
t.Errorf("the first client's own origin is missing:\n %s", firstCSP)
|
||||
}
|
||||
if strings.Contains(firstCSP, "second.example.test") {
|
||||
t.Errorf("another client's redirect origin leaked into this policy:\n %s", firstCSP)
|
||||
}
|
||||
|
||||
secondPage, _ := h.consent(authorizeParamsForClient(second, verifier43, "https://second.example.test/cb"))
|
||||
secondCSP := secondPage.Header().Get("Content-Security-Policy")
|
||||
if strings.Contains(secondCSP, "first.example.test") {
|
||||
t.Errorf("the first client's origin leaked into the second's policy:\n %s", secondCSP)
|
||||
}
|
||||
}
|
||||
|
||||
// An unrelated origin must never be permitted.
|
||||
func TestConsentCSPExcludesUnrelatedOrigins(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
clientID := h.register() // registered for testRedirect only
|
||||
page, _ := h.consent(authorizeParamsFor(clientID, verifier43))
|
||||
csp := page.Header().Get("Content-Security-Policy")
|
||||
|
||||
for _, unrelated := range []string{"https://evil.test", "https://attacker.example", "https://google.com"} {
|
||||
if strings.Contains(csp, unrelated) {
|
||||
t.Errorf("CSP permits an unrelated origin %q:\n %s", unrelated, csp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── redirectOrigins, directly ──────────────────────────────────────────── */
|
||||
|
||||
// The helper carries the whole safety argument, so it is tested on its own
|
||||
// rather than only through a rendered page.
|
||||
func TestRedirectOrigins(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
in []string
|
||||
want []string
|
||||
}{
|
||||
"https with path": {
|
||||
[]string{"https://claude.ai/api/mcp/auth_callback"},
|
||||
[]string{"https://claude.ai"},
|
||||
},
|
||||
"port preserved": {
|
||||
[]string{"https://app.example.test:8443/cb"},
|
||||
[]string{"https://app.example.test:8443"},
|
||||
},
|
||||
"loopback http is kept, per registration rules": {
|
||||
[]string{"http://127.0.0.1:33418/callback"},
|
||||
[]string{"http://127.0.0.1:33418"},
|
||||
},
|
||||
"localhost loopback": {
|
||||
[]string{"http://localhost:3000/cb"},
|
||||
[]string{"http://localhost:3000"},
|
||||
},
|
||||
"multiple registered URIs": {
|
||||
[]string{"https://claude.ai/cb", "http://127.0.0.1:33418/callback"},
|
||||
[]string{"https://claude.ai", "http://127.0.0.1:33418"},
|
||||
},
|
||||
"duplicates collapse to one source": {
|
||||
[]string{"https://claude.ai/one", "https://claude.ai/two", "https://claude.ai/three"},
|
||||
[]string{"https://claude.ai"},
|
||||
},
|
||||
"order is the order registered": {
|
||||
[]string{"https://b.test/cb", "https://a.test/cb"},
|
||||
[]string{"https://b.test", "https://a.test"},
|
||||
},
|
||||
// Everything below must be DROPPED, never broadened.
|
||||
"relative uri": {[]string{"/callback"}, nil},
|
||||
"no host": {[]string{"https://"}, nil},
|
||||
"custom scheme": {[]string{"myapp://callback"}, nil},
|
||||
"javascript scheme": {[]string{"javascript:alert(1)"}, nil},
|
||||
"data scheme": {[]string{"data:text/html,x"}, nil},
|
||||
"wildcard host": {[]string{"https://*.evil.test/cb"}, nil},
|
||||
"semicolon injection": {[]string{"https://evil.test;form-action *"}, nil},
|
||||
"space injection": {[]string{"https://evil.test /cb"}, nil},
|
||||
"empty": {[]string{""}, nil},
|
||||
"whitespace only": {[]string{" "}, nil},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
got := redirectOrigins(tc.in)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("redirectOrigins(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
for i := range tc.want {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Errorf("origin[%d] = %q, want %q", i, got[i], tc.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A dropped URI must never widen the policy — the page still renders, and the
|
||||
// form-action list is simply shorter.
|
||||
func TestABadRedirectURICannotWidenTheCSP(t *testing.T) {
|
||||
csp := consentCSP([]string{"https://evil.test;form-action *", "myapp://cb", "https://*.evil.test"})
|
||||
|
||||
if strings.Contains(csp, "*") {
|
||||
t.Errorf("a malformed redirect URI introduced a wildcard:\n %s", csp)
|
||||
}
|
||||
if strings.Count(csp, ";") != 3 {
|
||||
t.Errorf("the header has %d separators, want 3 — a URI broke out of its directive:\n %s",
|
||||
strings.Count(csp, ";"), csp)
|
||||
}
|
||||
if !strings.Contains(csp, "form-action 'self'") {
|
||||
t.Errorf("form-action lost 'self':\n %s", csp)
|
||||
}
|
||||
// With every URI dropped, the policy is exactly the strict one — which
|
||||
// blocks the flow visibly rather than permitting more.
|
||||
if strings.Contains(csp, "evil.test") {
|
||||
t.Errorf("a dropped URI still reached the policy:\n %s", csp)
|
||||
}
|
||||
}
|
||||
|
||||
// authorizeParamsForClient is authorizeParamsFor with an explicit redirect, so
|
||||
// a test can drive a client registered for something other than testRedirect.
|
||||
func authorizeParamsForClient(clientID, verifier, redirect string) map[string]string {
|
||||
p := authorizeParamsFor(clientID, verifier)
|
||||
p["redirect_uri"] = redirect
|
||||
return p
|
||||
}
|
||||
Reference in New Issue
Block a user