docs(32.1-01): document the MCP scope list and call context
- READMEs and service pages name the new pact and wristband identifiers - an empty MCP scope list resolves to the five fixed names; OIDC discovery is unchanged
This commit is contained in:
@@ -143,7 +143,7 @@ To protect routes, return that middleware from `pact.HasMiddleware` under a name
|
||||
|
||||
A guard that resolves more than a user, such as an API token record, implements `bouncer.CredentialGuard`; the middleware stores that record too, and handlers read it with `bouncer.Credential`.
|
||||
|
||||
A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` and cabana's `backend` middleware (the admin API and every route behind `backend`) honour a marker for the `backend` guard only when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route.
|
||||
A framework dispatcher that calls a route in-process on behalf of a caller it has already authenticated (the MCP module running a tool through the plugin's own route) does not mint a token for the call. It attaches a `bouncer.Dispatch` marker to the request context with `bouncer.WithDispatch`: the pre-resolved principal, its optional credential and the exact guard names that may honour it. The marker is context-only. Nothing reads a header, cookie or query string for it, so an HTTP request can never carry one, and only a dispatcher inside the framework sets it; plugins never do. `bouncer.Registry.Middleware` honours a marker only for a guard name the marker lists (`bouncer.Dispatched`) and then skips the guard, which covers every guard registered through the registry, such as a plugin's API token guard. `cabana.ResolveBackend` and cabana's `backend` middleware (the admin API and every route behind `backend`) honour a marker for the `backend` guard only when its principal is a backend one; a frontend marker is refused as unauthenticated, and a scoped principal is still checked against the cover codes. A marker for one guard has no effect on a route behind another. The one dispatcher in the framework is the [MCP endpoint](mcp.md): pier resolves the caller from the bearer of every MCP request, asks the tool's guard provider for the principal and sets the marker on the in-process call, so the route sees the same principal an HTTP request with that user's token would carry, while the bearer itself never reaches the route. Immediately before the marker it stores a `pact.MCPCall` with `pact.WithMCPCall`. A route reads that call with `pact.MCPCallFrom`; any other request has none. `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise. `pact.MCPSubject.Scopes` is the grant, not an implied expansion, and the read/write ceiling is unchanged. The scopes an MCP authorization server advertises are `pact.MCPAdvertisedScopes`, which pier copies when `pact.MCPAdvertisedScopes.MCPScopesSupported` is non-empty and otherwise replaces with `pier.PRMScopes`; OpenID Connect discovery stays `openid`, `profile` and `email`.
|
||||
|
||||
### Accepting admin tokens on plugin routes
|
||||
|
||||
|
||||
@@ -128,7 +128,9 @@ What pier does with it at boot:
|
||||
|
||||
Every request is authorized afresh from its bearer; a session never replaces the bearer. An OAuth access token of the application's authorization server (`oas_at_…`) is verified through the `pact.MCPTokenVerifier` the OAuth server plugin publishes: the grant's user is the frontend subject, the grant's scopes are the ceiling. With the `backend` scope and a link the published `pact.MCPLinkedBackendResolver` confirms, the linked administrator is the backend subject, resolved on every request (cached for at most five seconds on one instance and dropped at once by a link event), so unlinking takes effect on the next call with no token surgery. The verifier also reports which administrator the user consented to (`pact.MCPGrant.BackendUserID`); while the link names anyone else, or when the grant records none, the grant has no backend subject, so a link that changes after the consent never re-targets it. The backend principal keeps the administrator's real permissions, superuser included, under the grant's ceiling.
|
||||
|
||||
The `read` and `write` scopes are a ceiling: a grant with only `read` sees tools whose read-only hint is true, `write` unlocks the rest, and permissions always filter on top. The `ai` and `offline_access` scopes play no part in it.
|
||||
The `read` and `write` scopes are a ceiling, and that ceiling is unchanged: a grant with only `read` sees tools whose read-only hint is true, `write` unlocks the rest, and permissions always filter on top. The `ai` and `offline_access` scopes play no part in it. `pact.MCPSubject.Scopes` on a call is the grant, not an implied expansion of `pact.FrontendTokenScope.Implies`.
|
||||
|
||||
Every tool call stores a `pact.MCPCall` with `pact.WithMCPCall` immediately before the dispatch marker. A route reads it with `pact.MCPCallFrom`; a request pier did not dispatch has none. `pact.MCPCall.Subject` is the subject resolved for that tool's guard, `pact.MCPCall.Tool` is the exposed tool name and `pact.MCPCall.SessionID` is the MCP session id, empty on the stateless path. `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise.
|
||||
|
||||
With `mcp.allow_personal_tokens` true, personal API tokens also open sessions, each capped by the token's own scope exactly as over HTTP: an administrator's `g15_` token yields backend tools only, with the token's scope limit computed per dispatched route method; a frontend user's token, resolved by the user plugin's published `pact.MCPPersonalTokenResolver`, yields frontend tools only. An application has one such resolver; a subject it answers must carry the token's `TokenID`, which keys the session binding, and a subject without one is refused. With the flag off (the default), any bearer that is not an OAuth token is refused without a lookup.
|
||||
|
||||
@@ -136,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<mcp.path>`: the resource, the authorization server (the verifier's resource origin, else `app.url`), the supported scopes (`read`, `write`, `ai`, `offline_access`, `backend`) and the bearer method. 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="…", 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.
|
||||
|
||||
## Security notes
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@ func newServer() *wristband.Server {
|
||||
> [!WARNING]
|
||||
> Always set `wristband.Options.Resource` for your deployment. `wristband.DefaultOptions` leaves it empty, and while it is empty every authorization request that sends a `resource` parameter is refused with `invalid_target`.
|
||||
|
||||
The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. `wristband.Options.ScopesSupported` is both the advertised list and the authorize allow-list: a client may request exactly those scopes (the example above drops `ai`, so a request for it is `invalid_scope`; a host that appends a scope of its own accepts it), and an empty list keeps the default `read`, `write`, `ai` and `offline_access`. The metadata document serves the configured values:
|
||||
The defaults cover the rest: pending requests and codes live 10 minutes, access tokens 1 hour and refresh tokens 30 days, at most 200 unrevoked clients may register, and registration bodies are capped at 64 KiB. `wristband.Options.ScopesSupported` is both the advertised list and the authorize allow-list, one resolved slice. A non-empty list is used as given: the example above drops `ai`, so a request for it is `invalid_scope`, and `wristband.DefaultOptions` (`read`, `write`, `ai`, `offline_access`) still refuses `backend`. An empty or nil list on the MCP flavour resolves to `read`, `write`, `ai`, `offline_access` and `backend`. OpenID Connect discovery stays `openid`, `profile` and `email`.
|
||||
|
||||
The MCP advertised set is that resolved list. The fixed names `read`, `write`, `ai`, `offline_access` and `backend` are always on it. Declared member scopes (`pact.FrontendTokenScope`, including `pact.FrontendTokenScope.Implies`) come from the published `pact.MemberTokenScopeCatalog` and are filtered by the OAuth server plugin's key `golem15.oauthserver.mcp_scopes`. That key is the plugin's, not a framework config 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. The plugin publishes the list as `pact.MCPAdvertisedScopes`. pier copies `pact.MCPAdvertisedScopes.MCPScopesSupported` when it is non-empty and otherwise keeps `pier.PRMScopes`. The read/write ceiling is unchanged. A route the endpoint runs reads the caller with `pact.MCPCallFrom`: `pact.MCPSubject.TokenID` is a member personal-token id only when `pact.MCPSubject.ClientID` is empty, and the OAuth access-token row otherwise, and `pact.MCPSubject.Scopes` is the grant, not an implied expansion. The metadata document serves the configured values:
|
||||
|
||||
```go src=modules/wristband/example_test.go#ExampleServer_Metadata
|
||||
srv := newServer()
|
||||
@@ -104,7 +106,7 @@ The metadata document advertises these paths under the issuer, so mount them at
|
||||
|
||||
The authorization endpoint sends the browser to `<issuer>/connect?request=<id>`. That page belongs to the application: it signs the user in, shows what the client asks for and posts the decision to the application's own consent handler, which calls:
|
||||
|
||||
- `wristband.Server.PendingRequest` to read what to show, as a `wristband.PendingRequestView`;
|
||||
- `wristband.Server.PendingRequest` to read what to show, as a `wristband.PendingRequestView`, including `wristband.PendingRequestView.OfflineAccess` (the pending row's flag; `offline_access` is not a scope on the view and `wristband.Server.IssueCode` is unchanged);
|
||||
- `wristband.Server.IssueCode` to grant, which returns the redirect URL carrying the code, `iss` and `state`;
|
||||
- `wristband.Server.DenyPending` to refuse, which returns the `access_denied` redirect URL.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user