feat(mcp): delegated per-user token resolution for type=user (#317)#327
Conversation
#326 landed the type=user resolver as a pure stub — every call returned ErrNoToken. This makes it actually resolve a per-REQUESTING-USER token from the platform token endpoint (§19 delegated contract), the token-acquisition core of the delegated path. - `delegatedTokenSource`: POSTs `{server, subject}` to the platform token endpoint and caches the access token PER SUBJECT, so distinct users never share a token. The lock is not held across the network fetch, so a slow fetch for user A never blocks user B (the multi-user path is the point of #317). A platform 401/403/404 → ErrNoToken (auth-required), so a user without a grant stays lazy/non-blocking. Refresh token never reaches the agent (invariant 8). - `buildAuthFn` type=user: reads the requesting user from `auth.IdentityFromContext(ctx)` (email preferred, then user id) and resolves that subject's token. authFn runs per-request with the request ctx, so a shared connection carries per-user tokens on the call path. No user in ctx → ErrNoToken (lazy; never at startup). - `NewServer` type=user now requires the top-level platform block (it resolves via the platform endpoint), same as type=platform. - Extracted `doPlatformTokenRequest` shared by the agent-principal and delegated sources; the agent-principal path (#326) is behavior-preserved. Docs: configuration.md gains a "Managed identity (platform / user)" section — #326 shipped both types without config docs. Scope: this is the token-acquisition layer. The per-user CONNECTION lifecycle (lazy establishment so `initialize` runs under a user's session) and carrying the raw OIDC assertion for a broker-side ID-JAG exchange are the follow-ons; the platform's vaulted-3LO path resolves on subject alone today. Tests: per-subject fetch+cache (distinct users → distinct tokens, same user cached), platform-403 → ErrNoToken, buildAuthFn resolves subject from ctx / lazy without a user. Updated #326's rules test for the new platform-block requirement on type=user.
initializ-mk
left a comment
There was a problem hiding this comment.
Review: delegated per-user token resolution for type=user
This is the highest-risk area of the MCP-OAuth series — multi-user token resolution, where a cross-user token leak would be the worst outcome — and I traced the security-critical invariants; they hold:
- Per-subject caching is correct.
cache map[string]cachedTokenis keyed by subject, guarded byd.muon every read/write (no concurrent-map hazard), andcachedTokenis a value type so the fast-path copy doesn't alias. There is no path by which user A's fetch stores under, or serves to, user B. - The subject comes from a trusted source, not a user-controllable field.
delegatedSubjectreadsauth.IdentityFromContext(ctx)— the identity the auth middleware set from the verified bearer/OIDC token — email preferred, then uid. A requesting user can't spoof another's subject because it's the authenticated identity, not anything in the MCP call. - Fail-closed throughout: empty subject →
ErrNoToken(no empty-subject fetch), no user in ctx →ErrNoToken(lazy, never at startup), platform 401/403/404 →ErrNoToken(awaiting consent).required: trueis rejected fortype: user. - Invariant 8 held structurally:
doPlatformTokenRequestparses onlyaccess_token/expires_in— a refresh_token can't reach the agent even if the platform sends one. - Concurrency pattern is right: the lock is released across the network fetch, so a slow fetch for user A never blocks user B — the whole point of #317.
- Refactor is behavior-preserving: the extracted
doPlatformTokenRequestreturns the raw non-200 status so each caller classifies it — agent-principal still maps any non-200 →ErrProtocolError(unchanged), delegated maps 401/403/404 →ErrNoToken.
Good test coverage (distinct users → distinct tokens, same user cached to one fetch, 403 → ErrNoToken, lazy-without-user). CI green. No blockers — one docs finding, then minors.
1. Medium (docs, isolation-boundary clarity): "each user gets their own token" omits the connection-sharing caveat
The operator docs state delegated type: user gives "each user their own token (cached per subject)" — but the PR's own honest scope note says the per-user connection lifecycle is a follow-up: MCP initialize runs at connection setup, and today the per-request token rides a shared connection. So this PR delivers per-user token-level isolation, not yet connection-level. Against a session-stateful MCP server (one that binds identity at initialize rather than re-authorizing per call), swapping the per-call token may not re-scope the session — all users could act as the connection-establisher. The scope note is in the PR body, but an operator reads the docs, where "each user gets their own token" reads as complete isolation. Recommend a docs caveat: this resolves a per-user token per call; the underlying MCP connection is currently shared, and full per-subject connection isolation is a follow-up — verify a session-stateful server re-authorizes per call before relying on it for isolation. Inline.
2. Minor: the per-subject cache is unbounded and never evicts
d.cache[subject] is added per distinct user and never removed — expired entries are checked on read but never deleted, and there's no size bound. A long-running agent serving many distinct users grows the map with distinct-subject cardinality (each entry a token string + timestamp). For a high-user-count deployment that's a slow memory leak. Recommend evicting on expiry (delete the stale entry when the fast-path check finds it expired) or an LRU/TTL bound. Inline.
3. Minor: no per-subject singleflight → first-request stampede
Unlike the #325 client_credentials path (which singleflights), concurrent first requests for the same subject each miss the cache and each fire a platform fetch (last-write-wins on the cache). Correct (all return that user's token), just N redundant platform calls on a cold-cache burst for one user. Per-subject singleflight would collapse them; acceptable to defer, worth noting.
Nit: the subject (email) appears in error strings
ErrNoToken/ErrProtocolError messages embed subject %q (the user's email). If these errors are logged, user emails land in logs — a mild PII surface depending on the redaction posture. Consider logging the uid or a hash where the email isn't needed for the operator action.
Verdict
Merge-ready. The token-acquisition core is correct and secure — per-subject isolation, trusted subject, fail-closed, invariant 8 structural. Finding 1 is the one I'd fold in: it's a two-line docs caveat, and it's the difference between an operator understanding this as token-level isolation (true today) vs full per-user isolation (the follow-up). Findings 2–3 and the nit are cache-lifecycle and diagnostics follow-ups.
| is valid. | ||
| - **`type: user`** (#317) — delegated. Forge resolves the **requesting user's** | ||
| identity from the authenticated request and POSTs `{server: <ref>, subject}`, | ||
| so each user gets **their own** token (cached per subject). It is **inherently |
There was a problem hiding this comment.
Medium (isolation-boundary clarity): "each user gets their own token" needs the connection-sharing caveat.
This is true at the token level — but the PR's own scope note says the per-user connection lifecycle is a follow-up: MCP initialize runs at connection setup, and today the per-request token rides a shared connection. Against a session-stateful MCP server (identity bound at initialize, not re-checked per call), the per-call token swap may not re-scope the session, so users could effectively act as the connection-establisher.
An operator reads the docs, not the PR body, and "each user gets their own token" reads as complete isolation. Suggest adding: "This resolves a per-user token on each call; the underlying MCP connection is currently shared. Full per-subject connection isolation is a follow-up — for a session-stateful MCP server, confirm it re-authorizes per call before relying on this for isolation." Keeps operators from over-trusting isolation this slice doesn't yet fully provide.
There was a problem hiding this comment.
Added the caveat. The docs now spell out that this is per-user token-level isolation per call, the connection is shared, and a session-stateful server may not re-scope on the per-call token — confirm per-request re-auth before relying on it for hard isolation. This is exactly what the per-user connection lifecycle follow-up closes (starting on it next).
| client *http.Client | ||
|
|
||
| mu sync.Mutex | ||
| cache map[string]cachedToken // key: subject |
There was a problem hiding this comment.
Minor: this per-subject cache is unbounded and never evicts. Entries are added per distinct user and only read-checked for expiry — expired entries are never deleted, and there's no size cap. A long-running agent serving many distinct users grows the map with distinct-subject cardinality (a slow leak). Cheap fix: delete(d.cache, subject) when the fast-path check finds an entry expired, and/or a TTL/LRU bound. The token strings are also sensitive, so not holding stale ones longer than needed is a mild plus.
There was a problem hiding this comment.
Bounded it: evict the stale entry on the fast-path read, and sweep expired entries on each write — so one-shot users can't grow the map unbounded and tokens aren't held past use, with no background goroutine. Per-subject singleflight (finding 3) I've left as the noted follow-up.
Finding 1 (docs): add the connection-sharing caveat. type=user resolves a per-user token per CALL, but the underlying MCP connection is still shared (per-subject connection isolation is the follow-up). A stateless server that re-authorizes per request honors the per-call token; a session-stateful server that binds identity at initialize may not, so the docs now say to confirm per-request re-auth before relying on this for hard isolation. Finding 2: bound the per-subject cache. Evict the stale entry on the fast-path read, and opportunistically sweep expired entries on each write — so the map can't grow unbounded with one-shot users' stale tokens, and sensitive tokens aren't held past use. No background goroutine. Findings 3 (per-subject singleflight) + the email-in-errors nit noted on the threads as follow-ups; the "no grant yet" message keeps the subject because the operator needs to know who must consent.
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review of 6582f78 — both findings resolved, CI green
Finding 1 (docs isolation caveat) — landed exactly as recommended, arguably clearer. The new initialize → may not re-scope), telling operators to confirm per-request re-auth before relying on type: user for hard isolation. That closes the over-trust gap: an operator now knows this is token-level isolation and exactly what to verify before treating it as full isolation.
Finding 2 (unbounded cache) — bounded correctly, with a sound design choice. The fast-path read now deletes a stale entry, and each write opportunistically sweeps expired entries before inserting — bounding the map to live entries, no background goroutine, and the side benefit of not holding sensitive tokens past expiry.
One characteristic worth a mental note (not a blocker): the on-write sweep is O(n) under the lock, so a cold-start burst of N distinct first-time users does ~O(N²) cumulative map work across brief critical sections. Negligible at realistic per-agent user cardinality (the network fetches parallelize outside the lock); only worth revisiting (heap-by-expiry / periodic sweep) at extreme scale — tens of thousands of concurrent distinct users on one agent. The deliberate no-goroutine simplicity is the right call for the expected scale.
Findings 3 + the email nit — deferred with sound reasoning. Per-subject singleflight is a noted follow-up (I'd flagged it as deferrable). Keeping the subject email in the "no grant yet" error is a deliberate, correct call — that error is actionable precisely because it names the user who must consent. Good judgment on which nit to accept vs. justify.
Verdict
Merge-ready. The token-acquisition core was already correct and secure (per-subject isolation, trusted subject, fail-closed, invariant 8 structural), the cache is now simply bounded, and — the finding that mattered most — the docs draw the token-level-vs-connection-level isolation boundary honestly so operators don't over-trust this slice against a session-stateful server. All 10 checks green.
…fecycle (#317) First increment of #317's per-user CONNECTION lifecycle (design recorded on the issue). #327 gave per-user *tokens* on a shared connection; a session-stateful MCP server binds identity at `initialize`, so full per-user isolation needs one *connection* per user. `subjectConnPool` maintains a lazily-established MCP `Client` per requesting-user subject (keyed off `auth.IdentityFromContext`), and the `ClientResolver` seam it implements is how the tool-call path will route a call to the right user's connection. - Lazy: nothing connects until a user's first call; no user in ctx → ErrNoToken (never at startup). - The connect ctx carries the subject's identity (so authFn resolves THEIR token at initialize) but is decoupled from the caller's cancellation — background-derived + a hard timeout — so one caller's cancel can't tear down the shared establish (the B2 lesson). - Single-flighted per subject; distinct subjects never block each other. - Evict (drop+close on a connection error → next call re-establishes) and Close (tear down all) for lifecycle. Self-contained and heavily tested (per-subject lazy establish + reuse, no-subject → ErrNoToken, single-flight, evict-reconnects, close-teardown, identity-preserved-on-connect-ctx). No hot-path wiring yet — that's the next increment (the ClientResolver routing seam), then materialized tool schemas so type=user servers register without a startup connection.
…nnections live (#317 increment 3) Increment 3 makes the per-user connection lifecycle work end-to-end. A type=user server now registers platform-materialized tool schemas WITHOUT a startup connection and reaches Ready; each user's first call establishes their OWN connection lazily, whose initialize runs under their token — so identity is bound at the connection level, closing the shared-connection caveat from #327. - Config: `tools.schemas` (types.MCPToolSchema) — materialized tool descriptors (name + description + input_schema-as-YAML). Lives in types to avoid an mcp import cycle; the mcp package converts + validates them the same way tools/list results are validated. - Server: `type=user` gets a subjectConnPool (increment 1) whose connect = factory + Initialize under the requesting user's ctx. Run takes a new `runMaterialized` path (Configured → Ready, no eager connect); the pool is the server's per-call ClientResolver; Close tears the pool down. New Configured → Ready transition for the materialized path. - Manager.Tools(): includes a pool server (which has no shared client) and hands its ToolHandle the pool resolver — the seam from increment 2. Tests: schema conversion + validation; the full vertical under -race — a type=user server registers tools with NO startup connection, then a call for alice@corp.com establishes a per-user connection whose initialize carries `Bearer tok-alice@corp.com` (the per-user platform token). Docs: configuration.md materialized-schemas section + updated per-user-connection isolation note.
…fecycle (#317) First increment of #317's per-user CONNECTION lifecycle (design recorded on the issue). #327 gave per-user *tokens* on a shared connection; a session-stateful MCP server binds identity at `initialize`, so full per-user isolation needs one *connection* per user. `subjectConnPool` maintains a lazily-established MCP `Client` per requesting-user subject (keyed off `auth.IdentityFromContext`), and the `ClientResolver` seam it implements is how the tool-call path will route a call to the right user's connection. - Lazy: nothing connects until a user's first call; no user in ctx → ErrNoToken (never at startup). - The connect ctx carries the subject's identity (so authFn resolves THEIR token at initialize) but is decoupled from the caller's cancellation — background-derived + a hard timeout — so one caller's cancel can't tear down the shared establish (the B2 lesson). - Single-flighted per subject; distinct subjects never block each other. - Evict (drop+close on a connection error → next call re-establishes) and Close (tear down all) for lifecycle. Self-contained and heavily tested (per-subject lazy establish + reuse, no-subject → ErrNoToken, single-flight, evict-reconnects, close-teardown, identity-preserved-on-connect-ctx). No hot-path wiring yet — that's the next increment (the ClientResolver routing seam), then materialized tool schemas so type=user servers register without a startup connection.
…nnections live (#317 increment 3) Increment 3 makes the per-user connection lifecycle work end-to-end. A type=user server now registers platform-materialized tool schemas WITHOUT a startup connection and reaches Ready; each user's first call establishes their OWN connection lazily, whose initialize runs under their token — so identity is bound at the connection level, closing the shared-connection caveat from #327. - Config: `tools.schemas` (types.MCPToolSchema) — materialized tool descriptors (name + description + input_schema-as-YAML). Lives in types to avoid an mcp import cycle; the mcp package converts + validates them the same way tools/list results are validated. - Server: `type=user` gets a subjectConnPool (increment 1) whose connect = factory + Initialize under the requesting user's ctx. Run takes a new `runMaterialized` path (Configured → Ready, no eager connect); the pool is the server's per-call ClientResolver; Close tears the pool down. New Configured → Ready transition for the materialized path. - Manager.Tools(): includes a pool server (which has no shared client) and hands its ToolHandle the pool resolver — the seam from increment 2. Tests: schema conversion + validation; the full vertical under -race — a type=user server registers tools with NO startup connection, then a call for alice@corp.com establishes a per-user connection whose initialize carries `Bearer tok-alice@corp.com` (the per-user platform token). Docs: configuration.md materialized-schemas section + updated per-user-connection isolation note.
Delegated per-user token resolution for
auth.type: user— the token-acquisition core of #317's delegated path. Builds directly on #326 (which landedtype: useras a pure stub that always returnedErrNoToken).What
type: usernow resolves a per-requesting-user access token from the platform token endpoint (§19 delegated contract), instead of always failing.delegatedTokenSource— POSTs{server, subject}and caches the access token per subject, so distinct users never share a token. The lock is not held across the network fetch, so a slow fetch for user A never blocks user B (the multi-user path is the whole point of Epic: runtime per-user, ephemeral per-session MCP OAuth (interactive login via Slack / A2A UI) #317). A platform401/403/404→ErrNoToken(auth-required), so a user without a grant stays lazy/non-blocking. The refresh token never reaches the agent (invariant 8).buildAuthFntype: user— reads the requesting user fromauth.IdentityFromContext(ctx)(email preferred, then user id) and resolves that subject's token.authFnruns per-request with the request ctx (verified:MCPTool.Execute(ctx)→CallTool(ctx)→ transport →authFn(ctx)), so a shared connection carries per-user tokens on the call path. No user in ctx →ErrNoToken(lazy; never at startup).NewServertype: usernow requires the top-levelplatformblock (it resolves via the platform endpoint), same astype: platform.doPlatformTokenRequestshared by the agent-principal (feat(mcp): platform + user auth resolvers — reads via platform, writes via user (§19) #326) and delegated sources; the agent-principal path is behavior-preserved.How it fits the seam
Scope (honest)
This is the token-acquisition layer. Two follow-ons, called out so nobody expects them here:
initializeruns at connection setup; atype: userconnection must establish lazily under a user's session (there's no user at startup), and ideally per-subject. Today the per-request token works on a connection whose ctx carries a user; the lazy/per-user connection establishment is the next slice.user_assertion) for a broker-side ID-JAG exchange —auth.Identitycarries only parsed claims today. The platform's vaulted-3LO path resolves onsubjectalone, so this isn't needed yet; carrying the raw assertion is Epic: runtime per-user, ephemeral per-session MCP OAuth (interactive login via Slack / A2A UI) #317 item 1's second half.Docs
#326 shipped
type: platform/type: userwithout config docs — added a "Managed identity (platform / user)" section (theplatformblock, both types, the lazy/requiredrules, egress).Tests
Per-subject fetch+cache (distinct users → distinct tokens; same user cached — one fetch each), platform-
403→ErrNoToken,buildAuthFnresolves the subject from ctx / lazy without a user. Updated #326's rules test for the new platform-block requirement ontype: user. Fullforge-core+forge-cli/runtimesuites pass; lint clean.