Files
krow_backend/go-api/internal/httpserver/mcp_routes_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

786 lines
28 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package httpserver_test
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"github.com/krow/krow-backend/go-api/internal/config"
"github.com/krow/krow-backend/go-api/internal/db"
"github.com/krow/krow-backend/go-api/internal/httpserver"
"github.com/krow/krow-backend/go-api/internal/testutil"
)
// The configured deployment these tests run as. Fictional on purpose: every
// URL in a discovery document must be traceable to THIS configuration, and a
// realistic hostname would make a hardcoded one impossible to spot.
const (
testOAuthIssuer = "https://krow.example.test"
testMCPResource = "https://krow.example.test/mcp"
)
/* ── A fixture that can see headers and raw bodies ──────────────────────── */
// mcpResponse carries what the existing `response` deliberately does not: the
// headers (WWW-Authenticate is the whole point of several tests) and the raw
// body (the consent page is HTML, not JSON).
//
// A separate type rather than a change to `response`, so not one existing test
// in this package is touched.
type mcpResponse struct {
code int
body string
header http.Header
}
type mcpAPI struct {
t *testing.T
handler http.Handler
srv *httpserver.Server
h *testutil.Harness
cookie *http.Cookie
email string
userID string
}
// newOAuthAPI builds a server WITH OAuth configured, and signs in.
//
// The OAuth block is what makes routeOAuth and routeMCP register at all; the
// standard newAPI fixture leaves it empty, which is what
// TestMCPRoutesAreAbsentWhenUnconfigured relies on.
func newOAuthAPI(t *testing.T) *mcpAPI {
t.Helper()
h := testutil.New(t)
cfg := &config.Config{
AppEnv: "development",
HTTP: config.HTTPConfig{
Host: "127.0.0.1", Port: 0, ShutdownTimeout: time.Second,
},
DB: config.DBConfig{Schema: "public"},
OAuth: config.OAuthConfig{
Issuer: testOAuthIssuer,
Resource: testMCPResource,
LoginPath: "/login",
},
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
srv, err := httpserver.New(cfg, &db.DB{Pool: h.Pool, Schema: "public"}, log)
if err != nil {
t.Fatalf("build the server: %v", err)
}
a := &mcpAPI{t: t, handler: srv.Handler(), srv: srv, h: h}
a.userID, a.email = seededUser(t, h.Pool)
setPassword(t, h.Pool, a.userID)
result := signIn(t, a.handler, a.email, harnessPassword, false)
if result.code != http.StatusOK || result.cookie == nil {
t.Fatalf("the harness could not sign in: %d", result.code)
}
a.cookie = result.cookie
return a
}
func (a *mcpAPI) send(req *http.Request, withCookie bool) mcpResponse {
a.t.Helper()
if withCookie && a.cookie != nil {
req.AddCookie(a.cookie)
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return mcpResponse{code: rec.Code, body: rec.Body.String(), header: rec.Header()}
}
func (a *mcpAPI) jsonReq(method, path string, payload any) *http.Request {
a.t.Helper()
var body io.Reader
if payload != nil {
raw, err := json.Marshal(payload)
if err != nil {
a.t.Fatalf("encode: %v", err)
}
body = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, body)
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
return req
}
// do sends WITH the session cookie — a signed-in browser.
func (a *mcpAPI) do(method, path string, payload any) mcpResponse {
return a.send(a.jsonReq(method, path, payload), true)
}
// doAnon sends WITHOUT any credential.
func (a *mcpAPI) doAnon(method, path string, payload any) mcpResponse {
return a.send(a.jsonReq(method, path, payload), false)
}
// doAnonWithHeader sends one extra header and no cookie.
func (a *mcpAPI) doAnonWithHeader(method, path string, payload any, key, value string) mcpResponse {
req := a.jsonReq(method, path, payload)
req.Header.Set(key, value)
return a.send(req, false)
}
func (a *mcpAPI) formReq(method, path string, form url.Values) *http.Request {
req := httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
return req
}
// doForm posts a form WITH the cookie — the consent decision.
func (a *mcpAPI) doForm(method, path string, form url.Values) mcpResponse {
return a.send(a.formReq(method, path, form), true)
}
// doAnonForm posts a form WITHOUT a cookie — the back-channel token call.
func (a *mcpAPI) doAnonForm(method, path string, form url.Values) mcpResponse {
return a.send(a.formReq(method, path, form), false)
}
// oauthAccessToken runs the whole flow and returns a usable access token, for
// tests that need a valid credential to prove it is being ignored.
func (a *mcpAPI) oauthAccessToken(t *testing.T) string {
t.Helper()
reg := a.doAnon("POST", "/oauth/register", map[string]any{
"client_name": "Token Helper", "redirect_uris": []string{"https://client.example.test/cb"},
})
var regDoc struct {
ClientID string `json:"client_id"`
}
mustJSON(t, reg.body, &regDoc)
verifier := "helperVerifier0123456789abcdefghijklmnopqrst"
q := url.Values{
"client_id": {regDoc.ClientID}, "redirect_uri": {"https://client.example.test/cb"},
"response_type": {"code"}, "state": {"helper"},
"code_challenge": {challengeFor(verifier)}, "code_challenge_method": {"S256"},
"resource": {testMCPResource}, "scope": {"krow.read"},
}
consent := a.do("GET", "/oauth/authorize?"+q.Encode(), nil)
csrf := between(consent.body, `name="csrf" value="`, `"`)
form := url.Values{}
for k, v := range q {
form[k] = v
}
form.Set("decision", "approve")
form.Set("csrf", csrf)
approved := a.doForm("POST", "/oauth/authorize", form)
loc, _ := url.Parse(approved.header.Get("Location"))
tok := a.doAnonForm("POST", "/oauth/token", url.Values{
"grant_type": {"authorization_code"}, "code": {loc.Query().Get("code")},
"client_id": {regDoc.ClientID}, "redirect_uri": {"https://client.example.test/cb"},
"code_verifier": {verifier},
})
var tokens struct {
AccessToken string `json:"access_token"`
}
mustJSON(t, tok.body, &tokens)
if tokens.AccessToken == "" {
t.Fatalf("could not obtain a token: %s", tok.body)
}
return tokens.AccessToken
}
// challengeFor derives an S256 challenge, so these tests do not depend on the
// oauth package's unexported helpers.
func challengeFor(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
// The mounted surface, end to end.
//
// Everything below drives the REAL router — the same mux, the same
// authenticate() middleware, the same publicPaths allowlist that serves
// production. The point is not to re-test the OAuth package (internal/oauth
// does that against its own handlers) but to prove the MOUNTING is right: that
// discovery is reachable without a cookie, that /mcp is not, that a cookie
// cannot substitute for a bearer token, and that the routes appear at all only
// when the deployment is configured for them.
/* ── Route registration is conditional ──────────────────────────────────── */
// Without OAUTH_ISSUER and MCP_RESOURCE, none of this exists. An upgrade must
// not quietly add an authorization server to a deployment that never asked.
func TestMCPRoutesAreAbsentWhenUnconfigured(t *testing.T) {
a := newAPI(t) // the standard fixture: no OAuth configuration
for _, path := range []string{
"/mcp",
"/oauth/register",
"/oauth/authorize",
"/oauth/token",
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-authorization-server",
} {
r := a.doAnon("POST", path, nil)
if r.code != http.StatusNotFound && r.code != http.StatusUnauthorized {
t.Errorf("%s = %d on an unconfigured deployment; want 404 or 401, never a served response",
path, r.code)
}
}
}
/* ── Discovery is public ────────────────────────────────────────────────── */
// A client with no token must be able to read both documents, or it can never
// discover how to get one.
func TestDiscoveryIsReachableWithoutASession(t *testing.T) {
a := newOAuthAPI(t)
t.Run("protected resource", func(t *testing.T) {
r := a.doAnon("GET", "/.well-known/oauth-protected-resource", nil)
if r.code != http.StatusOK {
t.Fatalf("status = %d, want 200 without a cookie: %s", r.code, r.body)
}
var doc struct {
Resource string `json:"resource"`
AuthorizationServers []string `json:"authorization_servers"`
BearerMethods []string `json:"bearer_methods_supported"`
}
mustJSON(t, r.body, &doc)
if doc.Resource != testMCPResource {
t.Errorf("resource = %q, want %q", doc.Resource, testMCPResource)
}
if len(doc.AuthorizationServers) != 1 || doc.AuthorizationServers[0] != testOAuthIssuer {
t.Errorf("authorization_servers = %v, want [%q]", doc.AuthorizationServers, testOAuthIssuer)
}
// The MCP spec forbids a token in the query string.
if strings.Join(doc.BearerMethods, ",") != "header" {
t.Errorf("bearer_methods_supported = %v, want [header]", doc.BearerMethods)
}
})
t.Run("authorization server", func(t *testing.T) {
r := a.doAnon("GET", "/.well-known/oauth-authorization-server", nil)
if r.code != http.StatusOK {
t.Fatalf("status = %d, want 200 without a cookie: %s", r.code, r.body)
}
var doc struct {
Issuer string `json:"issuer"`
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
Scopes []string `json:"scopes_supported"`
ResponseTypes []string `json:"response_types_supported"`
GrantTypes []string `json:"grant_types_supported"`
PKCEMethods []string `json:"code_challenge_methods_supported"`
ResourceIndicators bool `json:"resource_indicators_supported"`
}
mustJSON(t, r.body, &doc)
// EVERY url must come from configuration. A hardcoded hostname would
// be one deployment's identity baked into every other one.
if doc.Issuer != testOAuthIssuer {
t.Errorf("issuer = %q, want %q", doc.Issuer, testOAuthIssuer)
}
for name, got := range map[string]string{
"authorization_endpoint": doc.AuthorizationEndpoint,
"token_endpoint": doc.TokenEndpoint,
"registration_endpoint": doc.RegistrationEndpoint,
} {
if !strings.HasPrefix(got, testOAuthIssuer) {
t.Errorf("%s = %q, want it under the configured issuer", name, got)
}
}
if strings.Join(doc.ResponseTypes, ",") != "code" {
t.Errorf("response_types_supported = %v; implicit must not be advertised", doc.ResponseTypes)
}
if strings.Join(doc.PKCEMethods, ",") != "S256" {
t.Errorf("code_challenge_methods_supported = %v, want [S256]", doc.PKCEMethods)
}
for _, forbidden := range []string{"password", "client_credentials", "implicit"} {
for _, advertised := range doc.GrantTypes {
if advertised == forbidden {
t.Errorf("grant_types_supported advertises %q", forbidden)
}
}
}
for _, s := range doc.Scopes {
if s == "krow.write" {
t.Error("scopes_supported advertises krow.write")
}
}
if !doc.ResourceIndicators {
t.Error("resource_indicators_supported must be true")
}
})
}
/* ── /mcp authentication ────────────────────────────────────────────────── */
// No bearer → 401 with a challenge that tells the client where to go.
func TestMCPWithoutBearerReturns401AndDiscoveryPointer(t *testing.T) {
a := newOAuthAPI(t)
r := a.doAnon("POST", "/mcp", map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
if r.code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401", r.code)
}
challenge := r.header.Get("WWW-Authenticate")
if !strings.HasPrefix(challenge, "Bearer") {
t.Fatalf("WWW-Authenticate = %q, want a Bearer challenge", challenge)
}
// RFC 9728: without resource_metadata the client has a 401 and nowhere to
// look. This is the difference between "failed" and "here is how".
if !strings.Contains(challenge, `resource_metadata="`+testOAuthIssuer) {
t.Errorf("WWW-Authenticate = %q, want resource_metadata built from the configured issuer", challenge)
}
// And it must be built from config, not baked in.
if strings.Contains(challenge, "krowforce.com") {
t.Errorf("WWW-Authenticate contains a hardcoded production hostname: %q", challenge)
}
}
// THE test for this phase's riskiest decision: a perfectly valid KROW session
// cookie must not open the MCP endpoint.
func TestMCPRejectsACookieSession(t *testing.T) {
a := newOAuthAPI(t)
// `a.do` sends the authenticated session cookie the rest of the suite uses.
r := a.do("POST", "/mcp", map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
if r.code != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401 — a browser cookie authenticated an MCP call", r.code)
}
}
func TestMCPRejectsAnInvalidBearer(t *testing.T) {
a := newOAuthAPI(t)
for name, header := range map[string]string{
"unknown token": "Bearer not-a-real-token",
"empty": "Bearer ",
"wrong scheme": "Basic dXNlcjpwYXNz",
"no scheme": "abcdef",
} {
t.Run(name, func(t *testing.T) {
r := a.doAnonWithHeader("POST", "/mcp", map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
}, "Authorization", header)
if r.code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401", r.code)
}
})
}
}
// A token must never be accepted from the query string. The MCP spec forbids
// it, and a URL is logged, cached and put in a Referer.
func TestMCPIgnoresATokenInTheQueryString(t *testing.T) {
a := newOAuthAPI(t)
token := a.oauthAccessToken(t)
r := a.doAnon("POST", "/mcp?access_token="+url.QueryEscape(token), map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
})
if r.code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401 — a query-string token was accepted", r.code)
}
}
// Custom identity headers must be ignored outright.
func TestMCPIgnoresCustomIdentityHeaders(t *testing.T) {
a := newOAuthAPI(t)
for _, header := range []string{"X-Access-Token", "X-Api-Key", "X-Org-Id", "X-User-Id", "X-Krow-Token"} {
r := a.doAnonWithHeader("POST", "/mcp", map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "tools/list",
}, header, a.oauthAccessToken(t))
if r.code != http.StatusUnauthorized {
t.Errorf("%s was accepted as a credential: %d", header, r.code)
}
}
}
/* ── The full discovery → consent → token → MCP journey ─────────────────── */
// Every step a Claude client performs, over the real router, in order.
func TestFullMCPConnectionJourney(t *testing.T) {
a := newOAuthAPI(t)
// 1–2. Call /mcp with no token; get 401 and a pointer.
unauth := a.doAnon("POST", "/mcp", map[string]any{
"jsonrpc": "2.0", "id": 1, "method": "initialize",
})
if unauth.code != http.StatusUnauthorized {
t.Fatalf("step 1: status = %d, want 401", unauth.code)
}
challenge := unauth.header.Get("WWW-Authenticate")
// 3. Follow resource_metadata to the protected-resource document.
metaURL := between(challenge, `resource_metadata="`, `"`)
if metaURL == "" {
t.Fatal("step 3: the challenge carries no resource_metadata")
}
prPath := strings.TrimPrefix(metaURL, testOAuthIssuer)
pr := a.doAnon("GET", prPath, nil)
if pr.code != http.StatusOK {
t.Fatalf("step 3: %s = %d", prPath, pr.code)
}
var prDoc struct {
AuthorizationServers []string `json:"authorization_servers"`
}
mustJSON(t, pr.body, &prDoc)
// 4. Authorization-server metadata.
as := a.doAnon("GET", "/.well-known/oauth-authorization-server", nil)
if as.code != http.StatusOK {
t.Fatalf("step 4: status = %d", as.code)
}
var asDoc struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
}
mustJSON(t, as.body, &asDoc)
// 5. Register, at the advertised endpoint.
reg := a.doAnon("POST", strings.TrimPrefix(asDoc.RegistrationEndpoint, testOAuthIssuer), map[string]any{
"client_name": "Journey Client",
"redirect_uris": []string{"https://client.example.test/cb"},
})
if reg.code != http.StatusCreated {
t.Fatalf("step 5: registration = %d %s", reg.code, reg.body)
}
var regDoc struct {
ClientID string `json:"client_id"`
}
mustJSON(t, reg.body, &regDoc)
// 6–7. Authorize, SIGNED IN. A cookie is exactly right here: this step is
// a person in a browser.
verifier := "journeyVerifier0123456789abcdefghijklmnopqrs"
q := url.Values{
"client_id": {regDoc.ClientID}, "redirect_uri": {"https://client.example.test/cb"},
"response_type": {"code"}, "state": {"journey-state"},
"code_challenge": {challengeFor(verifier)}, "code_challenge_method": {"S256"},
"resource": {testMCPResource}, "scope": {"krow.read"},
}
consent := a.do("GET", "/oauth/authorize?"+q.Encode(), nil)
// 8. A consent page, not a code.
if consent.code != http.StatusOK {
t.Fatalf("step 8: expected a consent page, got %d %s", consent.code, consent.body)
}
if !strings.Contains(consent.body, "Journey Client") {
t.Error("step 8: the consent page does not name the requesting client")
}
csrf := between(consent.body, `name="csrf" value="`, `"`)
if csrf == "" {
t.Fatal("step 8: no csrf token in the consent form")
}
// 9–10. Approve; receive a code.
form := url.Values{}
for k, v := range q {
form[k] = v
}
form.Set("decision", "approve")
form.Set("csrf", csrf)
approved := a.doForm("POST", "/oauth/authorize", form)
if approved.code != http.StatusFound {
t.Fatalf("step 10: approve = %d %s", approved.code, approved.body)
}
loc, _ := url.Parse(approved.header.Get("Location"))
code := loc.Query().Get("code")
if code == "" {
t.Fatalf("step 10: no code in %s", loc)
}
if loc.Query().Get("state") != "journey-state" {
t.Errorf("step 10: state = %q", loc.Query().Get("state"))
}
// 11. Exchange — with NO cookie, as a back-channel call.
tok := a.doAnonForm("POST", strings.TrimPrefix(asDoc.TokenEndpoint, testOAuthIssuer), url.Values{
"grant_type": {"authorization_code"}, "code": {code},
"client_id": {regDoc.ClientID}, "redirect_uri": {"https://client.example.test/cb"},
"code_verifier": {verifier},
})
if tok.code != http.StatusOK {
t.Fatalf("step 11: token = %d %s", tok.code, tok.body)
}
var tokens struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
}
mustJSON(t, tok.body, &tokens)
if tokens.AccessToken == "" || tokens.TokenType != "Bearer" {
t.Fatalf("step 11: unusable token response: %s", tok.body)
}
// 12–13. tools/list with the bearer token.
list := a.doAnonWithHeader("POST", "/mcp", map[string]any{
"jsonrpc": "2.0", "id": 2, "method": "tools/list",
}, "Authorization", "Bearer "+tokens.AccessToken)
if list.code != http.StatusOK {
t.Fatalf("step 13: tools/list = %d %s", list.code, list.body)
}
var listDoc struct {
Result struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
} `json:"result"`
}
mustJSON(t, list.body, &listDoc)
if len(listDoc.Result.Tools) != 16 {
t.Errorf("step 13: %d tools, want 16", len(listDoc.Result.Tools))
}
for _, tool := range listDoc.Result.Tools {
switch tool.Name {
case "assign_worker", "move_application", "knowledge_search":
t.Errorf("step 13: %q is exposed over the mounted route", tool.Name)
}
}
// 14. tools/call reaches the existing authorization and real data.
call := a.doAnonWithHeader("POST", "/mcp", map[string]any{
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": map[string]any{"name": "workspace_summary", "arguments": map[string]any{}},
}, "Authorization", "Bearer "+tokens.AccessToken)
if call.code != http.StatusOK {
t.Fatalf("step 14: tools/call = %d %s", call.code, call.body)
}
var callDoc struct {
Result struct {
IsError bool `json:"isError"`
Content []struct {
Text string `json:"text"`
} `json:"content"`
} `json:"result"`
}
mustJSON(t, call.body, &callDoc)
if callDoc.Result.IsError {
t.Fatalf("step 14: the tool refused: %s", callDoc.Result.Content[0].Text)
}
}
// Denial must reach the client correctly and issue nothing.
func TestConsentDenialOverTheMountedRoute(t *testing.T) {
a := newOAuthAPI(t)
reg := a.doAnon("POST", "/oauth/register", map[string]any{
"client_name": "Deny Client", "redirect_uris": []string{"https://client.example.test/cb"},
})
var regDoc struct {
ClientID string `json:"client_id"`
}
mustJSON(t, reg.body, &regDoc)
verifier := "denyVerifier0123456789abcdefghijklmnopqrstuv"
q := url.Values{
"client_id": {regDoc.ClientID}, "redirect_uri": {"https://client.example.test/cb"},
"response_type": {"code"}, "state": {"deny-state"},
"code_challenge": {challengeFor(verifier)}, "code_challenge_method": {"S256"},
"resource": {testMCPResource}, "scope": {"krow.read"},
}
consent := a.do("GET", "/oauth/authorize?"+q.Encode(), nil)
csrf := between(consent.body, `name="csrf" value="`, `"`)
form := url.Values{}
for k, v := range q {
form[k] = v
}
form.Set("decision", "deny")
form.Set("csrf", csrf)
denied := a.doForm("POST", "/oauth/authorize", form)
if denied.code != http.StatusFound {
t.Fatalf("status = %d, want 302", denied.code)
}
loc, _ := url.Parse(denied.header.Get("Location"))
if got := loc.Query().Get("error"); got != "access_denied" {
t.Errorf("error = %q, want access_denied", got)
}
if got := loc.Query().Get("state"); got != "deny-state" {
t.Errorf("state = %q, want deny-state", got)
}
if loc.Query().Get("code") != "" {
t.Error("a denial issued a code")
}
}
// /oauth/authorize is NOT public: an anonymous visitor must be sent to login.
func TestAuthorizeRequiresASession(t *testing.T) {
a := newOAuthAPI(t)
r := a.doAnon("GET", "/oauth/authorize?client_id=x", nil)
// Either the middleware refuses it (401) or the handler redirects to
// login. Both are correct; serving a consent page is not.
if r.code == http.StatusOK && strings.Contains(r.body, "Approve") {
t.Fatal("a consent page was served to an anonymous visitor")
}
}
/* ── Helpers ────────────────────────────────────────────────────────────── */
func mustJSON(t *testing.T, body string, dst any) {
t.Helper()
if err := json.Unmarshal([]byte(body), dst); err != nil {
t.Fatalf("response was not JSON: %v\nbody: %s", err, body)
}
}
// between returns the text between two markers, or "".
func between(s, start, end string) string {
i := strings.Index(s, start)
if i < 0 {
return ""
}
rest := s[i+len(start):]
j := strings.Index(rest, end)
if j < 0 {
return ""
}
return rest[:j]
}
/* ── Anonymous /oauth/authorize must reach the handler ──────────────────── */
// The regression test for the defect a live Claude Web connection exposed.
//
// /oauth/authorize was withheld from publicPaths, so the cookie middleware
// answered a signed-out visitor with its JSON 401 and the handler never ran —
// which meant the handler's redirect-to-login could never execute. A first-time
// connector user is signed out by definition, so OAuth's browser leg was
// unreachable for precisely the people who needed it.
//
// WHY THE EXISTING TESTS MISSED IT, and why this one is shaped differently:
//
// - oauth.TestAuthorizeRedirectsAnonymousToLogin drives AuthorizeHandler
// DIRECTLY, so the middleware is not in the path at all. It passed against
// broken behaviour because it never exercised the thing that was broken.
// - TestAuthorizeRequiresASession (below) asserts only that a consent page is
// not served anonymously — which a 401 satisfies perfectly well.
//
// So this one drives the MOUNTED router and asserts the POSITIVE behaviour: a
// redirect to the login, carrying the original authorization request.
func TestAnonymousAuthorizeReachesTheHandlerAndRedirectsToLogin(t *testing.T) {
a := newOAuthAPI(t)
// A client to name, so the request is well-formed enough to get past the
// handler's own client/redirect validation and reach the session check.
reg := a.doAnon("POST", "/oauth/register", map[string]any{
"client_name": "Anonymous Flow", "redirect_uris": []string{"https://client.example.test/cb"},
})
var regDoc struct {
ClientID string `json:"client_id"`
}
mustJSON(t, reg.body, &regDoc)
verifier := "anonVerifier0123456789abcdefghijklmnopqrstu"
q := url.Values{
"client_id": {regDoc.ClientID}, "redirect_uri": {"https://client.example.test/cb"},
"response_type": {"code"}, "state": {"anon-state"},
"code_challenge": {challengeFor(verifier)}, "code_challenge_method": {"S256"},
"resource": {testMCPResource}, "scope": {"krow.read"},
}
// doAnon sends NO session cookie — a first-time connector user.
r := a.doAnon("GET", "/oauth/authorize?"+q.Encode(), nil)
// The defect: the middleware's JSON 401 instead of the handler's redirect.
if r.code == http.StatusUnauthorized {
t.Fatalf("the middleware refused before the handler ran: %d %s\n"+
"a signed-out visitor must be sent to sign in, not told 'no'", r.code, r.body)
}
if strings.Contains(r.body, `"code": "unauthorized"`) ||
strings.Contains(r.body, `"code":"unauthorized"`) {
t.Fatalf("the response is the middleware's JSON 401, not the handler's: %s", r.body)
}
if r.code != http.StatusFound {
t.Fatalf("status = %d, want 302 to the login", r.code)
}
location := r.header.Get("Location")
if !strings.HasPrefix(location, "/login?returnTo=") {
t.Fatalf("Location = %q, want a redirect to the configured login path", location)
}
// The whole authorization request must survive the round trip, or the
// person signs in and lands nowhere.
returnTo, err := url.QueryUnescape(strings.TrimPrefix(location, "/login?returnTo="))
if err != nil {
t.Fatalf("returnTo is not decodable: %v", err)
}
for name, want := range map[string]string{
"path": "/oauth/authorize",
"client_id": "client_id=" + regDoc.ClientID,
"state": "state=anon-state",
"code_challenge": "code_challenge=" + challengeFor(verifier),
"code_challenge_method": "code_challenge_method=S256",
"resource": "resource=",
"redirect_uri": "redirect_uri=",
} {
if !strings.Contains(returnTo, want) {
t.Errorf("returnTo has lost the %s: %q", name, returnTo)
}
}
}
// Listing the path must NOT hand out consent, or a code, to somebody signed
// out. "Public" here means the handler decides — not that the route is open.
func TestAnonymousAuthorizeStillGrantsNothing(t *testing.T) {
a := newOAuthAPI(t)
reg := a.doAnon("POST", "/oauth/register", map[string]any{
"client_name": "Nothing Granted", "redirect_uris": []string{"https://client.example.test/cb"},
})
var regDoc struct {
ClientID string `json:"client_id"`
}
mustJSON(t, reg.body, &regDoc)
verifier := "nothingVerifier0123456789abcdefghijklmnopq"
q := url.Values{
"client_id": {regDoc.ClientID}, "redirect_uri": {"https://client.example.test/cb"},
"response_type": {"code"}, "state": {"nothing"},
"code_challenge": {challengeFor(verifier)}, "code_challenge_method": {"S256"},
"resource": {testMCPResource}, "scope": {"krow.read"},
}
// A GET must not render consent.
get := a.doAnon("GET", "/oauth/authorize?"+q.Encode(), nil)
if strings.Contains(get.body, "Approve") || strings.Contains(get.body, "Authorize access to Krow") {
t.Error("a consent page was served to a signed-out visitor")
}
// And a POST — skipping the page entirely, as an attacker would — must not
// issue a code. The handler's session check refuses before the CSRF check
// is even relevant.
form := url.Values{}
for k, v := range q {
form[k] = v
}
form.Set("decision", "approve")
form.Set("csrf", "forged")
post := a.doAnonForm("POST", "/oauth/authorize", form)
if loc := post.header.Get("Location"); strings.Contains(loc, "code=") {
t.Fatalf("an anonymous POST obtained an authorization code: %s", loc)
}
if post.code == http.StatusFound && strings.HasPrefix(post.header.Get("Location"), "https://client.example.test") {
t.Fatalf("an anonymous POST reached the client callback: %s", post.header.Get("Location"))
}
}