feat(mcp): OAuth discovery (RFC 9728/8414) + dynamic client registration (RFC 7591) (closes #316)#320
Conversation
…ion (RFC 7591) (closes #316) Remote-MCP OAuth no longer requires hand-configuring the client and endpoints. Given only `transport: http` + `url:`, Forge derives everything the way every other MCP client does: - RFC 9728 — protected-resource metadata (from the 401 WWW-Authenticate `resource_metadata` pointer, or the origin's `.well-known/oauth-protected-resource`) → the authorization server. - RFC 8414 — auth-server metadata (`oauth-authorization-server`, with the OpenID `openid-configuration` variant as fallback) → authorize/token/ registration endpoints + scopes_supported. - RFC 7591 — dynamic client registration mints a client_id at first `forge mcp login`; persisted (encrypted, alongside the token) and reused on refresh — never re-minted per run. Config: `client_id`/`authorize_url`/`token_url` are now OPTIONAL for `type: oauth`. Precedence: explicit config wins; else a persisted registration; else discovery+DCR (login only — the refresh path never mints). A partial endpoint pair (one set, one empty) is a validation error. Fail-closed with a clear message when a server advertises no metadata / no registration_endpoint and no client_id is set. Persistence: new generic `oauth.SaveRecord/LoadRecord/DeleteRecord` (encrypted-preferred, plaintext fallback, mirroring the token store) hold the discovered endpoints + minted client under `mcp_reg_<name>`. Egress: the discovered authorization-server host isn't in forge.yaml to pre-seed the allowlist, so `mcp.RegisteredOAuthHosts` reads it back from the login-time registration record and the runtime merges it into the egress allowlist (store-path override applied first). Also: docs example now points at `/mcp` (Streamable HTTP) rather than the legacy `/sse`. Wired through `OAuthFlow.Login` + `BearerToken` (new `resolveOAuthConfig`); `NewServer`/`ValidateMCPConfig` guards relaxed to allow discovery while still rejecting partial endpoint configs. Tests: 9728/8414/7591 happy path + persistence-and-reuse (one DCR call), WWW-Authenticate fallback, explicit-override precedence, no-registration fail-closed, refresh-never-discovers, RegisteredOAuthHosts, and the WWW-Authenticate / well-known parse helpers. Docs: mcp/configuration.md discovery section; forge.md MCP note.
initializ-mk
left a comment
There was a problem hiding this comment.
Review: MCP OAuth discovery (RFC 9728/8414) + DCR (RFC 7591)
Strong zero-config feature and the RFC layering is faithful (9728 protected-resource → 8414 auth-server metadata w/ OpenID fallback → 7591 DCR). I verified the parts that matter: the validation relaxation is clean (client_id + endpoints now optional; a partial endpoint pair is still an XOR error, tested both directions), Login passes allowDiscovery=true while BearerToken/refresh passes false, port-less loopback DCR redirect is the correct RFC 8252 §7.3 approach, and the discovered-host egress learn-back is sound. CI fully green.
Three findings (all confirmed against the code), then incremental minors:
1. Medium (security): a DCR confidential-client secret can land in a plaintext store — and it is currently persisted for no functional benefit
Confirmed the full chain: registerClient parses client_secret from the DCR response → resolveOAuthConfig stores it into oauthRegistration.ClientSecret (oauth_discovery.go:143) → oauth.SaveRecord (line 149) → SaveRecord falls through to saveRecordPlaintext (store.go:174) whenever no encrypted backend is configured. So on a host with no keyring/passphrase, an AS-issued confidential secret is written in clear to mcp_reg_<name>.json. "Encrypted-preferred, plaintext-fallback" is fine for a client_id; it is not fine for a secret.
What makes this cleaner to fix than it looks: the PR body itself notes the token path stays public-client PKCE and doesn't send client_secret yet. So the secret is persisted and unused — pure liability with zero current benefit. Simplest fix: don't persist ClientSecret at all today (drop it from the record before SaveRecord); wire secret handling — encryption-required — when the confidential token path actually lands. If you'd rather keep persisting it, fail closed on the plaintext path when a secret is present (refuse to write, error out). This matters more under admission-time DCR, not less: the platform mints and holds those secrets, so Forge should never be their store of record. Inline on oauth_discovery.go:143.
2. Docs (small): reframe "explicit config = override"
The docs frame explicit config as the override for servers that don't advertise metadata / don't support DCR (configuration.md:106). That's now incomplete — explicit is also the managed path: the normal case when a platform materializes config from a registry entry. Reframe as: discovery is the standalone default; explicit config is both the static override AND the platform-materialized path.
3. Test: assert the partial-materialization fail-closed (it's emergent, not asserted)
Verified the invariant holds but only emergently: on refresh, resolveOAuthConfig step-1 needs all three fields, step-2 finds no record in a fresh pod, and !allowDiscovery (oauth_discovery.go:110) returns ErrNoToken → fails closed, never mints. Good — but nothing pins it. The precise nuance to lock down: on the refresh/runtime path, a partially-materialized config (a platform env var came through empty) must fail closed and never mint a divergent client. (Note the asymmetry worth a comment: the login path with the same partial config would fall through to DCR and mint its own client — fine for laptop login, but it means "partial ⇒ fail-closed" is a refresh-path property specifically.) Cheap test, pins the drift scenario. Inline on the gate.
Incremental minors
authorization_servers[0]only (discoverProtectedResource): RFC 9728 advertises a list, but discovery tries only the first — no fallback to[1]if the first AS's 8414 metadata fetch fails. Minor robustness gap; a multi-AS resource with a dead primary won't complete.- Server-controlled redirect at login rides the non-egress client: the
WWW-Authenticateresource_metadatapointer andauthorization_servers[0]are server-controlled URLs, fetched with the plain 15s client (not the egress-enforced transport) at login. Laptop-side + operator-initiated + bounded bymaxMetadataBytes/timeout, so low severity, and the runtime/refresh path is egress-enforced — but it's a discovery-time SSRF surface worth one line acknowledging (a malicious MCP url can steer the login-time fetch at an internal metadata endpoint). - No re-registration trigger on client revocation/expiry: DCR is "never re-minted," so if the AS revokes the client or
client_secret_expires_atpasses, the persisted record silently goes stale and the operator mustDeleteRecord+ re-login. Acceptable for v1; worth a one-line doc note on the recovery path.
Verdict
The feature is well-built and the discovery/DCR layering is correct. Finding 1 is the one I'd gate merge on — a plaintext-persisted confidential secret is a real exposure, and since the token path doesn't use it yet, not persisting it is a strictly-safe, zero-cost fix. Findings 2–3 are a docs reframe and a cheap fail-closed test; the incremental minors are follow-ups.
| TokenURL: firstNonEmpty(cfg.TokenURL, meta.TokenEndpoint), | ||
| RegistrationURL: meta.RegistrationEndpoint, | ||
| ClientID: clientID, | ||
| ClientSecret: clientSecret, |
There was a problem hiding this comment.
Medium (security): this persists a DCR confidential-client secret that can land in plaintext — and it's unused today.
Chain: registerClient returns client_secret from the RFC 7591 response → stored here → oauth.SaveRecord (line 149) → SaveRecord falls to saveRecordPlaintext (store.go:174) when no encrypted backend is configured. So on a host without a keyring/passphrase, the AS-issued secret is written in clear to mcp_reg_<name>.json. Encrypted-preferred/plaintext-fallback is fine for a client_id; not for a secret.
The PR body notes the token path stays public-client PKCE and doesn't send client_secret yet — so this secret is persisted and unused. Simplest fix: don't persist ClientSecret at all right now (omit it from the record before SaveRecord); design secret handling with encryption required when the confidential token path actually lands. Alternative: fail closed on the plaintext path when a secret is present. Under admission-time DCR the platform holds these secrets, so Forge shouldn't be their store of record at all.
There was a problem hiding this comment.
Fixed in 534b6a2 — went with the strictly-safe option: stop persisting the secret entirely (dropped ClientSecret from the registration record and OAuthServerConfig) and fail closed when an AS issues a confidential client. Since we register token_endpoint_auth_method: none, a returned secret means we can't authenticate that client on the public-PKCE token path anyway — so failing closed with a pointer to explicit config is both safe and honest. No secret reaches the store on any path. Test: TestResolveOAuthConfig_ConfidentialClientFailsClosed asserts the error and that nothing was persisted (no host learned back).
| return mergeRegistration(cfg, reg), nil | ||
| } | ||
|
|
||
| if !allowDiscovery { |
There was a problem hiding this comment.
Assert this fail-closed (finding 3): it's currently emergent, not pinned.
This !allowDiscovery guard is what makes a partially-materialized config fail closed on the refresh path — step-1 needs all three fields, step-2 finds no record in a fresh pod, and refresh (BearerToken → allowDiscovery=false) lands here with ErrNoToken, never minting. That's the correct drift-prevention behavior, but nothing tests it.
Worth a cheap test: on the refresh path, a partial platform config (one env var empty) returns ErrNoToken and never mints a divergent client. Worth a comment on the asymmetry too: the login path (allowDiscovery=true) with the same partial config falls through to DCR and mints its own client — so "partial ⇒ fail-closed" is specifically a refresh-path property. Pinning it turns an emergent property into an asserted invariant, which is the whole point for the managed-drift scenario.
There was a problem hiding this comment.
Pinned in 534b6a2 — TestResolveOAuthConfig_RefreshPartialConfigFailsClosed: partial config (client_id present, endpoints empty) on the refresh path (allowDiscovery=false) returns ErrNoToken and mints nothing (0 DCR calls). Added the asymmetry comment at the gate too: the login path with the same partial config falls through to DCR, so "partial ⇒ fail-closed" is specifically a refresh-path property.
|
|
||
| **Precedence & rules:** | ||
|
|
||
| - **Explicit config always wins.** Set `client_id`/`authorize_url`/`token_url` |
There was a problem hiding this comment.
Small reframe (finding 2): "Explicit config always wins" frames explicit as the override for servers that don't advertise metadata / don't support DCR — but that's now incomplete. Explicit config is also the managed path: the normal case when a platform materializes client_id/endpoints from a registry entry. Suggest reframing as: discovery is the standalone default; explicit config is both the static override and the platform-materialized path. Otherwise a reader assumes explicit config is only for the exception case, when under admission-DCR it's the common one.
There was a problem hiding this comment.
Reframed in 534b6a2: "Discovery is the standalone default; explicit config always wins" — now called out as both the static override AND the platform-materialized path (the common case when a control plane materializes config from a registry entry), not just the exception.
… fail-closed, multi-AS Finding 1 (security, merge-gating): a DCR confidential-client secret was persisted (and, without a passphrase, in the plaintext-fallback store) yet never used — the token path is public-client PKCE. Stop persisting it entirely: drop `ClientSecret` from the registration record and from OAuthServerConfig, and **fail closed** when an AS issues a confidential client (we register `token_endpoint_auth_method: none`, so a secret means we can't authenticate it anyway) — pointing the operator at explicit config. No secret ever reaches the store. Finding 3 (test + comment): pin the refresh-path fail-closed invariant. A partially-materialized config (client_id present, endpoints empty) on the refresh path (allowDiscovery=false) now has a test asserting it returns ErrNoToken and mints nothing — and a comment documents the asymmetry (the login path with the same partial config would fall through to DCR; "partial ⇒ fail-closed" is a refresh-path property). Finding 2 (docs): reframe explicit config as the standalone default's override AND the platform-materialized path (the common case under managed provisioning), not only the exception. Minors: - Multi-AS: RFC 9728 advertises a list; discovery now tries every authorization_servers entry (was [0] only) so a dead primary still completes. - SSRF note: comment that the login-time discovery fetch of server-controlled URLs rides the plain client (laptop-side; the runtime/refresh path is egress-enforced). - Recovery: `Logout` now also deletes the registration record, so `forge mcp logout` + `login` is the re-discover/re-register path after a client is revoked; documented in configuration.md. Tests: confidential-client fail-closed (nothing persisted), refresh-partial-config fail-closed (no mint). Docs updated.
|
Addressed all three findings + the worthwhile minors in `534b6a2`:
Minors:
CI re-running. |
initializ-mk
left a comment
There was a problem hiding this comment.
Re-review of 534b6a2 — all three findings resolved, finding 1 fixed beyond the ask. CI green.
Finding 1 (plaintext confidential secret) — fixed with the strictest option, plus an improvement.
ClientSecretdropped from both the persistedoauthRegistrationrecord andOAuthServerConfig— the secret can never reach the store (plaintext or otherwise) or linger in in-memory config.- A DCR response that issues a confidential client now fails closed at resolve time with an actionable error ("Forge supports only public PKCE clients … configure client_id/authorize_url/token_url explicitly") instead of half-registering and failing later at token exchange with a confusing message.
registerClientstill readsclient_secret— necessary and correct, since you must detect the confidential issuance to refuse it — but never stores it.TestResolveOAuthConfig_ConfidentialClientFailsClosedpins it with a test server whose/registerreturns a secret, asserting resolve fails and persists nothing.
That's better than the minimum: it removes the exposure AND turns "AS issued a confidential client" from a silent downstream auth failure into a clear config error.
Finding 3 (assert partial-materialization fail-closed) — pinned exactly as specified. TestResolveOAuthConfig_RefreshPartialConfigFailsClosed drives partial config (client_id present, endpoints empty) on the refresh path and asserts both halves: the error routes to forge mcp login (fails closed) AND regCalls == 0 (mints nothing). The code comment documents the login-vs-refresh asymmetry too. Emergent property → asserted invariant.
Finding 2 (docs reframe) — done verbatim. "Discovery is the standalone default; explicit config always wins," with explicit called out as "the platform-materialized path — the normal case when a control plane materializes these fields from a registry entry."
Still open (my incremental minors — all follow-up severity, untouched)
authorization_servers[0]-only with no fallback to[1]on a dead primary;- the server-controlled discovery redirect riding the non-egress client at login (bounded SSRF surface; runtime/refresh path is egress-enforced);
- no re-registration trigger on client revocation /
client_secret_expires_at.
These were framed as follow-ups, so leaving them is fine — worth a tracking issue rather than blocking.
Verdict
Merge-ready. The merge-gating finding (plaintext secret) is resolved cleanly — the secret now touches nothing — and both security-relevant behaviors (confidential-refuse, partial-refresh-fail-closed) are pinned by tests. Good outcome: the drift scenario is now an asserted invariant rather than an accident of allowDiscovery.
`mcp login` and `mcp logout` apply the MCP token-store-path override (forge.yaml `mcp.token_store_path` > env `MCP_TOKEN_STORE_PATH`) via oauth.SetCredentialsDir before touching the store, but `mcp test` did not — so `test` always read the default ~/.forge/credentials and reported "no stored token" for any server logged in under a custom store path. Apply the same override in mcp test. Found while manually testing the #316 discovery flow with an isolated token store.
Manual end-to-end test results ✅Tested the built binary two ways — an offline mock and real Linear. Both green. Variant A — offline mock (deterministic RFC-by-RFC)A local mock MCP server implementing RFC 9728 / 8414 / 7591 + authorize/token. With
Variant B — real Linear (
|
… fail-closed, multi-AS Finding 1 (security, merge-gating): a DCR confidential-client secret was persisted (and, without a passphrase, in the plaintext-fallback store) yet never used — the token path is public-client PKCE. Stop persisting it entirely: drop `ClientSecret` from the registration record and from OAuthServerConfig, and **fail closed** when an AS issues a confidential client (we register `token_endpoint_auth_method: none`, so a secret means we can't authenticate it anyway) — pointing the operator at explicit config. No secret ever reaches the store. Finding 3 (test + comment): pin the refresh-path fail-closed invariant. A partially-materialized config (client_id present, endpoints empty) on the refresh path (allowDiscovery=false) now has a test asserting it returns ErrNoToken and mints nothing — and a comment documents the asymmetry (the login path with the same partial config would fall through to DCR; "partial ⇒ fail-closed" is a refresh-path property). Finding 2 (docs): reframe explicit config as the standalone default's override AND the platform-materialized path (the common case under managed provisioning), not only the exception. Minors: - Multi-AS: RFC 9728 advertises a list; discovery now tries every authorization_servers entry (was [0] only) so a dead primary still completes. - SSRF note: comment that the login-time discovery fetch of server-controlled URLs rides the plain client (laptop-side; the runtime/refresh path is egress-enforced). - Recovery: `Logout` now also deletes the registration record, so `forge mcp logout` + `login` is the re-discover/re-register path after a client is revoked; documented in configuration.md. Tests: confidential-client fail-closed (nothing persisted), refresh-partial-config fail-closed (no mint). Docs updated.
Closes #316.
What
Remote-MCP OAuth stops requiring the operator to pre-register a client and hand-type endpoints. Given only
transport: http+url:, Forge derives everything via the MCP Authorization spec — the same zero-config path Claude Code and the reference clients use.How
401WWW-Authenticateresource_metadatapointer, or{origin}/.well-known/oauth-protected-resource→ the authorization server.well-known/oauth-authorization-server(OpenIDopenid-configurationfallback) → authorize/token/registration endpoints +scopes_supportedclient_idat firstforge mcp login; persisted and reused on refresh — never re-mintedPrecedence & safety
client_id/authorize_url/token_urlto override discovery.allowDiscovery=false).registration_endpointand noclient_idis configured.oauth.SaveRecord/LoadRecord/DeleteRecord(encrypted-preferred, plaintext fallback — mirrors the token store) hold the discovered endpoints + minted client undermcp_reg_<name>, so refresh and a pod restart reuse them.forge.yamlto pre-seed the allowlist, somcp.RegisteredOAuthHostsreads it back from the login-time registration record and the runtime merges it into the egress allowlist (store-path override applied first). Discovery itself runs laptop-side, off the egress-enforced path.Acceptance criteria
type: oauth+url:) completes login against a spec-compliant server.WWW-Authenticateand the well-known path.NewServer/ValidateMCPConfigguards relaxed (partial pair still rejected).registration_endpointand no static config.Notes
/mcp(Streamable HTTP) instead of the legacy/sse.client_secretyet); build-time egress freezing can't know discovery hosts (runtime allowlist covers it). This lands the discovery config seam that themethod:-based reshape (perdesign-tool-registry.md§18) will build on.All three modules build;
golangci-lintclean;forge-coremcp/validate/oauth +forge-cliruntime/cmd suites pass.