From 1045eb47c2b46af4abc4ef7adec5b368d97415ac Mon Sep 17 00:00:00 2001
From: Jeremiah Lowin <153965+jlowin@users.noreply.github.com>
Date: Wed, 3 Sep 2025 12:11:38 -0400
Subject: [PATCH] OAuth proxy PKCE forwarding (#1733)
---
docs/servers/auth/oauth-proxy.mdx | 423 +++++++++++--------------
src/fastmcp/server/auth/oauth_proxy.py | 93 +++++-
tests/server/auth/test_oauth_proxy.py | 125 ++++++++
3 files changed, 388 insertions(+), 253 deletions(-)
diff --git a/docs/servers/auth/oauth-proxy.mdx b/docs/servers/auth/oauth-proxy.mdx
index 5f85577ab..d60fd2c98 100644
--- a/docs/servers/auth/oauth-proxy.mdx
+++ b/docs/servers/auth/oauth-proxy.mdx
@@ -1,123 +1,84 @@
---
title: OAuth Proxy
sidebarTitle: OAuth Proxy
-description: Enable authentication with OAuth providers that don't support Dynamic Client Registration.
+description: Bridge traditional OAuth providers to work seamlessly with MCP's authentication flow.
icon: share
tag: NEW
---
-import { VersionBadge } from "/snippets/version-badge.mdx"
+import { VersionBadge } from "/snippets/version-badge.mdx";
-OAuth Proxy enables your FastMCP server to authenticate with OAuth providers that **don't support Dynamic Client Registration (DCR)**. This includes virtually all traditional OAuth providers: GitHub, Google, Azure, Facebook, Discord, and most enterprise identity systems.
+OAuth Proxy enables FastMCP servers to authenticate with OAuth providers that **don't support Dynamic Client Registration (DCR)**. This includes virtually all traditional OAuth providers: GitHub, Google, Azure, Discord, Facebook, and most enterprise identity systems. For providers that do support DCR (like WorkOS AuthKit), use [`RemoteAuthProvider`](/servers/auth/remote-oauth) instead.
-While MCP clients expect to dynamically register and obtain credentials, these providers require manual app registration through their developer consoles. OAuth Proxy bridges this gap by presenting a DCR-compliant interface to MCP clients while using your pre-registered credentials with the upstream provider.
+MCP clients expect to register automatically and obtain credentials on the fly, but traditional providers require manual app registration through their developer consoles. OAuth Proxy bridges this gap by presenting a DCR-compliant interface to MCP clients while using your pre-registered credentials with the upstream provider. When a client attempts to register, the proxy returns your fixed credentials. When a client initiates authorization, the proxy handles the complexity of callback forwarding—storing the client's dynamic callback URL, using its own fixed callback with the provider, then forwarding back to the client after token exchange.
-
-**When to use OAuth Proxy vs RemoteAuthProvider:**
-- **OAuth Proxy**: For providers WITHOUT Dynamic Client Registration (GitHub, Google, Azure, etc.)
-- **RemoteAuthProvider**: For providers WITH Dynamic Client Registration (WorkOS AuthKit, etc.)
+This approach enables any MCP client (whether using random localhost ports or fixed URLs like Claude.ai) to authenticate with any traditional OAuth provider, all while maintaining full OAuth 2.1 and PKCE security.
-OAuth Proxy makes traditional OAuth providers work seamlessly with MCP's automated authentication flow.
-
+## Implementation
-## DCR vs Non-DCR Providers
+### Provider Setup Requirements
-The key distinction in MCP authentication is whether your OAuth provider supports **Dynamic Client Registration (DCR)**:
+Before using OAuth Proxy, you need to register your application with your OAuth provider:
-- **Providers WITH DCR** (WorkOS, some OIDC providers): Use [`RemoteAuthProvider`](/servers/auth/remote-oauth)
- - Clients can register themselves automatically
- - No manual app registration needed
- - True dynamic authentication flow
+1. **Register your application** in the provider's developer console (GitHub Settings, Google Cloud Console, Azure Portal, etc.)
+2. **Configure the redirect URI** as your FastMCP server URL plus your chosen callback path:
+ - Default: `https://your-server.com/auth/callback`
+ - Custom: `https://your-server.com/your/custom/path` (if you set `redirect_path`)
+ - Development: `http://localhost:8000/auth/callback`
+3. **Obtain your credentials**: Client ID and Client Secret
+4. **Note the OAuth endpoints**: Authorization URL and Token URL (usually found in the provider's OAuth documentation)
-- **Providers WITHOUT DCR** (GitHub, Google, Azure, Discord, etc.): Use `OAuthProxy` (this guide)
- - Requires manual app registration in provider's console
- - You obtain fixed client ID and secret
- - OAuth Proxy bridges the gap for MCP compatibility
+
+ The redirect URI you configure with your provider must exactly match your
+ FastMCP server's URL plus the callback path. If you customize `redirect_path`
+ in OAuth Proxy, update your provider's redirect URI accordingly.
+
-OAuth Proxy makes non-DCR providers work seamlessly with MCP by implementing a local DCR interface that always returns your pre-registered credentials.
+### Basic Setup
-## Understanding the DCR Gap
+Here's how to implement OAuth Proxy with any provider:
-**Dynamic Client Registration (DCR)** allows OAuth clients to automatically register themselves with an authorization server and obtain credentials without manual intervention. The MCP specification is designed around this capability, expecting clients to register dynamically.
+```python
+from fastmcp import FastMCP
+from fastmcp.server.auth import OAuthProxy
+from fastmcp.server.auth.providers.jwt import JWTVerifier
-However, most OAuth providers don't support DCR:
+# Configure token verification for your provider
+# See the Token Verification guide for provider-specific setups
+token_verifier = JWTVerifier(
+ jwks_uri="https://your-provider.com/.well-known/jwks.json",
+ issuer="https://your-provider.com",
+ audience="your-app-id"
+)
-| Provider Type | DCR Support | Registration Method | Examples |
-|--------------|-------------|--------------------|-----------|
-| Modern Auth Platforms | ✅ Yes | Automatic via API | WorkOS AuthKit, Some OIDC providers |
-| Traditional OAuth | ❌ No | Manual via console | GitHub, Google, Azure, Discord, Facebook |
-| Enterprise SSO | ❌ No | IT Administrator | Okta, AD FS, PingIdentity |
+# Create the OAuth proxy
+auth = OAuthProxy(
+ # Provider's OAuth endpoints (from their documentation)
+ upstream_authorization_endpoint="https://provider.com/oauth/authorize",
+ upstream_token_endpoint="https://provider.com/oauth/token",
-Providers without DCR require you to:
-- Manually register applications through their developer console
-- Obtain fixed client IDs and secrets that never change
-- Pre-configure specific redirect URIs
-- Manage credentials through their web interface
+ # Your registered app credentials
+ upstream_client_id="your-client-id",
+ upstream_client_secret="your-client-secret",
-This creates a fundamental incompatibility: MCP clients expect to call a registration endpoint and receive credentials, but traditional providers only work with pre-registered apps. OAuth Proxy solves this by accepting any client registration request and returning your fixed upstream credentials.
+ # Token validation (see Token Verification guide)
+ token_verifier=token_verifier,
-## How OAuth Proxy Works
-
-The OAuth Proxy implements an intelligent callback forwarding pattern that solves both the DCR problem and the redirect URI mismatch issue:
-
-```mermaid
-sequenceDiagram
- participant Client as MCP Client
(localhost:random)
- participant Proxy as FastMCP OAuth Proxy
(server:8000)
- participant Provider as OAuth Provider
(GitHub, etc.)
-
- Note over Client, Proxy: Dynamic Registration (Local)
- Client->>Proxy: 1. POST /register
redirect_uri: localhost:54321/callback
- Proxy-->>Client: 2. Returns fixed upstream credentials
-
- Note over Client, Proxy: Authorization with Callback Forwarding
- Client->>Proxy: 3. GET /authorize
redirect_uri=localhost:54321/callback
- Note over Proxy: Store transaction with client callback
- Proxy->>Provider: 4. Redirect to provider
redirect_uri=server:8000/auth/callback
+ # Your FastMCP server's public URL
+ base_url="https://your-server.com",
- Note over Provider, Proxy: Provider Callback
- Provider->>Proxy: 5. GET /auth/callback
with authorization code
- Proxy->>Provider: 6. Exchange code for tokens
- Provider-->>Proxy: 7. Access & refresh tokens
-
- Note over Proxy, Client: Client Callback Forwarding
- Proxy->>Client: 8. Redirect to localhost:54321/callback
with new authorization code
-
- Note over Client, Proxy: Token Exchange
- Client->>Proxy: 9. POST /token with code
- Proxy-->>Client: 10. Returns stored provider tokens
+ # Optional: customize the callback path (default is "/auth/callback")
+ # redirect_path="/custom/callback",
+)
+
+mcp = FastMCP(name="My Server", auth=auth)
```
-### The Callback Forwarding Pattern
+### Configuration Parameters
-OAuth Proxy implements an innovative callback forwarding pattern that solves the redirect URI mismatch problem:
-
-**The Challenge:**
-- MCP clients listen on random localhost ports (e.g., `http://localhost:54321/callback`)
-- Each client session uses a different port
-- OAuth providers only accept pre-registered, fixed redirect URIs
-- Registering every possible localhost port is impossible
-
-**The Solution:**
-
-The proxy acts as an intermediary callback handler:
-
-1. **Dynamic Registration**: Client provides its localhost callback URL during registration
-2. **Transaction Tracking**: Proxy stores the client's callback URL with a transaction ID
-3. **Fixed Provider Callback**: Proxy uses its own fixed callback URL with the provider
-4. **Server-Side Token Exchange**: Proxy receives the provider's callback and exchanges the authorization code for tokens
-5. **Client Forwarding**: Proxy redirects to the client's original localhost callback with a new authorization code
-6. **Token Delivery**: Client exchanges this new code with the proxy to receive the provider's tokens
-
-This pattern maintains full OAuth 2.1 security (including PKCE) while enabling dynamic client ports to work with fixed provider callbacks. The client never knows it's talking to a proxy - it experiences a standard DCR flow.
-
-## Basic Implementation
-
-The `OAuthProxy` class provides the complete proxy implementation:
-
-
+
URL of your OAuth provider's authorization endpoint (e.g., `https://github.com/login/oauth/authorize`)
@@ -158,216 +119,194 @@ The `OAuthProxy` class provides the complete proxy implementation:
Optional URL to your service documentation
-
+
+ Whether to forward PKCE (Proof Key for Code Exchange) to the upstream OAuth provider. When enabled and the client uses PKCE, the proxy generates its own PKCE parameters to send upstream while separately validating the client's PKCE. This ensures end-to-end PKCE security at both layers (client-to-proxy and proxy-to-upstream).
+ - `True` (default): Forward PKCE for providers that support it (Google, Azure, GitHub, etc.)
+ - `False`: Disable only if upstream provider doesn't support PKCE
+
- List of allowed redirect URI patterns for MCP clients. Patterns support wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`).
+ List of allowed redirect URI patterns for MCP clients. Patterns support wildcards (e.g., `"http://localhost:*"`, `"https://*.example.com/*"`).
- `None` (default): All redirect URIs allowed (for MCP/DCR compatibility)
- Empty list `[]`: No redirect URIs allowed
- Custom list: Only matching patterns allowed
These patterns apply to MCP client loopback redirects, NOT the upstream OAuth app redirect URI.
+
+
+ List of all possible valid scopes for the OAuth provider. These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` from your TokenVerifier if not specified.
+
-### Dynamic client scope
+### Using Built-in Providers
-When `OAuthProxy` creates a dynamic client (`ProxyDCRClient`) during registration or for temporary/unregistered access, it sets the client's `scope` string from your `TokenVerifier.required_scopes` (joined with spaces). If `required_scopes` is empty or `None`, the client's `scope` will be an empty string.
+FastMCP includes pre-configured providers for common services:
```python
-from fastmcp.server.auth.providers.jwt import JWTVerifier
-from fastmcp.server.auth.oauth_proxy import OAuthProxy
-
-token_verifier = JWTVerifier(
- jwks_uri="https://provider/.well-known/jwks.json",
- issuer="https://provider",
- audience="my-app",
-)
-token_verifier.required_scopes = ["read", "write"]
-
-auth = OAuthProxy(
- upstream_authorization_endpoint="https://provider/authorize",
- upstream_token_endpoint="https://provider/token",
- upstream_client_id="cid",
- upstream_client_secret="secret",
- token_verifier=token_verifier,
- base_url="https://your-server.com",
-)
-
-# Any dynamic client created by the proxy will have scope "read write"
-```
-
-```python
-from fastmcp import FastMCP
-from fastmcp.server.auth import OAuthProxy
-from fastmcp.server.auth.providers.jwt import JWTVerifier
-
-# Configure token validation for your provider
-token_verifier = JWTVerifier(
- jwks_uri="https://your-provider.com/.well-known/jwks.json",
- issuer="https://your-provider.com",
- audience="your-app-id"
-)
-
-# Create the OAuth proxy (accepts strings for URLs)
-auth = OAuthProxy(
- # Upstream provider endpoints
- upstream_authorization_endpoint="https://your-provider.com/oauth/authorize",
- upstream_token_endpoint="https://your-provider.com/oauth/token",
-
- # Your registered app credentials
- upstream_client_id="your-registered-client-id",
- upstream_client_secret="your-registered-client-secret",
-
- # Token validation
- token_verifier=token_verifier,
-
- # Your FastMCP server URL (string automatically converted to AnyHttpUrl)
- base_url="https://your-server.com",
-
- # Optional: customize callback path (defaults to "/auth/callback")
- redirect_path="/auth/callback",
+from fastmcp.server.auth.providers.github import GitHubProvider
+auth = GitHubProvider(
+ client_id="your-github-app-id",
+ client_secret="your-github-app-secret",
+ base_url="https://your-server.com"
)
mcp = FastMCP(name="My Server", auth=auth)
```
-### OAuth Provider Configuration
+Available providers include `GitHubProvider`, `GoogleProvider`, and others. These handle token verification automatically.
-When registering your application with your OAuth provider, configure the redirect/callback URL as:
+### Scope Configuration
-```
-https://your-server.com/auth/callback
-```
-
-For local development with providers that support it (like GitHub):
-
-```
-http://localhost:8000/auth/callback
-```
-
-The proxy automatically:
-- Implements DCR by returning your fixed credentials to any client that registers
-- Handles callback forwarding between dynamic client callbacks and your fixed provider callback
-- Exchanges authorization codes server-side for enhanced security
-- Validates tokens using your provider's public keys or API
-- Maintains PKCE security throughout the flow
-
-## Client Redirect URI Security
-
-
-OAuth Proxy accepts all redirect URIs by default to maintain compatibility with MCP's Dynamic Client Registration (DCR) pattern, where clients register with unpredictable redirect URIs.
-
-If you know which clients will connect, you can restrict redirect URIs using the `allowed_client_redirect_uris` parameter:
+OAuth scopes are configured through your `TokenVerifier`. Set `required_scopes` to automatically request the permissions your application needs:
```python
-# Default: allow all (for DCR compatibility)
-auth = OAuthProxy(...)
+JWTVerifier(..., required_scopes = ["read:user", "write:data"])
+```
-# Restrict to localhost only
+Dynamic clients created by the proxy will automatically include these scopes in their authorization requests.
+
+## How It Works
+
+```mermaid
+sequenceDiagram
+ participant Client as MCP Client
(localhost:random)
+ participant Proxy as FastMCP OAuth Proxy
(server:8000)
+ participant Provider as OAuth Provider
(GitHub, etc.)
+
+ Note over Client, Proxy: Dynamic Registration (Local)
+ Client->>Proxy: 1. POST /register
redirect_uri: localhost:54321/callback
+ Proxy-->>Client: 2. Returns fixed upstream credentials
+
+ Note over Client, Proxy: Authorization with PKCE & Callback Forwarding
+ Client->>Proxy: 3. GET /authorize
redirect_uri=localhost:54321/callback
code_challenge=CLIENT_CHALLENGE
+ Note over Proxy: Store transaction with client PKCE
Generate proxy PKCE pair
+ Proxy->>Provider: 4. Redirect to provider
redirect_uri=server:8000/auth/callback
code_challenge=PROXY_CHALLENGE
+
+ Note over Provider, Proxy: Provider Callback
+ Provider->>Proxy: 5. GET /auth/callback
with authorization code
+ Proxy->>Provider: 6. Exchange code for tokens
code_verifier=PROXY_VERIFIER
+ Provider-->>Proxy: 7. Access & refresh tokens
+
+ Note over Proxy, Client: Client Callback Forwarding
+ Proxy->>Client: 8. Redirect to localhost:54321/callback
with new authorization code
+
+ Note over Client, Proxy: Token Exchange
+ Client->>Proxy: 9. POST /token with code
code_verifier=CLIENT_VERIFIER
+ Proxy-->>Client: 10. Returns stored provider tokens
+```
+
+The flow diagram above illustrates the complete OAuth Proxy pattern. Let's understand each phase:
+
+### Registration Phase
+
+When an MCP client calls `/register` with its dynamic callback URL, the proxy responds with your pre-configured upstream credentials. The client stores these credentials believing it has registered a new app. Meanwhile, the proxy records the client's callback URL for later use.
+
+### Authorization Phase
+
+The client initiates OAuth by redirecting to the proxy's `/authorize` endpoint. The proxy:
+
+1. Stores the client's transaction with its PKCE challenge
+2. Generates its own PKCE parameters for upstream security
+3. Redirects to the upstream provider using the fixed callback URL
+
+This dual-PKCE approach maintains end-to-end security at both the client-to-proxy and proxy-to-provider layers.
+
+### Callback Phase
+
+After user authorization, the provider redirects back to the proxy's fixed callback URL. The proxy:
+
+1. Exchanges the authorization code for tokens with the provider
+2. Stores these tokens temporarily
+3. Generates a new authorization code for the client
+4. Redirects to the client's original dynamic callback URL
+
+### Token Exchange Phase
+
+Finally, the client exchanges its authorization code with the proxy to receive the provider's tokens. The proxy validates the client's PKCE verifier before returning the stored tokens.
+
+This entire flow is transparent to the MCP client—it experiences a standard OAuth flow with dynamic registration, unaware that a proxy is managing the complexity behind the scenes.
+
+### PKCE Forwarding
+
+OAuth Proxy automatically handles PKCE (Proof Key for Code Exchange) when working with providers that support or require it. The proxy generates its own PKCE parameters to send upstream while separately validating the client's PKCE, ensuring end-to-end security at both layers.
+
+This is enabled by default via the `forward_pkce` parameter and works seamlessly with providers like Google, Azure AD, and GitHub. Only disable it for legacy providers that don't support PKCE:
+
+```python
+# Disable PKCE forwarding only if upstream doesn't support it
auth = OAuthProxy(
...,
+ forward_pkce=False # Default is True
+)
+```
+
+### Redirect URI Validation
+
+While OAuth Proxy accepts all redirect URIs by default (for DCR compatibility), you can restrict which clients can connect by specifying allowed patterns:
+
+```python
+# Allow only localhost clients (common for development)
+auth = OAuthProxy(
+ # ... other parameters ...
allowed_client_redirect_uris=[
"http://localhost:*",
"http://127.0.0.1:*"
]
)
-# Allow specific known clients (e.g., Claude.ai)
+# Allow specific known clients
auth = OAuthProxy(
- ...,
+ # ... other parameters ...
allowed_client_redirect_uris=[
"http://localhost:*",
- "https://claude.ai/api/mcp/auth_callback"
- ]
-)
-
-# Custom patterns with wildcards
-auth = OAuthProxy(
- ...,
- allowed_client_redirect_uris=[
- "http://localhost:*",
- "https://*.example.com/auth/*"
+ "https://claude.ai/api/mcp/auth_callback",
+ "https://*.mycompany.com/auth/*" # Wildcard patterns supported
]
)
```
-**Tip:** Check your server logs for debug messages that say "Client registered with redirect_uri" messages to see what redirect URIs your clients are using.
-
+Check your server logs for "Client registered with redirect_uri" messages to identify what URLs your clients use.
-## Client Compatibility
+## Token Verification
-
-The OAuth Proxy's callback forwarding enables **any MCP client** to authenticate with **any OAuth provider**, regardless of redirect URI restrictions. Clients can use dynamic localhost ports while providers see their expected fixed callbacks.
-
+OAuth Proxy requires a compatible `TokenVerifier` to validate tokens from your provider. Different providers use different token formats:
-This breakthrough means that MCP clients no longer need to worry about registering specific callback URLs with OAuth providers. The proxy handles the complexity of bridging dynamic client callbacks with the fixed URLs that providers require. The entire flow maintains OAuth 2.1 and PKCE (RFC-7636) compliance for security.
+- **JWT tokens** (Google, Azure): Use `JWTVerifier` with the provider's JWKS endpoint
+- **Opaque tokens** (GitHub, Discord): Use provider-specific verifiers or implement custom validation
-## Token Verification Strategies
-
-Different OAuth providers use different token formats, requiring appropriate verification strategies:
-
-### Provider Token Types
-
-| Provider | Token Type | Verification Method | Built-in Support |
-|----------|-----------|--------------------|-----------------|
-| GitHub | Opaque | API validation (`/user` endpoint) | ✅ `GitHubProvider` |
-| Google | JWT | JWKS signature verification | ✅ `GoogleProvider` |
-| Azure AD | JWT | JWKS signature verification | Configure `JWTVerifier` |
-| Discord | Opaque | API validation | ✅ `DiscordOAuthProxyProvider` |
-| Custom | Varies | Implement `TokenVerifier` | Extend base class |
-
-### Using Built-in Providers
-
-FastMCP includes pre-configured providers that handle token verification automatically:
-
-```python
-from fastmcp.server.auth.providers.github import GitHubProvider
-
-# GitHub provider with automatic API-based token validation
-auth = GitHubProvider(
- client_id="your-github-client-id",
- client_secret="your-github-client-secret",
- base_url="https://your-server.com"
-)
-```
-
-### Custom Token Verification
-
-For providers without built-in support, implement a [`TokenVerifier`](/servers/auth/token-verification):
-
-- **JWT tokens**: Use `JWTVerifier` with the provider's JWKS endpoint
-- **Opaque tokens**: Extend `TokenVerifier` to validate via the provider's API
-- **Hybrid approaches**: Combine multiple verification methods as needed
+See the [Token Verification guide](/servers/auth/token-verification) for detailed setup instructions for your provider.
## Environment Configuration
-OAuth Proxy-based providers support environment-based configuration for production deployments. Use the specific provider implementations rather than the base OAuth Proxy class:
+For production deployments, configure OAuth Proxy through environment variables instead of hardcoding credentials:
```bash
-# Use a specific provider implementation
+# Specify the provider implementation
export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.github.GitHubProvider
-# or
-export FASTMCP_SERVER_AUTH=fastmcp.server.auth.providers.google.GoogleProvider
-# or your custom OAuth Proxy implementation
-export FASTMCP_SERVER_AUTH=mycompany.auth.CustomOAuthProvider
-# Provider-specific configuration (example for GitHub)
+# Provider-specific credentials
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="Ov23li..."
export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="abc123..."
-export FASTMCP_SERVER_AUTH_GITHUB_BASE_URL="https://your-server.com"
+export FASTMCP_SERVER_AUTH_GITHUB_BASE_URL="https://your-production-server.com"
```
-For custom OAuth Proxy implementations, configure the environment variables based on your provider's settings class.
-
-With environment variables configured, your code becomes:
+With environment configuration, your server code simplifies to:
```python
from fastmcp import FastMCP
# Authentication automatically configured from environment
mcp = FastMCP(name="My Server")
-```
\ No newline at end of file
+
+@mcp.tool
+def protected_tool(data: str) -> str:
+ """This tool is now protected by OAuth."""
+ return f"Processed: {data}"
+
+if __name__ == "__main__":
+ mcp.run(transport="http", port=8000)
+```
diff --git a/src/fastmcp/server/auth/oauth_proxy.py b/src/fastmcp/server/auth/oauth_proxy.py
index c70b12edc..dcb7d56d5 100644
--- a/src/fastmcp/server/auth/oauth_proxy.py
+++ b/src/fastmcp/server/auth/oauth_proxy.py
@@ -18,12 +18,15 @@ production use with enterprise identity providers.
from __future__ import annotations
+import hashlib
import secrets
import time
+from base64 import urlsafe_b64encode
from typing import TYPE_CHECKING, Any, Final
from urllib.parse import urlencode
import httpx
+from authlib.common.security import generate_token
from authlib.integrations.httpx_client import AsyncOAuth2Client
from mcp.server.auth.provider import (
AccessToken,
@@ -243,6 +246,8 @@ class OAuthProxy(OAuthProvider):
# Client redirect URI validation
allowed_client_redirect_uris: list[str] | None = None,
valid_scopes: list[str] | None = None,
+ # PKCE configuration
+ forward_pkce: bool = True,
):
"""Initialize the OAuth proxy provider.
@@ -264,7 +269,10 @@ class OAuthProxy(OAuthProvider):
If empty list, all redirect URIs are allowed (not recommended for production).
These are for MCP clients performing loopback redirects, NOT for the upstream OAuth app.
valid_scopes: List of all the possible valid scopes for a client.
- These are advertised to clients through the `/.well-known` endpoints. Defaults to `reuqired_scopes` if not provided.
+ These are advertised to clients through the `/.well-known` endpoints. Defaults to `required_scopes` if not provided.
+ forward_pkce: Whether to forward PKCE to upstream server (default True).
+ Enable for providers that support/require PKCE (Google, Azure, etc.).
+ Disable only if upstream provider doesn't support PKCE.
"""
# Always enable DCR since we implement it locally for MCP clients
client_registration_options = ClientRegistrationOptions(
@@ -300,6 +308,9 @@ class OAuthProxy(OAuthProvider):
)
self._allowed_client_redirect_uris = allowed_client_redirect_uris
+ # PKCE configuration
+ self._forward_pkce = forward_pkce
+
# Local state for DCR and token bookkeeping
self._clients: dict[str, OAuthClientInformationFull] = {}
self._access_tokens: dict[str, AccessToken] = {}
@@ -323,6 +334,25 @@ class OAuthProxy(OAuthProvider):
self._upstream_authorization_endpoint,
)
+ # -------------------------------------------------------------------------
+ # PKCE Helper Methods
+ # -------------------------------------------------------------------------
+
+ def _generate_pkce_pair(self) -> tuple[str, str]:
+ """Generate PKCE code verifier and challenge pair.
+
+ Returns:
+ Tuple of (code_verifier, code_challenge) using S256 method
+ """
+ # Generate code verifier: 43-128 characters from unreserved set
+ code_verifier = generate_token(48)
+
+ # Generate code challenge using S256 (SHA256 + base64url)
+ challenge_bytes = hashlib.sha256(code_verifier.encode()).digest()
+ code_challenge = urlsafe_b64encode(challenge_bytes).decode().rstrip("=")
+
+ return code_verifier, code_challenge
+
# -------------------------------------------------------------------------
# Client Registration (Local Implementation)
# -------------------------------------------------------------------------
@@ -389,14 +419,25 @@ class OAuthProxy(OAuthProvider):
This implements the DCR-compliant proxy pattern:
1. Store transaction with client details and PKCE challenge
- 2. Use transaction ID as state for IdP
- 3. Redirect to IdP with our fixed callback URL
+ 2. Generate proxy's own PKCE parameters if forwarding is enabled
+ 3. Use transaction ID as state for IdP
+ 4. Redirect to IdP with our fixed callback URL and proxy's PKCE
"""
# Generate transaction ID for this authorization request
txn_id = secrets.token_urlsafe(32)
+ # Generate proxy's own PKCE parameters if forwarding is enabled
+ proxy_code_verifier = None
+ proxy_code_challenge = None
+ if self._forward_pkce and params.code_challenge:
+ proxy_code_verifier, proxy_code_challenge = self._generate_pkce_pair()
+ logger.debug(
+ "Generated proxy PKCE for transaction %s (forwarding client PKCE to upstream)",
+ txn_id,
+ )
+
# Store transaction data for IdP callback processing
- self._oauth_transactions[txn_id] = {
+ transaction_data = {
"client_id": client.client_id,
"client_redirect_uri": str(params.redirect_uri),
"client_state": params.state,
@@ -406,6 +447,12 @@ class OAuthProxy(OAuthProvider):
"created_at": time.time(),
}
+ # Store proxy's PKCE verifier if we're forwarding
+ if proxy_code_verifier:
+ transaction_data["proxy_code_verifier"] = proxy_code_verifier
+
+ self._oauth_transactions[txn_id] = transaction_data
+
# Build query parameters for upstream IdP authorization request
# Use our fixed IdP callback and transaction ID as state
query_params: dict[str, Any] = {
@@ -421,14 +468,24 @@ class OAuthProxy(OAuthProvider):
if scopes_to_use:
query_params["scope"] = " ".join(scopes_to_use)
+ # Forward proxy's PKCE challenge to upstream if enabled
+ if proxy_code_challenge:
+ query_params["code_challenge"] = proxy_code_challenge
+ query_params["code_challenge_method"] = "S256"
+ logger.debug(
+ "Forwarding proxy PKCE challenge to upstream for transaction %s",
+ txn_id,
+ )
+
# Build the upstream authorization URL
separator = "&" if "?" in self._upstream_authorization_endpoint else "?"
upstream_url = f"{self._upstream_authorization_endpoint}{separator}{urlencode(query_params)}"
logger.debug(
- "Starting OAuth transaction %s for client %s, redirecting to IdP",
+ "Starting OAuth transaction %s for client %s, redirecting to IdP (PKCE forwarding: %s)",
txn_id,
client.client_id,
+ "enabled" if proxy_code_challenge else "disabled",
)
return upstream_url
@@ -803,14 +860,28 @@ class OAuthProxy(OAuthProvider):
f"Exchanging IdP code for tokens with redirect_uri: {idp_redirect_uri}"
)
- idp_tokens: dict[str, Any] = await oauth_client.fetch_token( # type: ignore[misc]
- url=self._upstream_token_endpoint,
- code=idp_code,
- redirect_uri=idp_redirect_uri,
- )
+ # Include proxy's code_verifier if we forwarded PKCE
+ proxy_code_verifier = transaction.get("proxy_code_verifier")
+ if proxy_code_verifier:
+ logger.debug(
+ "Including proxy code_verifier in token exchange for transaction %s",
+ txn_id,
+ )
+ idp_tokens: dict[str, Any] = await oauth_client.fetch_token( # type: ignore[misc]
+ url=self._upstream_token_endpoint,
+ code=idp_code,
+ redirect_uri=idp_redirect_uri,
+ code_verifier=proxy_code_verifier,
+ )
+ else:
+ idp_tokens: dict[str, Any] = await oauth_client.fetch_token( # type: ignore[misc]
+ url=self._upstream_token_endpoint,
+ code=idp_code,
+ redirect_uri=idp_redirect_uri,
+ )
logger.debug(
- f"Successfully exchanged IdP code for tokens (transaction: {txn_id})"
+ f"Successfully exchanged IdP code for tokens (transaction: {txn_id}, PKCE: {bool(proxy_code_verifier)})"
)
except Exception as e:
diff --git a/tests/server/auth/test_oauth_proxy.py b/tests/server/auth/test_oauth_proxy.py
index 8f9c90d7a..f171f196a 100644
--- a/tests/server/auth/test_oauth_proxy.py
+++ b/tests/server/auth/test_oauth_proxy.py
@@ -594,3 +594,128 @@ class TestOAuthProxyComprehensive:
# Verify code was cleaned up
assert code not in oauth_proxy._client_codes
+
+
+class TestOAuthProxyPKCE:
+ """Test suite for OAuth Proxy PKCE forwarding functionality."""
+
+ @pytest.fixture
+ def jwt_verifier(self):
+ """Create a mock JWT verifier for testing."""
+ verifier = Mock()
+ verifier.required_scopes = ["read", "write"]
+ return verifier
+
+ @pytest.fixture
+ def oauth_proxy_with_pkce(self, jwt_verifier):
+ """Create an OAuthProxy instance with PKCE forwarding enabled."""
+ return OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=jwt_verifier,
+ base_url="https://proxy.example.com",
+ forward_pkce=True, # Enable PKCE forwarding
+ )
+
+ @pytest.fixture
+ def oauth_proxy_without_pkce(self, jwt_verifier):
+ """Create an OAuthProxy instance with PKCE forwarding disabled."""
+ return OAuthProxy(
+ upstream_authorization_endpoint="https://oauth.example.com/authorize",
+ upstream_token_endpoint="https://oauth.example.com/token",
+ upstream_client_id="upstream-client",
+ upstream_client_secret="upstream-secret",
+ token_verifier=jwt_verifier,
+ base_url="https://proxy.example.com",
+ forward_pkce=False, # Disable PKCE forwarding
+ )
+
+ async def test_pkce_forwarding_enabled(self, oauth_proxy_with_pkce):
+ """Test that proxy generates and forwards its own PKCE when client uses PKCE."""
+ client = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+
+ params = AuthorizationParams(
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ state="client-state",
+ code_challenge="client_challenge_value",
+ code_challenge_method="S256",
+ scopes=["read"],
+ )
+
+ redirect_url = await oauth_proxy_with_pkce.authorize(client, params)
+ query_params = parse_qs(urlparse(redirect_url).query)
+
+ # Verify proxy forwards its own PKCE challenge
+ assert "code_challenge" in query_params
+ assert query_params["code_challenge"][0] != "client_challenge_value"
+ assert query_params["code_challenge_method"] == ["S256"]
+
+ # Verify transaction stores both client and proxy PKCE
+ txn_id = query_params["state"][0]
+ transaction = oauth_proxy_with_pkce._oauth_transactions[txn_id]
+ assert transaction["code_challenge"] == "client_challenge_value" # Client's
+ assert "proxy_code_verifier" in transaction # Proxy's verifier stored
+
+ async def test_pkce_forwarding_disabled(self, oauth_proxy_without_pkce):
+ """Test that PKCE is not forwarded when forward_pkce=False."""
+ client = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+
+ params = AuthorizationParams(
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ state="client-state",
+ code_challenge="client_challenge_value",
+ scopes=["read"],
+ )
+
+ redirect_url = await oauth_proxy_without_pkce.authorize(client, params)
+ query_params = parse_qs(urlparse(redirect_url).query)
+
+ # Verify NO PKCE parameters forwarded to upstream
+ assert "code_challenge" not in query_params
+ assert "code_challenge_method" not in query_params
+
+ # But client's PKCE is still stored for validation
+ txn_id = query_params["state"][0]
+ transaction = oauth_proxy_without_pkce._oauth_transactions[txn_id]
+ assert transaction["code_challenge"] == "client_challenge_value"
+ assert "proxy_code_verifier" not in transaction
+
+ async def test_no_pkce_when_client_has_none(self, oauth_proxy_with_pkce):
+ """Test that proxy doesn't generate PKCE if client doesn't use it."""
+ client = OAuthClientInformationFull(
+ client_id="test-client",
+ client_secret="test-secret",
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
+ )
+
+ params = AuthorizationParams(
+ redirect_uri=AnyUrl("http://localhost:12345/callback"),
+ redirect_uri_provided_explicitly=True,
+ state="client-state",
+ code_challenge="", # Empty string means no PKCE
+ scopes=["read"],
+ )
+
+ redirect_url = await oauth_proxy_with_pkce.authorize(client, params)
+ query_params = parse_qs(urlparse(redirect_url).query)
+
+ # Verify NO PKCE forwarded
+ assert "code_challenge" not in query_params
+ assert "code_challenge_method" not in query_params
+
+ # No proxy verifier stored
+ txn_id = query_params["state"][0]
+ transaction = oauth_proxy_with_pkce._oauth_transactions[txn_id]
+ assert "proxy_code_verifier" not in transaction