786 lines
28 KiB
Go
786 lines
28 KiB
Go
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"))
|
||
}
|
||
}
|