Skip to content

feat(mcp): auth-required gate for delegated MCP consent (#330)#331

Open
initializ-mk wants to merge 6 commits into
mainfrom
feat/mcp-authgate-330
Open

feat(mcp): auth-required gate for delegated MCP consent (#330)#331
initializ-mk wants to merge 6 commits into
mainfrom
feat/mcp-authgate-330

Conversation

@initializ-mk

@initializ-mk initializ-mk commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

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 ErrNoToken failure into a pause that resumes once the user consents. Built in self-contained increments (same pattern as #329).

Inc 1 — authgate park/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. First Await delivers 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 ErrNoToken from the per-user resolver, Execute calls AuthGate.Await (a narrow seam) instead of failing; on a granted resume it re-resolves and proceeds. nil gate ⇒ pre-#330 behavior. Only ErrNoToken is gated.

Inc 3 — runtime gate + consent-resume endpoint ✅

mcpAuthGate bridges the engine to runtime concerns: subject extraction, task-status flip to auth-required (restored on resume), consent delivery, audit (mcp_auth_required/_resolved/_timeout). POST /mcp/consent is the resume signal (managed platform / operator) — carries no token, mirrors /tasks/{id}/decisions status codes; granted:false fails fast. ConsentDeliverer seam (+ SetConsentDeliverer).

Inc 4 — standalone loopback consent ✅

stateBinder issues single-use, expiring OAuth state bound to {subject, server, session}. GET /mcp/oauth/callback validates state, enforces the session match (rejects cross-session / replayed / expired), exchanges the code via the injected CallbackCompleter, 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 ✅

SubjectTokenStore interface (§18.8 #2) formalizes the per-subject delegated-token cache; memSubjectTokenStore is the in-process default (skew + opportunistic sweep + evict-on-stale). A managed broker substitutes a shared/durable impl via PlatformSourceConfig.SubjectStore without the resolver or agent changing.

Acceptance (the #317 boxes) — all covered

  • type: user call lacking a grant pauses (not fails) and resumes after consent — inc 1–3.
  • state binding rejects cross-session / replayed / expired callbacks (standalone) — inc 4.
  • Managed: Forge parks/resumes only; callback + consent + token custody are platform-side — inc 3 (POST /mcp/consent, no token).
  • Session token store is a swappable interface a broker-backed impl substitutes — inc 5.
  • Never mints speculatively — park until authorized, then resolve — inc 1 + inc 4 (resume only after the token exchange succeeds).

Remaining (follow-up, not blocking)

Standalone Issue-side wiring — building the authorize URL + delivering the prompt via ConsentDeliverer (reuses oauth_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.

…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 initializ-mk left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  • gateKey is 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 (delete before the expiry check, so a replay finds nothing), TTL-bounded, session-bound, with opportunistic sweep. Crypto-random via oauth.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 returns BadGateway with no resume.
  • The gate never sees a token (it only unblocks re-resolution), /mcp/consent carries no token and granted:false fails fast, and only ErrNoToken is 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/consent is correctly authenticated — it's not in DefaultSkipPaths, 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",

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 != "" {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auth-required gate: DEFER park/resume for delegated MCP consent (#317 follow-up)

1 participant