fix(pier): MCP 401 challenge names only resource_metadata

- drop the response-writer wrapper that appended scope="read" to every 401
- the SDK handler now gets the original writer; RequireBearerToken still sets no Scopes
- TestPierTracerSmoke pins the exact challenge for a bad and a missing bearer
- README and docs/services/mcp.md describe the header without a scope parameter
This commit is contained in:
Jakub Zych
2026-11-06 15:09:10 +01:00
parent ef47baf0a1
commit 609caa4f3f
4 changed files with 33 additions and 46 deletions

View File

@@ -138,7 +138,7 @@ Sessions follow the streamable HTTP transport: `initialize` opens one, `Mcp-Sess
## Protected resource metadata ## 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<mcp.path>`: 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<mcp.path>`: 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 ## Security notes

View File

@@ -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. - 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<mcp.path>` 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`. - Protected resource metadata (RFC 9728): `GET /.well-known/oauth-protected-resource` and `GET /.well-known/oauth-protected-resource<mcp.path>` 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`. - 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="<app.url>/.well-known/oauth-protected-resource<mcp.path>", 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="<app.url>/.well-known/oauth-protected-resource<mcp.path>"`; 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`. - 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 ## Usage

View File

@@ -247,8 +247,12 @@ func (p *Pier) bodyLimit(next http.Handler) http.Handler {
// bearer verifies the bearer of every request afresh and stores the // bearer verifies the bearer of every request afresh and stores the
// resolved Identity on the request for the session router and the SDK // resolved Identity on the request for the session router and the SDK
// handlers; a failure is a 401 carrying WWW-Authenticate with the // handlers. A failure is a 401 whose WWW-Authenticate names only the
// protected resource metadata URL and the read scope. // 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 { func (p *Pier) bearer(next http.Handler) http.Handler {
verify := func(ctx context.Context, token string, r *http.Request) (*auth.TokenInfo, error) { verify := func(ctx context.Context, token string, r *http.Request) (*auth.TokenInfo, error) {
ctx = towel.WithClientIP(ctx, p.clientIP(r)) 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)}, Extra: map[string]any{extraIdentity: id, extraCall: callInfoFrom(r)},
}, nil }, nil
} }
h := auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{ return auth.RequireBearerToken(verify, &auth.RequireBearerTokenOptions{
ResourceMetadataURL: p.prmURL, ResourceMetadataURL: p.prmURL,
AllowMissingExpiration: true, AllowMissingExpiration: true,
})(next) })(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 // 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 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. // identityFromRequest reads the Identity the bearer middleware stored.
func identityFromRequest(r *http.Request) (Identity, bool) { func identityFromRequest(r *http.Request) (Identity, bool) {
ti := auth.TokenInfoFromContext(r.Context()) ti := auth.TokenInfoFromContext(r.Context())

View File

@@ -96,10 +96,19 @@ func TestPierTracerSmoke(t *testing.T) {
t.Fatalf("text content = %+v", res.Content[0]) 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"}}}` init := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"x","version":"1"}}}`
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)) req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/mcp", bytes.NewBufferString(init))
req.Header.Set("Authorization", "Bearer oas_at_bad") if tc.authorization != "" {
req.Header.Set("Authorization", tc.authorization)
}
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("Accept", "application/json, text/event-stream")
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
@@ -109,8 +118,9 @@ func TestPierTracerSmoke(t *testing.T) {
_, _ = io.Copy(io.Discard, resp.Body) _, _ = io.Copy(io.Discard, resp.Body)
_ = resp.Body.Close() _ = resp.Body.Close()
challenge := resp.Header.Get("WWW-Authenticate") 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"`) { if resp.StatusCode != http.StatusUnauthorized || challenge != wantChallenge || strings.Contains(challenge, "scope=") {
t.Fatalf("bad bearer = %d %q", resp.StatusCode, challenge) 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. // The same tool declared twice fails Mount naming the exposed name.