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:
@@ -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<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`.
|
||||
- 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`.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user