feat(mcp): auth-required gate for delegated MCP consent (#330)#331
feat(mcp): auth-required gate for delegated MCP consent (#330)#331initializ-mk wants to merge 6 commits into
Conversation
…c 1)
The generalized DEFER-style pause-and-resume primitive behind the
auth-required gate. Sibling of deferpolicy (R4c): where DEFER parks
until a human approves an action, authgate parks until a user completes
OAuth consent and the platform holds a grant.
Keyed by {subject, server} (not taskID): a grant is per user per server,
so one consent fans out to resume EVERY parked call of that user's for
that server, across tasks. First Await is told to deliver the prompt;
joiners attach silently — one prompt per user, not one per call. Resolve
broadcasts (close(done)) to all waiters; a timeout auto-fails; the last
waiter to cancel tears the gate down (DecisionCanceled) instead of
idling to timeout.
The gate never mints or sees a token — it only unblocks the executor to
re-resolve through the normal delegated path (delegation follows
authorization, design-tool-registry.md §18.5).
Tests cover fan-out, key independence, timeout, idempotent/racing
resolve, last-waiter + one-of-many cancellation, and a -race storm.
Wire the authgate seam into MCPTool.Execute. When resolving the per-user connection returns ErrNoToken (no grant yet for the requesting user), the tool no longer fails — it calls AuthGate.Await, which parks the executor until the user consents, then re-resolves (the delegated path now finds the grant) and proceeds. A gate that gives up (timeout/cancel) fails the call as before, so the pause stays bounded. AuthGate is a narrow seam (Await(ctx, server) error) implemented in the runtime, which owns the engine + consent delivery; nil disables gating, preserving the pre-#330 behavior for non-delegated servers and tests. Only ErrNoToken is gated — other resolver errors fail immediately.
Wire the authgate engine into the runtime as the concrete AuthGate the
MCP tool adapter calls:
- mcpAuthGate bridges the pure engine to runtime concerns the engine
deliberately doesn't know: subject extraction (email-preferred, from
auth.IdentityFromContext), task-status flip to auth-required while
parked (restored on resume), consent delivery, and audit. On a granted
resume it returns nil (caller re-resolves); on timeout/cancel it
returns an ErrNoToken-wrapping error so the adapter still classifies
it 'no_token' — the same reason code as the failure it replaced.
- POST /mcp/consent — the resume signal the platform (managed) or an
operator (standalone) calls when a grant lands, waking every call
parked on {subject, server}. Mirrors POST /tasks/{id}/decisions status
codes (400/404/409/200). Carries NO token — a pure 're-resolve' signal;
granted:false is an explicit refusal that fails the call fast.
- ConsentDeliverer seam (+ SetConsentDeliverer): the managed platform
injects delivery; standalone leaves it nil until the loopback resolver
(inc 4). Best-effort, mirrors DeferralNotifier.
- Three audit events: mcp_auth_required / _resolved / _timeout.
Engine is built alongside the MCP manager and passed to every MCPTool
(harmless for non-delegated servers — only an ErrNoToken from the
per-user resolver trips it). Tests (-race): park→resume, timeout→
ErrNoToken, no-subject fail-fast, status trail, endpoint status codes,
explicit refusal.
… inc 4)
The standalone-mode consent loop (design-tool-registry.md §18.4). In
managed mode the platform hosts the callback and only signals via
POST /mcp/consent; in standalone mode Forge hosts its own loopback here.
- stateBinder: issues single-use, expiring OAuth 'state' values bound to
the {subject, server, session} that initiated the flow, and validates
them on callback. Consume enforces single-use (delete-on-read → replay
finds nothing) + expiry; Issue opportunistically sweeps stale entries.
- GET /mcp/oauth/callback: validates state, enforces the session match
(cross-session/leaked-state callbacks rejected), exchanges the code for
a token via the injected CallbackCompleter, and ONLY THEN resumes the
parked call — never resume before the grant exists (delegation follows
authorization). Exchange failure → 502, gate stays parked.
- CallbackCompleter seam (+ SetCallbackCompleter): the standalone
interactive resolver injects code→token exchange. nil ⇒ the callback is
not registered (managed hosts its own, never hands Forge a code).
Closes the '#317 state binding rejects cross-session/replayed/expired'
acceptance box. Tests (-race): issue/consume, replay, unknown/empty,
expiry, sweep, happy-path resume, cross-session reject (no exchange),
replay reject (single exchange), missing params, exchange-failure-no-resume.
Remaining for standalone: the Issue-side wiring (authorize-URL build +
prompt delivery via ConsentDeliverer) reuses oauth_flow.go — tracked as
the final standalone wire; managed mode is fully functional now.
Formalize the delegated (type=user) per-subject token cache as the SubjectTokenStore interface (§18.8 #2) so a managed broker can substitute a shared/durable impl — surviving restarts, shared across replicas — without the resolver or the agent changing. memSubjectTokenStore is the in-process default, carrying over the exact semantics that were inline in delegatedTokenSource: early-refresh skew, evict-on-stale-read (no sensitive token held past use), and an opportunistic sweep on Put so one-shot users can't grow the map unbounded (no background goroutine). Injected via PlatformSourceConfig.SubjectStore; nil ⇒ the default. The store holds only short-lived ACCESS tokens; refresh tokens never reach it (invariant 8). Behavior is unchanged — the existing delegated resolver tests pass untouched. Closes the '#317 session token store is a swappable interface' box.
…330) Add a 'Delegated consent — the auth-required gate' section to the MCP configuration doc: the park/resume flow, one-prompt-per-user keying, the two resume modes (managed POST /mcp/consent with no token vs standalone loopback callback with session-bound state), the never-resume-before-grant rule, and the swappable SubjectTokenStore. Reword the type: user summary from 'call fails auth-required' to 'call pauses on the auth-required gate'.
initializ-mk
left a comment
There was a problem hiding this comment.
Review: auth-required gate for delegated MCP consent (#330)
The last Forge-side piece of the #317 arc, and the security fundamentals are strong. I scrutinized the two highest-risk surfaces — the OAuth state/callback and the park/resume engine — and the core is right:
gateKeyis collision-proof:subject + "\x00" + server. NUL can't appear in an email or server name, so no delimiter-injection collision that would let one user's consent resolve another's gate. That's the exact composite-key hazard, defended correctly.- State binding is textbook: single-use (
deletebefore the expiry check, so a replay finds nothing), TTL-bounded, session-bound, with opportunistic sweep. Crypto-random viaoauth.GenerateState(). - Resume-ordering is correct: the callback exchanges the code and stores the token BEFORE
engine.Resolve— never resume before the grant exists; a failed exchange returnsBadGatewaywith no resume. - The gate never sees a token (it only unblocks re-resolution),
/mcp/consentcarries no token andgranted:falsefails fast, and onlyErrNoTokenis gated (narrow seam; nil gate → pre-#330 behavior). - Fan-out is clean: closed-channel broadcast,
resolved sync.Once, waiter-count teardown so an abandoned prompt doesn't idle to full timeout. /mcp/consentis correctly authenticated — it's not inDefaultSkipPaths, so the managed platform authenticates when it POSTs the resume signal. Right call.
CI fully green (-race). Two related findings, both on the standalone callback (inc 4):
1. Medium (latent-functional + security): the standalone callback must be auth-EXEMPT, but it's auth-wrapped
GET /mcp/oauth/callback is registered via RegisterHTTPHandler on the auth-wrapped mux, and it is not in auth.DefaultSkipPaths() (which lists only /.well-known/* and /healthz). But an OAuth callback is a browser redirect from the IdP — it carries no bearer token, so the auth middleware will 401 it before makeMCPCallbackHandler ever runs. The standalone consent flow can't complete.
It's masked today: the Issue-side wiring is follow-up, and the callback tests call the handler function directly, bypassing the middleware — so nothing exercises the real request path. But it's a hard break the moment standalone lands. The callback's authenticity comes from the state binding, not bearer auth, so it must be skip-listed (add GET /mcp/oauth/callback to the exempt set). Inline.
2. Medium (security, ties to #1): the empty-session skip is a CSRF gap on an unauthenticated, network-exposed callback
Once #1 is fixed (callback unauthenticated, as it must be), the state binding is the only security — and if b.session != "" skips the cross-session guard when the session is empty, leaving only single-use + expiry. The callback is registered on the main server (bound to cfg.Host, potentially 0.0.0.0 in a container), not loopback. So an empty-session state on a network-reachable, unauthenticated callback can be completed by anyone who obtains the state within the 10-min window. The "CLI loopback degradation" rationale only holds if the endpoint is actually loopback-bound. Recommend: require a non-empty session (reject empty) unless the callback is provably loopback-bound — so an unauthenticated, network-exposed callback is never guardable by single-use+expiry alone. Best designed in now, before the Issue-side issues real state. Inline.
3. Minor (test gap): the callback tests bypass the auth middleware
Because the tests call makeMCPCallbackHandler's returned func directly, they can't catch finding 1 (the 401). A test that drives GET /mcp/oauth/callback through the registered server with the auth middleware would surface it — worth adding alongside the skip-list fix so the exemption is pinned.
Verdict
Merge-ready for the managed path — managed consent is fully functional and correctly authenticated, the gate/state/keying fundamentals are sound, and CI is green. Findings 1–2 are about the standalone callback, which the PR itself scopes as partially follow-up — but I'd fix them (or explicitly defer with a tracking note) before the standalone Issue-side lands, because #1 makes standalone non-functional and #2 makes its only security control skippable. Both are small: a skip-list entry and a non-empty-session requirement. Excellent close to the #317 epic otherwise — the NUL-keyed gate and resume-after-exchange ordering are exactly right.
| if r.stateBinder == nil { | ||
| r.stateBinder = newStateBinder(defaultStateTTL) | ||
| } | ||
| srv.RegisterHTTPHandler("GET /mcp/oauth/callback", |
There was a problem hiding this comment.
Medium (latent-functional): this callback must be auth-EXEMPT, but it's auth-wrapped.
It registers on the auth-wrapped mux and is not in auth.DefaultSkipPaths() (only /.well-known/* + /healthz are). An OAuth callback is a browser redirect from the IdP — no bearer token — so the auth middleware 401s it before this handler runs, and the standalone flow can't complete. Masked today only because the Issue-side is follow-up and the tests call the handler directly (bypassing the middleware).
The callback's authenticity is the state binding, not bearer auth, so it must be skip-listed: add GET /mcp/oauth/callback to the exempt set. Note the contrast with POST /mcp/consent, which correctly stays auth-wrapped (the managed platform authenticates) — these two endpoints have opposite auth requirements, and only the callback needs exempting.
| // Cross-session guard: the callback must land in the session that | ||
| // started the flow. A leaked/replayed state used from another | ||
| // session is rejected. | ||
| if b.session != "" { |
There was a problem hiding this comment.
Medium (security, pairs with the auth-exempt finding): don't skip the cross-session guard on a network-exposed callback.
Since the callback is (correctly) unauthenticated, the state binding is its entire security. This if b.session != "" skips the cross-session check whenever the session is empty — leaving only single-use + expiry. The callback is registered on the main server (cfg.Host, possibly 0.0.0.0), not loopback, so an empty-session state is completable by anyone who obtains it within the 10-min TTL. The "CLI loopback" rationale only holds if the endpoint is actually loopback-bound.
Recommend requiring a non-empty session (reject empty here) unless the callback is provably loopback-bound — so an unauthenticated, network-reachable callback is never protected by single-use+expiry alone. Worth designing in before the Issue-side starts issuing real state.
Carved out of the #317 epic (tracked as #330). The delegated token (#327) and connection (#329) resolution already landed; this is the remaining Forge-side half — the runtime auth-required gate that turns "no grant yet" from a hard
ErrNoTokenfailure into a pause that resumes once the user consents. Built in self-contained increments (same pattern as #329).Inc 1 —
authgatepark/resume engine ✅forge-core/security/authgate— the generalized DEFER-style pause-and-resume primitive. Keyed by{subject, server}(not taskID): one consent fans out to resume every parked call of that user's for that server, across tasks. FirstAwaitdelivers the prompt; joiners attach silently. Binary resolution (granted/timeout/canceled). Never mints or sees a token — it only unblocks the executor to re-resolve.Inc 2 — gate wired into
MCPTool.Execute✅On
ErrNoTokenfrom the per-user resolver,ExecutecallsAuthGate.Await(a narrow seam) instead of failing; on a granted resume it re-resolves and proceeds. nil gate ⇒ pre-#330 behavior. OnlyErrNoTokenis gated.Inc 3 — runtime gate + consent-resume endpoint ✅
mcpAuthGatebridges the engine to runtime concerns: subject extraction, task-status flip toauth-required(restored on resume), consent delivery, audit (mcp_auth_required/_resolved/_timeout).POST /mcp/consentis the resume signal (managed platform / operator) — carries no token, mirrors/tasks/{id}/decisionsstatus codes;granted:falsefails fast.ConsentDelivererseam (+SetConsentDeliverer).Inc 4 — standalone loopback consent ✅
stateBinderissues single-use, expiring OAuthstatebound to{subject, server, session}.GET /mcp/oauth/callbackvalidates state, enforces the session match (rejects cross-session / replayed / expired), exchanges the code via the injectedCallbackCompleter, and only then resumes the gate (never resume before the grant exists). Registered only when a completer is set (standalone); managed hosts its own.Inc 5 — swappable session token store ✅
SubjectTokenStoreinterface (§18.8 #2) formalizes the per-subject delegated-token cache;memSubjectTokenStoreis the in-process default (skew + opportunistic sweep + evict-on-stale). A managed broker substitutes a shared/durable impl viaPlatformSourceConfig.SubjectStorewithout the resolver or agent changing.Acceptance (the #317 boxes) — all covered
type: usercall lacking a grant pauses (not fails) and resumes after consent — inc 1–3.statebinding rejects cross-session / replayed / expired callbacks (standalone) — inc 4.POST /mcp/consent, no token).Remaining (follow-up, not blocking)
Standalone Issue-side wiring — building the authorize URL + delivering the prompt via
ConsentDeliverer(reusesoauth_flow.go). The security core (state binding + callback + gate) is in place; managed mode is fully functional now.All increments green under
-race+golangci-lint. Closes #330.Epic #317 · tokens #327 · connections #329 · AARM R10 #319 · authority
design-tool-registry.md§18.4/§18.5.