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, ®Doc) 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, ®Doc) // 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, ®Doc) 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, ®Doc) 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, ®Doc) 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")) } }