diff --git a/docs/services/mcp.md b/docs/services/mcp.md index e7494de2..394ed9cc 100644 --- a/docs/services/mcp.md +++ b/docs/services/mcp.md @@ -138,7 +138,7 @@ Sessions follow the streamable HTTP transport: `initialize` opens one, `Mcp-Sess ## Protected resource metadata -pier serves the OAuth protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource` and at `/.well-known/oauth-protected-resource`: the resource, the authorization server (the verifier's resource origin, else `app.url`), the supported scopes and the bearer method. When the published verifier implements `pact.MCPAdvertisedScopes` and `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty, that slice is copied; otherwise the document keeps `pier.PRMScopes` (`read`, `write`, `ai`, `offline_access`, `backend`). pier does not read `pact.MemberTokenScopeCatalog`. The list the OAuth server plugin publishes is the MCP advertised set: the fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes follow the plugin's `golem15.oauthserver.mcp_scopes` filter, which is that plugin's key and not a framework `mcp.*` key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. An empty `wristband.Options.ScopesSupported` on the MCP flavour resolves to those five names; a non-empty list is used as given. OpenID Connect discovery stays `openid`, `profile` and `email`. A route reads who called a tool with `pact.MCPCallFrom`. A 401 carries `WWW-Authenticate: Bearer resource_metadata="…", scope="read"`, which is how a client finds the authorization server. `mcp.resource` must equal the resource the OAuth server issues tokens for; a difference is logged as a warning at boot. +pier serves the OAuth protected resource metadata (RFC 9728) at `/.well-known/oauth-protected-resource` and at `/.well-known/oauth-protected-resource`: the resource, the authorization server (the verifier's resource origin, else `app.url`), the supported scopes and the bearer method. When the published verifier implements `pact.MCPAdvertisedScopes` and `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty, that slice is copied; otherwise the document keeps `pier.PRMScopes` (`read`, `write`, `ai`, `offline_access`, `backend`). pier does not read `pact.MemberTokenScopeCatalog`. The list the OAuth server plugin publishes is the MCP advertised set: the fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes follow the plugin's `golem15.oauthserver.mcp_scopes` filter, which is that plugin's key and not a framework `mcp.*` key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. An empty `wristband.Options.ScopesSupported` on the MCP flavour resolves to those five names; a non-empty list is used as given. OpenID Connect discovery stays `openid`, `profile` and `email`. A route reads who called a tool with `pact.MCPCallFrom`. A 401 carries `WWW-Authenticate: Bearer resource_metadata="…"`, which is how a client finds the authorization server. It carries no scope parameter, so a client requests the scopes listed in the metadata document and consent decides what is granted. `mcp.resource` must equal the resource the OAuth server issues tokens for; a difference is logged as a warning at boot. ## Security notes diff --git a/modules/pier/README.md b/modules/pier/README.md index 88272a6a..f04c8d53 100644 --- a/modules/pier/README.md +++ b/modules/pier/README.md @@ -44,7 +44,7 @@ Use cases: - Widening on return (A-WR-04): when a binding comes back with a different identity (more or fewer scopes, a backend subject added or dropped), its stateful session or stateless server is re-filtered on that request, so newly allowed tools and prompts appear at once (with `list_changed`) instead of waiting for an event. Token ids play no part in this, so an access-token rotation alone re-filters nothing. A stateless server is shared by every live token of its binding, so a return with less reach only adds there and never removes: the receiving middleware hides, per request, what that request's identity may not use, and a call of a wider token is never cut off by a narrower one. A guard provider error during this re-filtering is not a refusal: the cached server stays as it was and the next request retries (logged as `pier: widening deferred`); a fan-out recompute that meets one hides the failing items and leaves the server to be re-filtered by the binding's next request. - Protected resource metadata (RFC 9728): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource` answer the resource, the authorization server (the published verifier's resource origin, else `app.url`), the supported scopes and the bearer method. When the published `pact.MCPTokenVerifier` also implements `pact.MCPAdvertisedScopes` and `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty, that slice is copied; otherwise the document keeps `pier.PRMScopes`. pier does not read `pact.MemberTokenScopeCatalog` and does not widen the read/write ceiling. A `mcp.resource` that differs from the verifier's resource is a boot warning. The list the OAuth server plugin publishes is the MCP advertised set: the fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes follow the plugin's `golem15.oauthserver.mcp_scopes` filter, which is that plugin's key and not a framework `mcp.*` key: unset means every declared member scope, a set filter means only those declared names, and an empty filter means the five fixed names only. An empty `wristband.Options.ScopesSupported` on the MCP flavour resolves to those five names; a non-empty list is used as given. OpenID Connect discovery stays `openid`, `profile` and `email`. A route reads who called a tool with `pact.MCPCallFrom`. - Prompts (D-28): prefixed and filtered like tools, required arguments checked before `Render` runs; a prompt never dispatches a route. A `Render` error is logged server-side and answered with the fixed JSON-RPC error `pier: the prompt could not be rendered`, so no database or provider error reaches the agent. A prompt declared with `Writes` (it directs tools that are not read-only) sits under the read/write ceiling and is hidden from an identity without `write`. -- A 401 carries `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource", scope="read"`; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read. +- A 401 carries `WWW-Authenticate: Bearer resource_metadata="/.well-known/oauth-protected-resource"`; there is no scope parameter, so a client requests the `scopes_supported` of the protected resource metadata; a browser request whose `Origin` is not in `mcp.allowed_origins` is refused with 403 before any body is read. - One audit line per tool call (`pier: tool call`: tool, guard, user id, admin id, client id, token kind, session prefix, status, duration) and one per session step (`pier: session`: `open`, `rehydrate`, `end`, `binding_refused`, `revoked`, `stream_closed`, `expired`), never arguments, bodies or tokens. A tool call served on the stateful path without a session logs the session `ephemeral`, one on the stateless path `stateless`. ## Usage diff --git a/modules/pier/pier.go b/modules/pier/pier.go index 4a2f990a..bf368fbe 100644 --- a/modules/pier/pier.go +++ b/modules/pier/pier.go @@ -247,8 +247,12 @@ func (p *Pier) bodyLimit(next http.Handler) http.Handler { // bearer verifies the bearer of every request afresh and stores the // resolved Identity on the request for the session router and the SDK -// handlers; a failure is a 401 carrying WWW-Authenticate with the -// protected resource metadata URL and the read scope. +// handlers. A failure is a 401 whose WWW-Authenticate names only the +// protected resource metadata URL. It carries no scope parameter, so a +// client requests the scopes the metadata document advertises and the +// authorization server's consent decides the grant; tools are then filtered +// per granted scope. No Scopes option is set: the SDK would answer 403 to +// every token lacking them. func (p *Pier) bearer(next http.Handler) http.Handler { verify := func(ctx context.Context, token string, r *http.Request) (*auth.TokenInfo, error) { ctx = towel.WithClientIP(ctx, p.clientIP(r)) @@ -263,13 +267,10 @@ func (p *Pier) bearer(next http.Handler) http.Handler { Extra: map[string]any{extraIdentity: id, extraCall: callInfoFrom(r)}, }, nil } - h := auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{ + return auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{ ResourceMetadataURL: p.prmURL, AllowMissingExpiration: true, })(next) - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - h.ServeHTTP(&challengeWriter{ResponseWriter: w}, r) - }) } // clientIP is the request's client address: the host's answer when it @@ -285,30 +286,6 @@ func (p *Pier) clientIP(r *http.Request) string { return r.RemoteAddr } -// challengeWriter completes the 401 challenge with scope="read", which the -// SDK middleware would otherwise enforce on every request. -type challengeWriter struct { - http.ResponseWriter -} - -func (c *challengeWriter) WriteHeader(status int) { - if status == http.StatusUnauthorized { - h := c.Header() - if v := h.Get("WWW-Authenticate"); v != "" && !strings.Contains(v, "scope=") { - h.Set("WWW-Authenticate", v+`, scope="read"`) - } - } - c.ResponseWriter.WriteHeader(status) -} - -func (c *challengeWriter) Flush() { - if f, ok := c.ResponseWriter.(http.Flusher); ok { - f.Flush() - } -} - -func (c *challengeWriter) Unwrap() http.ResponseWriter { return c.ResponseWriter } - // identityFromRequest reads the Identity the bearer middleware stored. func identityFromRequest(r *http.Request) (Identity, bool) { ti := auth.TokenInfoFromContext(r.Context()) diff --git a/modules/pier/tracer_smoke_test.go b/modules/pier/tracer_smoke_test.go index 1fef1580..a1028276 100644 --- a/modules/pier/tracer_smoke_test.go +++ b/modules/pier/tracer_smoke_test.go @@ -96,21 +96,31 @@ func TestPierTracerSmoke(t *testing.T) { t.Fatalf("text content = %+v", res.Content[0]) } - // A bad bearer is a 401 carrying the protected resource metadata challenge. + // A bad or missing bearer is a 401 whose challenge names only the + // protected resource metadata URL: no scope parameter, so a client + // requests the scopes the metadata document advertises. init := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"x","version":"1"}}}` - req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/mcp", bytes.NewBufferString(init)) - req.Header.Set("Authorization", "Bearer oas_at_bad") - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Accept", "application/json, text/event-stream") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatal(err) - } - _, _ = io.Copy(io.Discard, resp.Body) - _ = resp.Body.Close() - challenge := resp.Header.Get("WWW-Authenticate") - if resp.StatusCode != http.StatusUnauthorized || !strings.Contains(challenge, `resource_metadata="http://acme.test/.well-known/oauth-protected-resource/mcp"`) || !strings.Contains(challenge, `scope="read"`) { - t.Fatalf("bad bearer = %d %q", resp.StatusCode, challenge) + const wantChallenge = `Bearer resource_metadata="http://acme.test/.well-known/oauth-protected-resource/mcp"` + for _, tc := range []struct{ name, authorization string }{ + {"bad bearer", "Bearer oas_at_bad"}, + {"missing bearer", ""}, + } { + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/mcp", bytes.NewBufferString(init)) + if tc.authorization != "" { + req.Header.Set("Authorization", tc.authorization) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + challenge := resp.Header.Get("WWW-Authenticate") + if resp.StatusCode != http.StatusUnauthorized || challenge != wantChallenge || strings.Contains(challenge, "scope=") { + t.Fatalf("%s = %d %q, want 401 %q", tc.name, resp.StatusCode, challenge, wantChallenge) + } } // The same tool declared twice fails Mount naming the exposed name.