ADR: MCP-Native OAuth (auto-connect any MCP server)¶
Status: Accepted · Milestone: M6 · Epic: COA-1874 · Supersedes nothing (extends M4)
Context¶
M4 gave the Gateway per-user OAuth: an admin pre-registers an OAuth app with a provider and
pastes client_id + client_secret + authorize_url + token_url into a connector's
oauth_app_config. That works against a provider's API OAuth (e.g. api.notion.com), but not
against the provider's own MCP server. A live probe (2026-08-06) confirmed that
mcp.notion.com, mcp.linear.app, and api.githubcopilot.com/mcp all implement the MCP
Authorization spec instead:
OAuth 2.1 + PKCE (RFC 7636) + Dynamic Client Registration (RFC 7591) + protected-resource metadata discovery (RFC 9728) + authorization-server metadata (RFC 8414) + resource indicators (RFC 8707).
A Notion API token (ntn_…) is rejected by mcp.notion.com (invalid_token) — different
audience / auth server. So M4's static path cannot connect a provider's MCP server at all.
Goal: teach the Gateway the MCP Authorization spec so an admin registers a connector by URL alone and any spec-compliant MCP server works with zero per-provider config — while every existing static connector keeps working unchanged.
Decision¶
1. Connector shape — auth_mode inside the encrypted config¶
We keep auth_type='oauth' and add an auth_mode discriminator inside the Fernet-encrypted
auth_config_encrypted blob (not a new column — avoids a migration and keeps the secret-bearing
fields in one encrypted place):
auth_mode |
Meaning | Key fields stored (encrypted) |
|---|---|---|
absent / static |
Classic M4 OAuth app | client_id, client_secret, authorize_url, token_url, scopes, token_auth, token_request_format |
mcp |
Auto-configured spec server | authorize_url, token_url (discovered), resource, authorization_server, scopes, client_id (+ client_secret if confidential), token_endpoint_auth_method, registration_access_token, registration_client_uri, token_auth |
The consent core (connectors/oauth.py) reads the same config dict for both modes, so
build_authorize_url / exchange_code / refresh_user_token are mode-agnostic. A public
(PKCE-only) DCR client sets token_auth="none" — client_id in the token body, no secret.
2. When discovery + DCR run — eager, at registration¶
Discovery (RFC 9728 → RFC 8414) and DCR (RFC 7591) run at connector registration, alongside the existing tool discovery, and the resolved config is cached (encrypted) on the connector:
- A credential-less register (no
oauth_app_config) is probed; a spec OAuth resource is auto-upgraded to anauth_mode='mcp'connector, a plain upstream stays as-is. - Gated on
OAUTH_CALLBACK_URLbeing configured (the DCRredirect_uri); unset → probe skipped (pre-M6 behavior), so rollout is controlled by deploy config. - Auto-config is best-effort: discovery/DCR failure flags the connector
status='error'rather than rejecting registration — mirroring tool discovery. - Re-registration: on
invalid_client/ expired DCR client at consent time, re-run DCR once and retry (COA-1880). Re-discovery also triggers when the storedauthorization_serverno longer matches the upstream's advertised one.
Rejected alternative — lazy (first-consent) discovery: pushes network latency + failure onto the end user's connect click and complicates the browser-facing flow. Eager keeps consent fast and surfaces misconfiguration to the admin at registration.
3. code_verifier storage — short-lived server-side, keyed by signed state¶
PKCE requires the code_verifier (generated at authorize time) to survive until the token exchange,
and it must never reach the browser. The BFF is stateless on Cloud Run and its state is
Ed25519-signed but browser-visible, so the verifier cannot ride in state.
Decision: the transformer generates the PKCE pair, stores the code_verifier in a
short-lived server-side record keyed by the signed state (TTL ~10 min, single-use, deleted on
exchange), and returns only the authorize URL (carrying the code_challenge) to the BFF. On
callback the BFF passes state back; the transformer looks up + consumes the verifier. The secret
stays entirely server-side in the transformer, next to the other OAuth material.
Rejected alternatives:
- Signed-state-carried verifier — would expose the verifier to the browser (state is a visible query param); signing ≠ encryption. Rejected on security grounds.
- httpOnly SameSite=None cookie on the BFF — viable, but adds cross-site-cookie fragility (the
same class of issue that already forced us to trust signed
stateover the session cookie), and spreads PKCE state across two services. Rejected for simplicity + robustness.
4. Resource indicator (RFC 8707) + scope selection¶
- The canonical
resourcecomes from the protected-resource metadata (falling back to the probed MCP URL) and is sent on both/authorizeand/token(and on refresh where the auth server requires it) so tokens are audience-bound to this MCP server. - Scopes: use the auth server's
scopes_supportedwhen advertised; otherwise omitscopeand let the server apply its default. No per-provider scope config.
5. Backward-compatibility guarantees¶
- A connector with
oauth_app_config(static creds) is untouched — same authorize URL, same exchange, no PKCE unless opted in. The Notion API app connector keeps working verbatim. - All new consent-core parameters (
code_challenge,resource,code_verifier) are optional; absent them the behavior is byte-for-byte M4. - The mcp-proxy forward path (COA-1325) and token refresh (COA-1565) are unchanged — they already carry the per-user vault token.
Consequences¶
- An admin pastes an MCP URL → a working, per-user-auth connector, no client credentials entered.
- One code path serves both static and MCP-native connectors; the divergence is data, not branches.
- New surfaces to operate: the DCR client lifecycle (re-registration) and the short-lived verifier store (TTL/cleanup). Both are server-side and covered by the sub-issue tests.
Implementation sequencing (sub-issues)¶
| Issue | Slice | Status |
|---|---|---|
| COA-1876 | Metadata discovery (WWW-Authenticate → protected-resource → auth-server) | ✅ |
| COA-1877 | Dynamic Client Registration (RFC 7591) | ✅ |
| COA-1878 | PKCE (RFC 7636) + resource indicator (RFC 8707) in the consent core | ✅ |
| COA-1879 | Auto-config connector registration by URL | ✅ |
| COA-1880 | Wire discovery + DCR + PKCE into the per-user consent flow (verifier store) | ▶ |
| COA-1881 | End-to-end tests against a mock spec-compliant provider | ▢ |
| COA-1882 | Gate: connect Notion + Linear zero-config, two users, per-user attribution | ▢ |
References¶
MCP Authorization spec; RFC 9728 (protected-resource metadata), RFC 8414 (auth-server metadata), RFC 7591 (Dynamic Client Registration), RFC 8707 (resource indicators), OAuth 2.1 / RFC 7636 (PKCE).