ADR-044: MCP OAuth 2.1 Gateway Proxy Architecture
Metadataβ
| Field | Value |
|---|---|
| Status | β Accepted |
| Date | 2026-02-15 |
| Decision Makers | Platform Team |
| Linear | CAB-1281 |
Related Decisionsβ
- ADR-024: Gateway Unified Modes β edge-mcp mode (current)
- ADR-039: mTLS Cert-Bound Tokens β mTLS bypass for OAuth paths
- ADR-041: Plugin Architecture β community vs enterprise feature gates
- CAB-1094: MCP OAuth 2.1 implementation (PRs #528, #532, #541)
Contextβ
STOA's MCP Gateway enables AI agents (Claude.ai, ChatGPT, custom clients) to interact with enterprise APIs via the Model Context Protocol. MCP 2025-03-26 mandates OAuth 2.1 for authentication, using RFC 9728 (Protected Resource Metadata) for discovery.
The Problemβ
When Claude.ai connects to ${STOA_GATEWAY_URL}/mcp/sse, it follows this OAuth discovery chain:
1. GET /.well-known/oauth-protected-resource β reads authorization_servers[0]
2. GET /.well-known/oauth-authorization-server β reads registration_endpoint, token_endpoint
3. POST /oauth/register β Dynamic Client Registration (DCR)
4. GET /authorize (browser) β User consent + PKCE
5. POST /oauth/token β Exchange code for access token
Direct Keycloak integration fails at multiple points:
-
Keycloak metadata doesn't advertise
"none"auth method β MCP clients are public (browser-based, no client_secret). Keycloak's/.well-known/openid-configurationonly listsclient_secret_basicandclient_secret_post, causing clients to fail at step 3. -
Keycloak DCR replaces realm scope defaults β When a client sends
scope: "openid profile stoa:read"in the DCR payload, Keycloak treats this as an exhaustive scope list, removing all realm default scopes (profile,email,roles,web-origins). The client then fails authorization withinvalid_scope. -
Keycloak DCR creates confidential clients β DCR defaults to
publicClient: false, requiring aclient_secretthat browser-based MCP clients cannot securely store. -
mTLS blocks OAuth discovery β When mTLS is enabled (ADR-039), the extraction middleware returns
MTLS_CERT_REQUIRED(401) before the client can discover OAuth endpoints, preventing theWWW-Authenticate: Bearerchallenge that triggers the OAuth flow.
Production Incidentsβ
These issues were discovered during Claude.ai integration (Feb 2026):
| PR | Issue | Root Cause | Fix |
|---|---|---|---|
| #528 | OAuth endpoints unreachable | mTLS middleware blocks before OAuth challenge | Hardcoded bypass list for OAuth/MCP paths |
| #532 | token_endpoint_auth_methods missing "none" | Keycloak metadata doesn't advertise public client support | Gateway serves curated metadata |
| #541 | invalid_scope during authorization | DCR scope field replaces Keycloak realm defaults | Gateway strips scope from DCR payload |
Decisionβ
Gateway as OAuth Metadata Curator + Proxyβ
The STOA Gateway serves as an intermediary between MCP clients and Keycloak, with three responsibilities:
1. Curated OAuth Discovery (RFC 9728 + RFC 8414)β
The gateway serves its own /.well-known/oauth-protected-resource (RFC 9728) and /.well-known/oauth-authorization-server (RFC 8414) endpoints with curated metadata:
RFC 9728 (Protected Resource):
resource: ${STOA_GATEWAY_URL}
authorization_servers: [${STOA_GATEWAY_URL}] β gateway, NOT Keycloak
scopes_supported: [openid, profile, email, stoa:read, stoa:write, stoa:admin]
RFC 8414 (Authorization Server):
issuer: ${STOA_AUTH_URL}/realms/stoa β Keycloak (JWT validation)
authorization_endpoint: ${STOA_AUTH_URL}/... β Keycloak (browser-facing)
token_endpoint: ${STOA_GATEWAY_URL}/oauth/token β gateway proxy
registration_endpoint: ${STOA_GATEWAY_URL}/oauth/register β gateway proxy
token_endpoint_auth_methods_supported: ["none", "client_secret_basic", "client_secret_post"]
code_challenge_methods_supported: ["S256"]
Key invariant: authorization_servers in RFC 9728 MUST point to the gateway URL. This ensures clients discover the gateway's curated metadata (with "none" auth method) instead of Keycloak's native metadata.
2. DCR Proxy with Payload Normalizationβ
POST /oauth/register proxies to Keycloak's DCR endpoint with two modifications:
-
Scope stripping β removes the
scopefield from the DCR payload before forwarding. This preserves Keycloak's realm default scopes instead of replacing them. -
Public client patching β after successful DCR, the gateway uses the Keycloak Admin API to:
- Set
publicClient: true(removes client_secret requirement) - Set
pkce.code.challenge.method: S256(enables PKCE)
- Set
This two-step approach (DCR + admin patch) is necessary because Keycloak's DCR API does not support publicClient or PKCE attributes directly.
3. Token Proxyβ
POST /oauth/token is a transparent proxy to Keycloak's token endpoint. No modification β forwards request and response as-is. This ensures token exchange works through the gateway URL advertised in metadata.
mTLS Bypass (Hardcoded)β
OAuth and MCP discovery paths bypass mTLS extraction middleware unconditionally:
Bypass prefixes: /.well-known/, /oauth/, /mcp/sse, /mcp/tools/, /mcp/v1/
Bypass exact: /mcp, /mcp/capabilities, /mcp/health, /health, /ready, /metrics
This list is hardcoded, not configurable β a deliberate design decision. A misconfigured bypass list would silently break MCP OAuth for all clients, with no obvious error message (mTLS returns 401 before the OAuth challenge).
MCP Protocol Negotiationβ
Claude.ai requests MCP protocol version 2025-11-25. The gateway negotiates down to 2025-03-26 (the latest version the gateway implements). This negotiation is transparent β the client adapts to the negotiated version.
Architecture Diagramβ
Claude.ai (MCP Client)
β
β 1. GET /.well-known/oauth-protected-resource
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β STOA Gateway (${STOA_GATEWAY_URL}) β
β β
β ββ discovery.rs ββββββββββββββββββββββββββββββββββ β
β β RFC 9728: authorization_servers β [gateway] β β
β β RFC 8414: token_endpoint β gateway/oauth/tokenβ β
β β registration β gateway/oauth/registerβ
β β authorization β Keycloak/auth β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β ββ proxy.rs ββββββββββββββββββββββββββββββββββββββ β
β β POST /oauth/register β β
β β 1. Strip `scope` from payload β β
β β 2. Forward to Keycloak DCR β β
β β 3. Patch client β public + PKCE S256 β β
β β β β
β β POST /oauth/token β β
β β β Transparent proxy to Keycloak β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β
β ββ mtls.rs βββββββββββββββββββββββββββββββββββββββ β
β β is_mtls_bypass_path() β hardcoded bypass list β β
β β OAuth/MCP paths skip mTLS entirely β β
β ββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββ
β
β DCR + token + admin API
βΌ
βββββββββββββββββββββββββ
β Keycloak (IdP) β
β ${STOA_AUTH_URL} β
β β
β - Realm: stoa β
β - DCR: enabled β
β - PKCE: supported β
β - Scopes: openid, β
β stoa:read/write/ β
β admin β
βββββββββββββββββββββββββ
Rejected Alternativesβ
Alternative 1: Direct Keycloak Metadata (No Proxy)β
Point authorization_servers directly to Keycloak (${STOA_AUTH_URL}/realms/stoa). Let MCP clients interact with Keycloak natively.
Rejected because:
- Keycloak metadata doesn't advertise
token_endpoint_auth_methods: ["none"]β public MCP clients cannot register - No opportunity to normalize DCR payloads (scope stripping)
- No opportunity to patch clients to public + PKCE
- mTLS bypass would need Keycloak-side configuration (not in our control for managed Keycloak deployments)
Alternative 2: Keycloak SPI Extensionβ
Write a Keycloak SPI plugin to customize DCR behavior (auto-public client, scope normalization, metadata override).
Rejected because:
- Keycloak SPI development is complex and version-coupled (API breaks between KC 23/24/25)
- Deployment friction: custom JAR in every Keycloak instance
- Not portable to other IdPs (Auth0, Okta, Azure AD)
- Gateway proxy is simpler, testable, and IdP-agnostic
Alternative 3: Configurable Bypass Listβ
Make the mTLS bypass paths configurable via environment variable or config file.
Rejected because:
- A misconfigured bypass list silently breaks MCP OAuth for all clients
- The error mode is opaque: mTLS returns 401 before OAuth challenge, giving no hint about the root cause
- The bypass paths are protocol-defined (RFC 9728, MCP spec) and should not change
- Hardcoded list is simpler, safer, and self-documenting
Keycloak Version Compatibilityβ
| Feature | KC 23 | KC 24 | KC 25 | Notes |
|---|---|---|---|---|
| DCR endpoint | β | β | β | Stable across versions |
scope in DCR replaces defaults | β | β | β | Consistent behavior (by design) |
| Admin API client patch | β | β | β | PUT /admin/realms/{r}/clients/{id} |
publicClient attribute | β | β | β | Supported in all versions |
| PKCE attributes | β | β | β | Via attributes.pkce.code.challenge.method |
| Allowed Client Scopes policy | β | β | β | Component-based policy in DCR |
Known risk: future Keycloak versions may change DCR scope handling behavior. The scope stripping logic in the gateway is a safe hedge β if KC stops replacing defaults, stripping scope is a no-op (no harm).
Competitive Contextβ
As of February 2026, STOA is the only open-source API gateway implementing the full MCP OAuth 2.1 flow:
- RFC 9728 Protected Resource Metadata discovery
- RFC 8414 Authorization Server Metadata with curated public client support
- Dynamic Client Registration with scope normalization and PKCE patching
- mTLS coexistence β OAuth and mTLS on the same gateway without conflict
Kong, Gravitee, and Tyk support OAuth 2.0 for API consumers but none implement MCP-specific OAuth discovery or DCR proxy patterns.
Consequencesβ
Positiveβ
- MCP clients work out of the box β no Keycloak configuration required beyond DCR enablement and scope creation
- IdP-agnostic β the proxy pattern can work with any OAuth 2.0/OIDC provider, not just Keycloak
- Safe mTLS coexistence β hardcoded bypass prevents config-level mistakes
- Testable β each proxy function has unit tests with wiremock (15 tests in
proxy.rs, 7 indiscovery.rs)
Negativeβ
- Dependency on Keycloak Admin API β PKCE patching requires
KEYCLOAK_ADMIN_PASSWORDenv var. Without it, clients are created as confidential (logged as warning, not fatal) - Two-step DCR β client registration + admin patch is not atomic. A crash between steps leaves a confidential client in Keycloak (harmless but untidy)
- Metadata drift risk β if Keycloak adds new grant types or scopes, the gateway's curated metadata must be updated manually
Regression Protectionβ
| Layer | Tests | What's Covered |
|---|---|---|
Unit (proxy.rs) | 15 | Token proxy, DCR proxy, scope stripping, error cases |
Unit (discovery.rs) | 7 | Metadata fields, URL construction, defaults |
Unit (mtls.rs) | 4 | Bypass paths (OAuth, MCP, infra, non-bypass) |
Contract (oauth.rs) | 3 | Snapshot: authorization_servers, metadata shape |
Integration (mcp.rs) | 12 | OAuth discovery endpoints, metadata validation |
| E2E (bash) | 9 steps | Full flow: discovery β DCR β token (manual) |