Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 64 additions & 4 deletions docs/mcp/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ MCPs).
| `bearer` | `token_env` | In-cluster sidecars; CI machine-to-machine |
| `static` | `token_env` | Same as bearer; named for clarity |
| `platform` | top-level `platform` block | **Managed:** agent-principal (service) identity resolved by the platform |
| `user` | top-level `platform` block | **Managed:** delegated per-requesting-user identity, resolved by the platform |
| `user` | top-level `platform` block **or** explicit endpoints (standalone) | Delegated per-requesting-user identity — **managed** (platform-resolved) or **standalone** (Forge runs the OAuth itself, #332) |

`token_env` is the name of an environment variable; the variable's
value is read at runtime — never stored in `forge.yaml`.
Expand Down Expand Up @@ -125,9 +125,69 @@ mcp:
> B's. Connections are established lazily (first call) and per subject.

> The platform materializes both the `platform` block and the per-identity
> server entries (same URL, split by `type`). For the **standalone** (no
> platform) equivalents, use `type: oauth` — 3-legged `forge mcp login` for a
> user, or `grant: client_credentials` for an agent-principal (above).
> server entries (same URL, split by `type`). For a standalone agent-principal,
> use `type: oauth` with `grant: client_credentials` (above); for standalone
> **per-user** delegation, use standalone `type: user` (below).

#### Standalone delegated consent — `type: user` without a platform (#332)

When there is **no** `platform` block, `type: user` runs the delegated OAuth
**in Forge itself**: a grantless call parks on the auth-required gate, the user
is shown a "Connect" login link, they consent in the browser, and Forge stores
their per-user access token in memory (no disk) so the call resumes.

```yaml
server:
public_url: https://agent.example.com # or the AGENT_URL env var — see below

mcp:
servers:
- name: atlassian
url: https://mcp.atlassian.com/mcp
auth:
type: user
# standalone requires explicit endpoints + client_id (no runtime discovery):
client_id: ${ATLASSIAN_CLIENT_ID}
authorize_url: https://auth.atlassian.com/authorize
token_url: https://auth.atlassian.com/oauth/token
scopes: [read:jira-work, write:jira-work]
required: false
tools: { allow: ["*"] }
```

- **`grant`** is `authorization_code` (the default) — `client_credentials` is
rejected for standalone `type: user`.
- **Explicit endpoints + `client_id` are required.** Standalone does not run
discovery/registration at runtime, so `authorize_url`, `token_url`, and
`client_id` must be set. (Add a `platform` block instead for managed
delegation, which resolves tokens without any of these.)
- **`server.public_url`** (falling back to the `AGENT_URL` env var) is the
agent's externally-reachable base URL. Forge builds `redirect_uri =
<public_url>/mcp/oauth/callback`, so the URL must be reachable by the user's
browser after IdP consent.
- **Delivery.** The login link is published on the parked task's A2A
`auth-required` artifact (a UI/A2A client renders it). Forge-driven Slack
delivery of the same link is tracked separately (#343).
- **Endpoints registered** (only in standalone mode): `GET /mcp/oauth/start`
(sets a `forge_session` cookie, then redirects to the IdP) and
`GET /mcp/oauth/callback` (validates state + session, exchanges the code,
resumes the parked call). Both are auth-exempt (anonymous browser hops); their
authenticity rests on the single-use, expiring, session-bound state — see the
[auth-required gate](#delegated-consent--the-auth-required-gate-330).

> **Trust model / limitation (standalone).** The consent link is a **bearer
> capability**: the browser that completes it is anonymous, so the session
> cookie only proves the *same browser* did `/start` and `/callback` across the
> IdP round-trip — it does **not** prove the completing user is the parked
> subject. If the link leaks, an attacker could authenticate at the IdP as
> *themselves* and have that token filed under the victim's subject (a
> confused-deputy / token-fixation vector). This is bounded by the single-use,
> short-TTL state and, above all, by **delivering the link only over an
> authenticated channel** to the requesting user (the A2A `auth-required`
> artifact in their own session; the Slack DM in #343). Treat the link as a
> secret. The tamper-proof (heavier) alternative — verifying the IdP `userinfo`
> identity against the parked subject at exchange time — is deferred; managed
> mode sidesteps it entirely (the platform owns the callback and token custody).

#### Materialized tool schemas (#317)

Expand Down
147 changes: 125 additions & 22 deletions forge-cli/runtime/mcp_consent_callback.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package runtime

import (
"context"
"net/http"
"strings"
"sync"
"time"

Expand All @@ -26,10 +28,18 @@ import (
// stateBinding is what an issued OAuth `state` is bound to. The callback
// must match all of it (modulo an empty session) to be honored.
type stateBinding struct {
subject string
server string
session string // the initiating session; callback must match (cross-session guard)
deadline time.Time
subject string
server string
session string // the initiating session; callback must match (cross-session guard)
// verifier is the PKCE code_verifier minted when the authorize URL was
// built; the callback needs it to exchange the code (#332). Empty for
// managed flows (Forge never sees the code) or where PKCE isn't used.
verifier string
// authorizeURL is the IdP authorize URL this state was minted for. The
// GET /mcp/oauth/start endpoint redirects the browser here after setting
// the session cookie (#332). Empty when Forge doesn't front the redirect.
authorizeURL string
deadline time.Time
}

// stateBinder issues single-use, expiring OAuth `state` values bound to the
Expand All @@ -56,7 +66,8 @@ func newStateBinder(ttl time.Duration) *stateBinder {
}

// Issue mints a cryptographically-random state bound to {subject, server,
// session} and returns it for embedding in the authorize URL.
// session} and returns it for embedding in the authorize URL. Used where the
// caller doesn't front the redirect (no verifier/authorizeURL captured).
func (s *stateBinder) Issue(subject, server, session string) (string, error) {
state, err := oauth.GenerateState()
if err != nil {
Expand All @@ -69,6 +80,38 @@ func (s *stateBinder) Issue(subject, server, session string) (string, error) {
return state, nil
}

// Bind stores a fully-formed binding under a caller-provided state. The
// standalone front-half (#332) generates the state itself (it must embed it in
// the authorize URL before binding), then records the session, PKCE verifier,
// and authorize URL here so GET /mcp/oauth/start can set the session cookie and
// redirect, and GET /mcp/oauth/callback can complete the exchange.
func (s *stateBinder) Bind(state, subject, server, session, verifier, authorizeURL string) {
s.mu.Lock()
defer s.mu.Unlock()
s.sweepLocked()
s.m[state] = stateBinding{
subject: subject, server: server, session: session,
verifier: verifier, authorizeURL: authorizeURL,
deadline: s.now().Add(s.ttl),
}
}

// Peek returns the binding for state WITHOUT consuming it (unlike Consume),
// for the GET /mcp/oauth/start redirect: the state is spent later, at the
// callback. Returns ok=false for unknown or expired states.
func (s *stateBinder) Peek(state string) (stateBinding, bool) {
if state == "" {
return stateBinding{}, false
}
s.mu.Lock()
defer s.mu.Unlock()
b, ok := s.m[state]
if !ok || s.now().After(b.deadline) {
return stateBinding{}, false
}
return b, true
}

// Consume looks up and REMOVES the binding for state. It returns ok=false
// for an unknown state (forged), an already-used state (replay — the entry
// is gone after the first Consume), or an expired one. Single-use + expiry
Expand Down Expand Up @@ -100,12 +143,13 @@ func (s *stateBinder) sweepLocked() {
}
}

// CallbackCompleter exchanges an OAuth authorization code for a token and
// stores it for {subject, server}, so the resumed call finds a grant. The
// standalone interactive resolver provides one; when nil the loopback
// callback is NOT registered (managed mode hosts its own callback and never
// hands Forge a code).
type CallbackCompleter func(subject, server, code string) error
// CallbackCompleter exchanges an OAuth authorization code (with the PKCE
// verifier bound to the state) for a token and stores it for {subject, server},
// so the resumed call finds a grant. The standalone resolver provides one; when
// nil the loopback callback is NOT registered (managed mode hosts its own
// callback and never hands Forge a code). ctx is the request context so the
// token exchange inherits a finite deadline.
type CallbackCompleter func(ctx context.Context, subject, server, code, verifier string) error

// sessionFromRequest extracts the caller's session id for the cross-session
// check. Prefers an explicit forge session header; falls back to a session
Expand All @@ -123,20 +167,71 @@ func sessionFromRequest(r *http.Request) string {
return ""
}

// registerMCPCallbackEndpoint wires the standalone loopback callback. It is
// registerMCPCallbackEndpoint wires the standalone consent endpoints. They are
// registered ONLY when a CallbackCompleter is set (standalone interactive
// mode); managed deployments never register it.
// mode); managed deployments never register them.
//
// - GET /mcp/oauth/start: the link Forge delivers. It sets the forge_session
// cookie (the producer the callback's mandatory session match requires) and
// redirects the browser to the IdP authorize URL.
// - GET /mcp/oauth/callback: the IdP redirect target. Validates state +
// session, exchanges the code, resumes the parked call.
func (r *Runner) registerMCPCallbackEndpoint(srv *server.Server, auditLogger *coreruntime.AuditLogger) {
if r.authGateEngine == nil || r.callbackCompleter == nil {
return
}
if r.stateBinder == nil {
r.stateBinder = newStateBinder(defaultStateTTL)
}
srv.RegisterHTTPHandler("GET /mcp/oauth/start", makeMCPStartHandler(r.stateBinder))

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.

[High / Blocking] This endpoint is registered behind the auth middleware but isn't exempted — the standalone consent flow can't start in any auth-enforcing deployment.

Both /mcp/oauth/start (here) and /mcp/oauth/callback (next line) are wrapped by the auth middleware, but auth.DefaultSkipPaths() (forge-core/auth/middleware.go) exempts only the callback:

"GET /mcp/oauth/callback":     true,
"OPTIONS /mcp/oauth/callback": true,
// GET /mcp/oauth/start  ← missing

Matching is an exact lookup (skip[r.Method+" "+r.URL.Path]), and the runner uses DefaultSkipPaths() unmodified at all three sites (runner.go:3029/3071/3078). So when the user clicks the delivered "Connect" link, their anonymous browser (no bearer token) hits GET /mcp/oauth/startwriteAuthError(...)401 before the handler runs. The flow dies at its first hop in every deployment that enforces auth — which is the entire point of delegated per-user consent. (Works only under --no-auth.)

/start is safe to exempt for the same reason the callback is: its authenticity rests on the Peek'd session-bound state (it rejects any binding lacking authorizeURL+session), not a bearer token — and it is not an open redirect (b.authorizeURL is server-built from config, reachable only via a Forge-minted state).

Why CI didn't catch it: TestStandaloneConsent_LinkAndStart calls makeMCPStartHandler(...) directly, so it never exercises the middleware — green handler, broken wiring. Same shape as the #331 callback bug and the #323 mcp login bypass.

Fix: in DefaultSkipPaths() add

"GET /mcp/oauth/start":     true,
"OPTIONS /mcp/oauth/start": true, // symmetry; GET is the load-bearing one

and add a middleware-level test asserting /start is reachable unauthenticated and that POST /mcp/consent still requires auth (the both-directions test #331 added for the callback).

srv.RegisterHTTPHandler("GET /mcp/oauth/callback",
makeMCPCallbackHandler(r.stateBinder, r.authGateEngine, r.callbackCompleter, sessionFromRequest, auditLogger))
}

// forgeSessionCookie is the cookie name the callback's cross-session guard
// reads (sessionFromRequest). The start endpoint is its only producer.
const forgeSessionCookie = "forge_session"

// makeMCPStartHandler serves GET /mcp/oauth/start?state=<state>. It is the
// session producer for the standalone consent flow (#332): the callback
// mandates a matching forge_session, and this is where the browser gets it.
// The step is necessary because the user's browser only ever touches Forge at
// the callback (after the IdP), so the cookie must be planted on an earlier
// same-origin visit — this redirect.
//
// The session value lives ONLY in the server-side binding and the cookie, never
// in a URL query, so it is never leaked to the IdP via Referer (unlike the
// state, which round-trips through the IdP by design). SameSite=Lax lets the
// cookie survive the top-level cross-site redirect back from the IdP.
//
// Scope of the guarantee (see docs/mcp/configuration.md — Trust model): the
// browser here is ANONYMOUS, and this plants the cookie for whoever holds the
// link. So the session binding proves browser CONTINUITY across the round-trip
// (a stolen state alone, replayed from a different browser, can't complete) —
// it does NOT prove the completing user is the parked subject. The link is a
// bearer capability; its real containment is authenticated-channel delivery +
// single-use short-TTL state.
func makeMCPStartHandler(binder *stateBinder) http.HandlerFunc {
return func(w http.ResponseWriter, req *http.Request) {
state := req.URL.Query().Get("state")
b, ok := binder.Peek(state) // Peek, not Consume — the state is spent at the callback.
if !ok || b.authorizeURL == "" || b.session == "" {
http.Error(w, "invalid or expired consent link", http.StatusBadRequest)
return
}
http.SetCookie(w, &http.Cookie{
Name: forgeSessionCookie,
Value: 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 / Design question] In standalone mode this cookie proves browser continuity, not that the completer is the parked subject.

The consent browser is anonymous until it reaches /start, and /start plants forge_session = b.session for whoever presents the link. So the callback's session match only guarantees the same browser did /start and /callback across the IdP round-trip — it does not tie the flow to the authenticated identity that triggered the park.

Consequence: if the consent link leaks, an attacker can hit /start, authenticate at the IdP as themselves, and the callback files their token under the victim's b.subject (fixed server-side from the parked call). The victim's resumed call then runs against the attacker's account — a confused-deputy / token-fixation vector (attacker data flows into the victim's agent, or victim data is written to the attacker's account).

This is bounded: the state is single-use + short-TTL and the link is delivered on the authenticated A2A artifact (Slack DM in #343), so it's delivery-gated rather than open. Raising it because "session-bound cross-session guard" reads stronger than the standalone guarantee — the flow structurally can't bind the browser to the parked subject. Worth an explicit decision + a doc note (like #339 documented its spoofing limitation). The tamper-proof-but-heavy alternative is verifying the IdP userinfo identity against b.subject at exchange time.

Path: "/mcp/oauth/",
HttpOnly: true,
Secure: req.TLS != nil || strings.EqualFold(req.Header.Get("X-Forwarded-Proto"), "https"),
SameSite: http.SameSiteLaxMode,
Expires: b.deadline,
})
http.Redirect(w, req, b.authorizeURL, http.StatusFound)
}
}

// makeMCPCallbackHandler validates the state, enforces the session match,
// exchanges the code for a token, and resumes the parked call. Extracted so
// tests can drive every rejection path without a real IdP.
Expand All @@ -162,26 +257,34 @@ func makeMCPCallbackHandler(
http.Error(w, "invalid, expired, or already-used state", http.StatusBadRequest)
return
}
// Cross-session guard — MANDATORY. This endpoint is unauthenticated
// Session-continuity guard — MANDATORY. This endpoint is unauthenticated
// (a browser redirect carries no bearer token) and is registered on
// the network-exposed main server (cfg.Host, possibly 0.0.0.0), so
// the state binding is its ENTIRE security. Single-use + expiry alone
// would let anyone who obtains a state within its TTL complete the
// flow, so we additionally require the callback to land in the SAME
// session that started it. An empty bound session is a config/Issue-
// side bug (a network-exposed flow must bind a session) and is
// rejected fail-closed rather than silently downgrading to
// single-use+expiry. A future loopback-bound variant that wanted to
// relax this would bind the listener to localhost.
// flow from a DIFFERENT browser, so we additionally require the callback
// to land in the SAME browser that started it (the forge_session cookie
// planted at /start). An empty bound session is a config/Issue-side bug
// (a network-exposed flow must bind a session) and is rejected
// fail-closed rather than silently downgrading to single-use+expiry.
//
// What this does NOT guarantee (see docs/mcp/configuration.md — Trust
// model): in standalone mode the browser is anonymous, so this proves
// browser continuity across the IdP round-trip, NOT that the completing
// user is b.subject. Containment against a leaked link is
// authenticated-channel delivery + the short single-use TTL, not this
// cookie. A future loopback-bound variant would bind the listener to
// localhost; the tamper-proof alternative verifies IdP userinfo == subject.
if b.session == "" || sessionOf(req) != b.session {
http.Error(w, "state/session mismatch", http.StatusBadRequest)
return
}
// Exchange the code for a token and store it for {subject, server}.
// Only AFTER this succeeds do we resume — resolving the gate with no
// grant would just re-park the call (delegation follows
// authorization: never resume before the grant exists).
if err := complete(b.subject, b.server, code); err != nil {
// authorization: never resume before the grant exists). The PKCE
// verifier bound to the state proves this is the same flow we started.
if err := complete(req.Context(), b.subject, b.server, code, b.verifier); err != nil {
http.Error(w, "authorization exchange failed", http.StatusBadGateway)
return
}
Expand Down
2 changes: 1 addition & 1 deletion forge-cli/runtime/mcp_consent_callback_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ type fakeCompleter struct {
err error
}

func (f *fakeCompleter) complete(_, _, code string) error {
func (f *fakeCompleter) complete(_ context.Context, _, _, code, _ string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.calls++
Expand Down
Loading
Loading