Files
krow_backend/go-api/internal/oauth/consent_test.go
Aravind f2aa3b3ad8
Some checks failed
CI / fixture (push) Has been cancelled
CI / test (push) Has been cancelled
mcp connection
2026-09-22 10:58:02 +05:30

502 lines
18 KiB
Go

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(), &reg)
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(), "&lt;script&gt;") {
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(), &reg)
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(), &reg)
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
}