mcp connection
This commit is contained in:
785
go-api/internal/httpserver/mcp_routes_test.go
Normal file
785
go-api/internal/httpserver/mcp_routes_test.go
Normal file
@@ -0,0 +1,785 @@
|
||||
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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user