Jeremiah Lowin commited on
Commit
f5238eb
·
unverified ·
1 Parent(s): 2f4e9b1

Add OAuth proxy that allows authentication with social IDPs without DCR support (#1434)

Browse files
.github/workflows/run-tests.yml CHANGED
@@ -76,3 +76,5 @@ jobs:
76
  run: uv run pytest tests -m "integration"
77
  env:
78
  FASTMCP_GITHUB_TOKEN: ${{ secrets.FASTMCP_GITHUB_TOKEN }}
 
 
 
76
  run: uv run pytest tests -m "integration"
77
  env:
78
  FASTMCP_GITHUB_TOKEN: ${{ secrets.FASTMCP_GITHUB_TOKEN }}
79
+ FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID }}
80
+ FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET: ${{ secrets.FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET }}
docs/docs.json CHANGED
@@ -99,8 +99,9 @@
99
  "icon": "shield-check",
100
  "pages": [
101
  "servers/auth/authentication",
102
- "servers/auth/remote-oauth",
103
  "servers/auth/token-verification",
 
 
104
  "servers/auth/full-oauth-server"
105
  ]
106
  },
@@ -122,10 +123,7 @@
122
  {
123
  "group": "Essentials",
124
  "icon": "cube",
125
- "pages": [
126
- "clients/client",
127
- "clients/transports"
128
- ]
129
  },
130
  {
131
  "group": "Core Operations",
@@ -151,10 +149,7 @@
151
  {
152
  "group": "Authentication",
153
  "icon": "user-shield",
154
- "pages": [
155
- "clients/auth/oauth",
156
- "clients/auth/bearer"
157
- ]
158
  }
159
  ]
160
  },
@@ -163,6 +158,8 @@
163
  "pages": [
164
  "integrations/anthropic",
165
  "integrations/authkit",
 
 
166
  "integrations/chatgpt",
167
  "integrations/claude-code",
168
  "integrations/claude-desktop",
@@ -200,17 +197,12 @@
200
  },
201
  {
202
  "anchor": "What's New",
203
- "pages": [
204
- "updates",
205
- "changelog"
206
- ]
207
  },
208
  {
209
  "anchor": "Community",
210
  "icon": "users",
211
- "pages": [
212
- "community/showcase"
213
- ]
214
  }
215
  ]
216
  },
 
99
  "icon": "shield-check",
100
  "pages": [
101
  "servers/auth/authentication",
 
102
  "servers/auth/token-verification",
103
+ "servers/auth/remote-oauth",
104
+ "servers/auth/oauth-proxy",
105
  "servers/auth/full-oauth-server"
106
  ]
107
  },
 
123
  {
124
  "group": "Essentials",
125
  "icon": "cube",
126
+ "pages": ["clients/client", "clients/transports"]
 
 
 
127
  },
128
  {
129
  "group": "Core Operations",
 
149
  {
150
  "group": "Authentication",
151
  "icon": "user-shield",
152
+ "pages": ["clients/auth/oauth", "clients/auth/bearer"]
 
 
 
153
  }
154
  ]
155
  },
 
158
  "pages": [
159
  "integrations/anthropic",
160
  "integrations/authkit",
161
+ "integrations/github",
162
+ "integrations/google",
163
  "integrations/chatgpt",
164
  "integrations/claude-code",
165
  "integrations/claude-desktop",
 
197
  },
198
  {
199
  "anchor": "What's New",
200
+ "pages": ["updates", "changelog"]
 
 
 
201
  },
202
  {
203
  "anchor": "Community",
204
  "icon": "users",
205
+ "pages": ["community/showcase"]
 
 
206
  }
207
  ]
208
  },
docs/integrations/github.mdx ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: GitHub OAuth 🤝 FastMCP
3
+ sidebarTitle: GitHub OAuth
4
+ description: Secure your FastMCP server with GitHub OAuth
5
+ icon: github
6
+ tag: NEW
7
+ ---
8
+
9
+ import { VersionBadge } from "/snippets/version-badge.mdx"
10
+
11
+ <VersionBadge version="2.12.0" />
12
+
13
+ This guide shows you how to secure your FastMCP server using **GitHub OAuth**. Since GitHub doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge GitHub's traditional OAuth with MCP's authentication requirements.
14
+
15
+ ## Configuration
16
+
17
+ ### Prerequisites
18
+
19
+ Before you begin, you will need:
20
+ 1. A **[GitHub Account](https://github.com/)** with access to create OAuth Apps
21
+ 2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`)
22
+
23
+ ### Step 1: Create a GitHub OAuth App
24
+
25
+ Create an OAuth App in your GitHub settings to get the credentials needed for authentication:
26
+
27
+ <Steps>
28
+ <Step title="Navigate to OAuth Apps">
29
+ Go to **Settings → Developer settings → OAuth Apps** in your GitHub account, or visit [github.com/settings/developers](https://github.com/settings/developers).
30
+
31
+ Click **"New OAuth App"** to create a new application.
32
+ </Step>
33
+
34
+ <Step title="Configure Your OAuth App">
35
+ Fill in the application details:
36
+
37
+ - **Application name**: Choose a name users will recognize (e.g., "My FastMCP Server")
38
+ - **Homepage URL**: Your application's homepage or documentation URL
39
+ - **Authorization callback URL**: Your server URL + `/oauth/callback` (e.g., `http://localhost:8000/oauth/callback`)
40
+
41
+ <Warning>
42
+ The callback URL must match exactly. The default path is `/oauth/callback`, but you can customize it using the `redirect_path` parameter. For local development, GitHub allows `http://localhost` URLs. For production, you must use HTTPS.
43
+ </Warning>
44
+
45
+ <Tip>
46
+ If you want to use a custom callback path (e.g., `/auth/github/callback`), make sure to set the same path in both your GitHub OAuth App settings and the `redirect_path` parameter when configuring the GitHubProvider.
47
+ </Tip>
48
+ </Step>
49
+
50
+ <Step title="Save Your Credentials">
51
+ After creating the app, you'll see:
52
+
53
+ - **Client ID**: A public identifier like `Ov23liAbcDefGhiJkLmN`
54
+ - **Client Secret**: Click "Generate a new client secret" and save the value securely
55
+
56
+ <Tip>
57
+ Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production.
58
+ </Tip>
59
+ </Step>
60
+ </Steps>
61
+
62
+ ### Step 2: FastMCP Configuration
63
+
64
+ Create your FastMCP server using the `GitHubProvider`, which handles GitHub's OAuth quirks automatically:
65
+
66
+ ```python server.py
67
+ from fastmcp import FastMCP
68
+ from fastmcp.server.auth.providers.github import GitHubProvider
69
+
70
+ # The GitHubProvider handles GitHub's token format and validation
71
+ auth_provider = GitHubProvider(
72
+ client_id="Ov23liAbcDefGhiJkLmN", # Your GitHub OAuth App Client ID
73
+ client_secret="github_pat_...", # Your GitHub OAuth App Client Secret
74
+ base_url="http://localhost:8000", # Must match your OAuth App configuration
75
+ # redirect_path="/oauth/callback" # Default value, customize if needed
76
+ )
77
+
78
+ mcp = FastMCP(name="GitHub Secured App", auth=auth_provider)
79
+
80
+ # Add a protected tool to test authentication
81
+ @mcp.tool
82
+ async def get_user_info() -> dict:
83
+ """Returns information about the authenticated GitHub user."""
84
+ from fastmcp.server.dependencies import get_access_token
85
+
86
+ token = get_access_token()
87
+ # The GitHubProvider stores user data in token claims
88
+ return {
89
+ "github_user": token.claims.get("login"),
90
+ "name": token.claims.get("name"),
91
+ "email": token.claims.get("email")
92
+ }
93
+ ```
94
+
95
+ ## Testing
96
+
97
+ ### Running the Server
98
+
99
+ Start your FastMCP server with HTTP transport to enable OAuth flows:
100
+
101
+ ```bash
102
+ fastmcp run server.py --transport http --port 8000
103
+ ```
104
+
105
+ Your server is now running and protected by GitHub OAuth authentication.
106
+
107
+ ### Testing with a Client
108
+
109
+ Create a test client that authenticates with your GitHub-protected server:
110
+
111
+ ```python test_client.py
112
+ from fastmcp import Client
113
+ import asyncio
114
+
115
+ async def main():
116
+ # The client will automatically handle GitHub OAuth
117
+ async with Client("http://localhost:8000/mcp/", auth="oauth") as client:
118
+ # First-time connection will open GitHub login in your browser
119
+ print("✓ Authenticated with GitHub!")
120
+
121
+ # Test the protected tool
122
+ result = await client.call_tool("get_user_info")
123
+ print(f"GitHub user: {result['github_user']}")
124
+
125
+ if __name__ == "__main__":
126
+ asyncio.run(main())
127
+ ```
128
+
129
+ When you run the client for the first time:
130
+ 1. Your browser will open to GitHub's authorization page
131
+ 2. After you authorize the app, you'll be redirected back
132
+ 3. The client receives the token and can make authenticated requests
133
+
134
+ <Info>
135
+ The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache.
136
+ </Info>
137
+
138
+ ## Environment Variables
139
+
140
+ For production deployments, use environment variables instead of hardcoding credentials.
141
+
142
+ <Info>
143
+ To use the registered GitHub provider, you must set `FASTMCP_SERVER_AUTH=GITHUB`. Learn more about [registered providers](/servers/auth/authentication#registered-providers).
144
+ </Info>
145
+
146
+ ### Provider Selection
147
+
148
+ <ParamField path="FASTMCP_SERVER_AUTH" default="Not set" required>
149
+ Set to `GITHUB` to use the registered GitHubProvider with default configuration.
150
+ </ParamField>
151
+
152
+ ### GitHub-Specific Configuration
153
+
154
+ <Card>
155
+ <ParamField path="FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID" required>
156
+ Your GitHub OAuth App Client ID (e.g., `Ov23liAbcDefGhiJkLmN`)
157
+ </ParamField>
158
+
159
+ <ParamField path="FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET" required>
160
+ Your GitHub OAuth App Client Secret
161
+ </ParamField>
162
+
163
+ <ParamField path="FASTMCP_SERVER_AUTH_GITHUB_BASE_URL" default="http://localhost:8000">
164
+ Public URL of your FastMCP server for OAuth callbacks
165
+ </ParamField>
166
+
167
+ <ParamField path="FASTMCP_SERVER_AUTH_GITHUB_REDIRECT_PATH" default="/oauth/callback">
168
+ Redirect path configured in your GitHub OAuth App
169
+ </ParamField>
170
+
171
+ <ParamField path="FASTMCP_SERVER_AUTH_GITHUB_REQUIRED_SCOPES" default='["user"]'>
172
+ Comma-separated list of required GitHub scopes (e.g., `user,repo`)
173
+ </ParamField>
174
+
175
+ <ParamField path="FASTMCP_SERVER_AUTH_GITHUB_TIMEOUT_SECONDS" default="10">
176
+ HTTP request timeout for GitHub API calls
177
+ </ParamField>
178
+ </Card>
179
+
180
+ Example `.env` file:
181
+ ```bash
182
+ # Use the registered GitHub provider
183
+ FASTMCP_SERVER_AUTH=GITHUB
184
+
185
+ # GitHub OAuth credentials
186
+ FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID=Ov23liAbcDefGhiJkLmN
187
+ FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET=github_pat_...
188
+ FASTMCP_SERVER_AUTH_GITHUB_BASE_URL=https://your-server.com
189
+ FASTMCP_SERVER_AUTH_GITHUB_REQUIRED_SCOPES=user,repo
190
+ ```
191
+
192
+ With environment variables set, your server code simplifies to:
193
+
194
+ ```python server.py
195
+ from fastmcp import FastMCP
196
+
197
+ # Authentication is automatically configured from environment
198
+ mcp = FastMCP(name="GitHub Secured App")
199
+
200
+ @mcp.tool
201
+ async def list_repos() -> list[str]:
202
+ """List the authenticated user's repositories."""
203
+ # Your tool implementation here
204
+ pass
205
+ ```
docs/integrations/google.mdx ADDED
@@ -0,0 +1,215 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Google OAuth 🤝 FastMCP
3
+ sidebarTitle: Google OAuth
4
+ description: Secure your FastMCP server with Google OAuth
5
+ icon: google
6
+ tag: NEW
7
+ ---
8
+
9
+ import { VersionBadge } from "/snippets/version-badge.mdx"
10
+
11
+ <VersionBadge version="2.12.0" />
12
+
13
+ This guide shows you how to secure your FastMCP server using **Google OAuth**. Since Google doesn't support Dynamic Client Registration, this integration uses the [**OAuth Proxy**](/servers/auth/oauth-proxy) pattern to bridge Google's traditional OAuth with MCP's authentication requirements.
14
+
15
+ ## Configuration
16
+
17
+ ### Prerequisites
18
+
19
+ Before you begin, you will need:
20
+ 1. A **[Google Cloud Account](https://console.cloud.google.com/)** with access to create OAuth 2.0 Client IDs
21
+ 2. Your FastMCP server's URL (can be localhost for development, e.g., `http://localhost:8000`)
22
+
23
+ ### Step 1: Create a Google OAuth 2.0 Client ID
24
+
25
+ Create an OAuth 2.0 Client ID in your Google Cloud Console to get the credentials needed for authentication:
26
+
27
+ <Steps>
28
+ <Step title="Navigate to OAuth Consent Screen">
29
+ Go to the [Google Cloud Console](https://console.cloud.google.com/apis/credentials) and select your project (or create a new one).
30
+
31
+ First, configure the OAuth consent screen by navigating to **APIs & Services → OAuth consent screen**. Choose "External" for testing or "Internal" for G Suite organizations.
32
+ </Step>
33
+
34
+ <Step title="Create OAuth 2.0 Client ID">
35
+ Navigate to **APIs & Services → Credentials** and click **"+ CREATE CREDENTIALS"** → **"OAuth client ID"**.
36
+
37
+ Configure your OAuth client:
38
+
39
+ - **Application type**: Web application
40
+ - **Name**: Choose a descriptive name (e.g., "FastMCP Server")
41
+ - **Authorized JavaScript origins**: Add your server's base URL (e.g., `http://localhost:8000`)
42
+ - **Authorized redirect URIs**: Add your server URL + `/oauth/callback` (e.g., `http://localhost:8000/oauth/callback`)
43
+
44
+ <Warning>
45
+ The redirect URI must match exactly. The default path is `/oauth/callback`, but you can customize it using the `redirect_path` parameter. For local development, Google allows `http://localhost` URLs with various ports. For production, you must use HTTPS.
46
+ </Warning>
47
+
48
+ <Tip>
49
+ If you want to use a custom callback path (e.g., `/auth/google/callback`), make sure to set the same path in both your Google OAuth Client settings and the `redirect_path` parameter when configuring the GoogleProvider.
50
+ </Tip>
51
+ </Step>
52
+
53
+ <Step title="Save Your Credentials">
54
+ After creating the client, you'll receive:
55
+
56
+ - **Client ID**: A string ending in `.apps.googleusercontent.com`
57
+ - **Client Secret**: A string starting with `GOCSPX-`
58
+
59
+ Download the JSON credentials or copy these values securely.
60
+
61
+ <Tip>
62
+ Store these credentials securely. Never commit them to version control. Use environment variables or a secrets manager in production.
63
+ </Tip>
64
+ </Step>
65
+ </Steps>
66
+
67
+ ### Step 2: FastMCP Configuration
68
+
69
+ Create your FastMCP server using the `GoogleProvider`, which handles Google's OAuth flow automatically:
70
+
71
+ ```python server.py
72
+ from fastmcp import FastMCP
73
+ from fastmcp.server.auth.providers.google import GoogleProvider
74
+
75
+ # The GoogleProvider handles Google's token format and validation
76
+ auth_provider = GoogleProvider(
77
+ client_id="123456789.apps.googleusercontent.com", # Your Google OAuth Client ID
78
+ client_secret="GOCSPX-abc123...", # Your Google OAuth Client Secret
79
+ base_url="http://localhost:8000", # Must match your OAuth configuration
80
+ required_scopes=["openid", "email", "profile"], # Request user information
81
+ # redirect_path="/oauth/callback" # Default value, customize if needed
82
+ )
83
+
84
+ mcp = FastMCP(name="Google Secured App", auth=auth_provider)
85
+
86
+ # Add a protected tool to test authentication
87
+ @mcp.tool
88
+ async def get_user_info() -> dict:
89
+ """Returns information about the authenticated Google user."""
90
+ from fastmcp.server.dependencies import get_access_token
91
+
92
+ token = get_access_token()
93
+ # The GoogleProvider stores user data in token claims
94
+ return {
95
+ "google_id": token.claims.get("sub"),
96
+ "email": token.claims.get("email"),
97
+ "name": token.claims.get("name"),
98
+ "picture": token.claims.get("picture"),
99
+ "locale": token.claims.get("locale")
100
+ }
101
+ ```
102
+
103
+ ## Testing
104
+
105
+ ### Running the Server
106
+
107
+ Start your FastMCP server with HTTP transport to enable OAuth flows:
108
+
109
+ ```bash
110
+ fastmcp run server.py --transport http --port 8000
111
+ ```
112
+
113
+ Your server is now running and protected by Google OAuth authentication.
114
+
115
+ ### Testing with a Client
116
+
117
+ Create a test client that authenticates with your Google-protected server:
118
+
119
+ ```python test_client.py
120
+ from fastmcp import Client
121
+ import asyncio
122
+
123
+ async def main():
124
+ # The client will automatically handle Google OAuth
125
+ async with Client("http://localhost:8000/mcp/", auth="oauth") as client:
126
+ # First-time connection will open Google login in your browser
127
+ print("✓ Authenticated with Google!")
128
+
129
+ # Test the protected tool
130
+ result = await client.call_tool("get_user_info")
131
+ print(f"Google user: {result['email']}")
132
+ print(f"Name: {result['name']}")
133
+
134
+ if __name__ == "__main__":
135
+ asyncio.run(main())
136
+ ```
137
+
138
+ When you run the client for the first time:
139
+ 1. Your browser will open to Google's authorization page
140
+ 2. Sign in with your Google account and grant the requested permissions
141
+ 3. After authorization, you'll be redirected back
142
+ 4. The client receives the token and can make authenticated requests
143
+
144
+ <Info>
145
+ The client caches tokens locally, so you won't need to re-authenticate for subsequent runs unless the token expires or you explicitly clear the cache.
146
+ </Info>
147
+
148
+ ## Environment Variables
149
+
150
+ For production deployments, use environment variables instead of hardcoding credentials.
151
+
152
+ <Info>
153
+ To use the registered Google provider, you must set `FASTMCP_SERVER_AUTH=GOOGLE`. Learn more about [registered providers](/servers/auth/authentication#registered-providers).
154
+ </Info>
155
+
156
+ ### Provider Selection
157
+
158
+ <ParamField path="FASTMCP_SERVER_AUTH" default="Not set" required>
159
+ Set to `GOOGLE` to use the registered GoogleProvider with default configuration.
160
+ </ParamField>
161
+
162
+ ### Google-Specific Configuration
163
+
164
+ <Card>
165
+ <ParamField path="FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID" required>
166
+ Your Google OAuth 2.0 Client ID (e.g., `123456789.apps.googleusercontent.com`)
167
+ </ParamField>
168
+
169
+ <ParamField path="FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET" required>
170
+ Your Google OAuth 2.0 Client Secret (e.g., `GOCSPX-abc123...`)
171
+ </ParamField>
172
+
173
+ <ParamField path="FASTMCP_SERVER_AUTH_GOOGLE_BASE_URL" default="http://localhost:8000">
174
+ Public URL of your FastMCP server for OAuth callbacks
175
+ </ParamField>
176
+
177
+ <ParamField path="FASTMCP_SERVER_AUTH_GOOGLE_REDIRECT_PATH" default="/oauth/callback">
178
+ Redirect path configured in your Google OAuth Client
179
+ </ParamField>
180
+
181
+ <ParamField path="FASTMCP_SERVER_AUTH_GOOGLE_REQUIRED_SCOPES" default="[]">
182
+ Comma-separated list of required Google scopes (e.g., `openid`)
183
+ </ParamField>
184
+
185
+ <ParamField path="FASTMCP_SERVER_AUTH_GOOGLE_TIMEOUT_SECONDS" default="10">
186
+ HTTP request timeout for Google API calls
187
+ </ParamField>
188
+ </Card>
189
+
190
+ Example `.env` file:
191
+ ```bash
192
+ # Use the registered Google provider
193
+ FASTMCP_SERVER_AUTH=GOOGLE
194
+
195
+ # Google OAuth credentials
196
+ FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID=123456789.apps.googleusercontent.com
197
+ FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET=GOCSPX-abc123...
198
+ FASTMCP_SERVER_AUTH_GOOGLE_BASE_URL=https://your-server.com
199
+ FASTMCP_SERVER_AUTH_GOOGLE_REQUIRED_SCOPES=openid,email,profile
200
+ ```
201
+
202
+ With environment variables set, your server code simplifies to:
203
+
204
+ ```python server.py
205
+ from fastmcp import FastMCP
206
+
207
+ # Authentication is automatically configured from environment
208
+ mcp = FastMCP(name="Google Secured App")
209
+
210
+ @mcp.tool
211
+ async def protected_tool(query: str) -> str:
212
+ """A tool that requires Google authentication to access."""
213
+ # Your tool implementation here
214
+ return f"Processing authenticated request: {query}"
215
+ ```
docs/servers/auth/authentication.mdx CHANGED
@@ -34,7 +34,7 @@ Traditional web authentication assumes a human user with a browser who can inter
34
 
35
  These challenges mean that not all authentication approaches work well with MCP. The patterns that do work fall into three categories based on the level of authentication responsibility your server assumes.
36
 
37
- ## Understanding Authentication Responsibility
38
 
39
  Authentication responsibility exists on a spectrum. Your MCP server can validate tokens created elsewhere, coordinate with external identity providers, or handle the complete authentication lifecycle internally. Each approach involves different trade-offs between simplicity, security, and control.
40
 
@@ -66,9 +66,9 @@ Full OAuth implementation means building user interfaces for login and consent,
66
 
67
  This pattern makes sense only when you need complete control over the authentication process, operate in air-gapped environments, or have specialized requirements that external providers cannot meet.
68
 
69
- ## FastMCP Implementation
70
 
71
- FastMCP translates these authentication responsibility levels into three concrete classes that handle the complexities of MCP protocol integration.
72
 
73
  ### TokenVerifier
74
 
@@ -97,11 +97,13 @@ This example configures token validation against a JWT issuer. The `JWTVerifier`
97
 
98
  ### RemoteAuthProvider
99
 
100
- `RemoteAuthProvider` combines token validation with OAuth discovery metadata, enabling MCP clients to automatically discover and authenticate with external identity providers.
101
 
102
- This class extends `TokenVerifier` functionality by adding OAuth 2.0 protected resource endpoints that advertise your authentication requirements. MCP clients can examine these endpoints to understand which identity providers you trust and how to obtain valid tokens.
103
 
104
- The implementation handles the OAuth metadata generation required by the MCP specification while delegating actual token validation to an underlying `TokenVerifier`. This separation allows you to use different token validation strategies while maintaining consistent OAuth discovery behavior.
 
 
105
 
106
  ```python
107
  from fastmcp import FastMCP
@@ -117,10 +119,41 @@ mcp = FastMCP(name="Enterprise Server", auth=auth)
117
 
118
  This example uses WorkOS AuthKit as the external identity provider. The `AuthKitProvider` automatically configures token validation against WorkOS and provides the OAuth metadata that MCP clients need for automatic authentication.
119
 
120
- `RemoteAuthProvider` excels for production applications that need professional identity management without implementation complexity.
121
 
122
  → **Complete guide**: [Remote OAuth](/servers/auth/remote-oauth)
123
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  ### OAuthProvider
125
 
126
  `OAuthProvider` implements a complete OAuth 2.0 authorization server within your MCP server. This class handles the full authentication lifecycle from user credential verification to token management.
@@ -162,6 +195,37 @@ Environment-based configuration separates authentication settings from applicati
162
 
163
  FastMCP automatically detects authentication configuration from environment variables when no explicit `auth` parameter is provided. The configuration system supports all authentication providers and their various options.
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  ```bash
166
  export FASTMCP_SERVER_AUTH=JWT
167
  export FASTMCP_SERVER_AUTH_JWT_JWKS_URI="https://auth.example.com/jwks"
@@ -184,7 +248,9 @@ This approach simplifies deployment pipelines and follows twelve-factor app prin
184
 
185
  The authentication approach you choose depends on your existing infrastructure, security requirements, and operational constraints.
186
 
187
- **For most production applications, external identity providers offer the best balance of security, features, and simplicity.** This approach provides enterprise-grade authentication without implementation complexity and scales well as your application grows. The main trade-off is requiring users to sign up with your chosen identity provider, but this also brings benefits like professional user management, security monitoring, and compliance features.
 
 
188
 
189
  **Token validation works well when you already have authentication infrastructure that issues structured tokens.** If your organization already uses JWT-based systems, API gateways, or enterprise SSO that can generate tokens, this approach integrates seamlessly while keeping your MCP server focused on its core functionality. The simplicity comes from leveraging existing investment in authentication infrastructure.
190
 
 
34
 
35
  These challenges mean that not all authentication approaches work well with MCP. The patterns that do work fall into three categories based on the level of authentication responsibility your server assumes.
36
 
37
+ ## Authentication Responsibility
38
 
39
  Authentication responsibility exists on a spectrum. Your MCP server can validate tokens created elsewhere, coordinate with external identity providers, or handle the complete authentication lifecycle internally. Each approach involves different trade-offs between simplicity, security, and control.
40
 
 
66
 
67
  This pattern makes sense only when you need complete control over the authentication process, operate in air-gapped environments, or have specialized requirements that external providers cannot meet.
68
 
69
+ ## FastMCP Authentication Providers
70
 
71
+ FastMCP translates these authentication responsibility levels into a variety of concrete classes that handle the complexities of MCP protocol integration. You can build on these classes to handle the complexities of MCP protocol integration.
72
 
73
  ### TokenVerifier
74
 
 
97
 
98
  ### RemoteAuthProvider
99
 
100
+ `RemoteAuthProvider` enables authentication with identity providers that **support Dynamic Client Registration (DCR)**, such as WorkOS AuthKit. With DCR, MCP clients can automatically register themselves with the identity provider and obtain credentials without any manual configuration.
101
 
102
+ This class combines token validation with OAuth discovery metadata. It extends `TokenVerifier` functionality by adding OAuth 2.0 protected resource endpoints that advertise your authentication requirements. MCP clients examine these endpoints to understand which identity providers you trust and how to obtain valid tokens.
103
 
104
+ The key requirement is that your identity provider must support DCR - the ability for clients to dynamically register and obtain credentials. This is what enables the seamless, automated authentication flow that MCP requires.
105
+
106
+ For example, the built-in `AuthKitProvider` uses WorkOS AuthKit, which fully supports DCR:
107
 
108
  ```python
109
  from fastmcp import FastMCP
 
119
 
120
  This example uses WorkOS AuthKit as the external identity provider. The `AuthKitProvider` automatically configures token validation against WorkOS and provides the OAuth metadata that MCP clients need for automatic authentication.
121
 
122
+ `RemoteAuthProvider` is ideal for production applications when your identity provider supports Dynamic Client Registration (DCR). This enables fully automated authentication without manual client configuration.
123
 
124
  → **Complete guide**: [Remote OAuth](/servers/auth/remote-oauth)
125
 
126
+ ### OAuthProxy
127
+
128
+ <VersionBadge version="2.12.0" />
129
+
130
+ `OAuthProxy` enables authentication with OAuth providers that **don't support Dynamic Client Registration (DCR)**, such as GitHub, Google, Azure, and most traditional enterprise identity systems.
131
+
132
+ When identity providers require manual app registration and fixed credentials, `OAuthProxy` bridges the gap. It presents a DCR-compliant interface to MCP clients (accepting any registration request) while using your pre-registered credentials with the upstream provider. The proxy handles the complexity of callback forwarding, enabling dynamic client callbacks to work with providers that require fixed redirect URIs.
133
+
134
+ This class solves the fundamental incompatibility between MCP's expectation of dynamic registration and traditional OAuth providers' requirement for manual app registration.
135
+
136
+ For example, the built-in `GitHubProvider` extends `OAuthProxy` to work with GitHub's OAuth system:
137
+
138
+ ```python
139
+ from fastmcp import FastMCP
140
+ from fastmcp.server.auth.providers.github import GitHubProvider
141
+
142
+ auth = GitHubProvider(
143
+ client_id="Ov23li...", # Your GitHub OAuth App ID
144
+ client_secret="abc123...", # Your GitHub OAuth App Secret
145
+ base_url="https://your-server.com"
146
+ )
147
+
148
+ mcp = FastMCP(name="GitHub-Protected Server", auth=auth)
149
+ ```
150
+
151
+ This example uses the GitHub provider, which extends `OAuthProxy` with GitHub-specific token validation. The proxy handles the complete OAuth flow while making GitHub's non-DCR authentication work seamlessly with MCP clients.
152
+
153
+ `OAuthProxy` is essential when integrating with OAuth providers that don't support DCR. This includes most established providers like GitHub, Google, and Azure, which require manual app registration through their developer consoles.
154
+
155
+ → **Complete guide**: [OAuth Proxy](/servers/auth/oauth-proxy)
156
+
157
  ### OAuthProvider
158
 
159
  `OAuthProvider` implements a complete OAuth 2.0 authorization server within your MCP server. This class handles the full authentication lifecycle from user credential verification to token management.
 
195
 
196
  FastMCP automatically detects authentication configuration from environment variables when no explicit `auth` parameter is provided. The configuration system supports all authentication providers and their various options.
197
 
198
+ #### Registered Providers
199
+
200
+ FastMCP includes pre-configured providers for popular OAuth services that can be activated with a single environment variable:
201
+
202
+ <ParamField path="FASTMCP_SERVER_AUTH" type="string">
203
+ The authentication provider to use. Supported values:
204
+ - `GITHUB` - GitHub OAuth (requires additional GitHub-specific env vars)
205
+ - `GOOGLE` - Google OAuth (requires additional Google-specific env vars)
206
+ - `JWT` - JWT token verification
207
+ - `WORKOS` - WorkOS AuthKit
208
+ - Custom provider class names
209
+ </ParamField>
210
+
211
+ When using registered providers like GitHub or Google, you'll need to set provider-specific environment variables:
212
+
213
+ ```bash
214
+ # GitHub OAuth
215
+ export FASTMCP_SERVER_AUTH=GITHUB
216
+ export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="Ov23li..."
217
+ export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="github_pat_..."
218
+
219
+ # Google OAuth
220
+ export FASTMCP_SERVER_AUTH=GOOGLE
221
+ export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID="123456.apps.googleusercontent.com"
222
+ export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="GOCSPX-..."
223
+ ```
224
+
225
+ #### Custom Provider Configuration
226
+
227
+ For providers that aren't pre-registered, specify the provider class and its configuration:
228
+
229
  ```bash
230
  export FASTMCP_SERVER_AUTH=JWT
231
  export FASTMCP_SERVER_AUTH_JWT_JWKS_URI="https://auth.example.com/jwks"
 
248
 
249
  The authentication approach you choose depends on your existing infrastructure, security requirements, and operational constraints.
250
 
251
+ **For OAuth providers without DCR support (GitHub, Google, Azure, most enterprise systems), use OAuth Proxy.** These providers require manual app registration through their developer consoles. OAuth Proxy bridges the gap by presenting a DCR-compliant interface to MCP clients while using your fixed credentials with the provider. The proxy's callback forwarding pattern enables dynamic client ports to work with providers that require fixed redirect URIs.
252
+
253
+ **For identity providers with DCR support (WorkOS AuthKit, modern auth platforms), use RemoteAuthProvider.** These providers allow clients to dynamically register and obtain credentials without manual configuration. This enables the fully automated authentication flow that MCP is designed for, providing the best user experience and simplest implementation.
254
 
255
  **Token validation works well when you already have authentication infrastructure that issues structured tokens.** If your organization already uses JWT-based systems, API gateways, or enterprise SSO that can generate tokens, this approach integrates seamlessly while keeping your MCP server focused on its core functionality. The simplicity comes from leveraging existing investment in authentication infrastructure.
256
 
docs/servers/auth/oauth-proxy.mdx ADDED
@@ -0,0 +1,300 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: OAuth Proxy
3
+ sidebarTitle: OAuth Proxy
4
+ description: Enable authentication with OAuth providers that don't support Dynamic Client Registration.
5
+ icon: share
6
+ tag: NEW
7
+ ---
8
+
9
+ import { VersionBadge } from "/snippets/version-badge.mdx"
10
+
11
+ <VersionBadge version="2.12.0" />
12
+
13
+ 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.
14
+
15
+ 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.
16
+
17
+ <Tip>
18
+ **When to use OAuth Proxy vs RemoteAuthProvider:**
19
+ - **OAuth Proxy**: For providers WITHOUT Dynamic Client Registration (GitHub, Google, Azure, etc.)
20
+ - **RemoteAuthProvider**: For providers WITH Dynamic Client Registration (WorkOS AuthKit, etc.)
21
+
22
+ OAuth Proxy makes traditional OAuth providers work seamlessly with MCP's automated authentication flow.
23
+ </Tip>
24
+
25
+ ## DCR vs Non-DCR Providers
26
+
27
+ The key distinction in MCP authentication is whether your OAuth provider supports **Dynamic Client Registration (DCR)**:
28
+
29
+ - **Providers WITH DCR** (WorkOS, some OIDC providers): Use [`RemoteAuthProvider`](/servers/auth/remote-oauth)
30
+ - Clients can register themselves automatically
31
+ - No manual app registration needed
32
+ - True dynamic authentication flow
33
+
34
+ - **Providers WITHOUT DCR** (GitHub, Google, Azure, Discord, etc.): Use `OAuthProxy` (this guide)
35
+ - Requires manual app registration in provider's console
36
+ - You obtain fixed client ID and secret
37
+ - OAuth Proxy bridges the gap for MCP compatibility
38
+
39
+ OAuth Proxy makes non-DCR providers work seamlessly with MCP by implementing a local DCR interface that always returns your pre-registered credentials.
40
+
41
+ ## Understanding the DCR Gap
42
+
43
+ **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.
44
+
45
+ However, most OAuth providers don't support DCR:
46
+
47
+ | Provider Type | DCR Support | Registration Method | Examples |
48
+ |--------------|-------------|--------------------|-----------|
49
+ | Modern Auth Platforms | ✅ Yes | Automatic via API | WorkOS AuthKit, Some OIDC providers |
50
+ | Traditional OAuth | ❌ No | Manual via console | GitHub, Google, Azure, Discord, Facebook |
51
+ | Enterprise SSO | ❌ No | IT Administrator | Okta, AD FS, PingIdentity |
52
+
53
+ Providers without DCR require you to:
54
+ - Manually register applications through their developer console
55
+ - Obtain fixed client IDs and secrets that never change
56
+ - Pre-configure specific redirect URIs
57
+ - Manage credentials through their web interface
58
+
59
+ 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.
60
+
61
+ ## How OAuth Proxy Works
62
+
63
+ The OAuth Proxy implements an intelligent callback forwarding pattern that solves both the DCR problem and the redirect URI mismatch issue:
64
+
65
+ ```mermaid
66
+ sequenceDiagram
67
+ participant Client as MCP Client<br/>(localhost:random)
68
+ participant Proxy as FastMCP OAuth Proxy<br/>(server:8000)
69
+ participant Provider as OAuth Provider<br/>(GitHub, etc.)
70
+
71
+ Note over Client, Proxy: Dynamic Registration (Local)
72
+ Client->>Proxy: 1. POST /register<br/>redirect_uri: localhost:54321/callback
73
+ Proxy-->>Client: 2. Returns fixed upstream credentials
74
+
75
+ Note over Client, Proxy: Authorization with Callback Forwarding
76
+ Client->>Proxy: 3. GET /authorize<br/>redirect_uri=localhost:54321/callback
77
+ Note over Proxy: Store transaction with client callback
78
+ Proxy->>Provider: 4. Redirect to provider<br/>redirect_uri=server:8000/oauth/callback
79
+
80
+ Note over Provider, Proxy: Provider Callback
81
+ Provider->>Proxy: 5. GET /oauth/callback<br/>with authorization code
82
+ Proxy->>Provider: 6. Exchange code for tokens
83
+ Provider-->>Proxy: 7. Access & refresh tokens
84
+
85
+ Note over Proxy, Client: Client Callback Forwarding
86
+ Proxy->>Client: 8. Redirect to localhost:54321/callback<br/>with new authorization code
87
+
88
+ Note over Client, Proxy: Token Exchange
89
+ Client->>Proxy: 9. POST /token with code
90
+ Proxy-->>Client: 10. Returns stored provider tokens
91
+ ```
92
+
93
+ ### The Callback Forwarding Pattern
94
+
95
+ OAuth Proxy implements an innovative callback forwarding pattern that solves the redirect URI mismatch problem:
96
+
97
+ **The Challenge:**
98
+ - MCP clients listen on random localhost ports (e.g., `http://localhost:54321/callback`)
99
+ - Each client session uses a different port
100
+ - OAuth providers only accept pre-registered, fixed redirect URIs
101
+ - Registering every possible localhost port is impossible
102
+
103
+ **The Solution:**
104
+
105
+ The proxy acts as an intermediary callback handler:
106
+
107
+ 1. **Dynamic Registration**: Client provides its localhost callback URL during registration
108
+ 2. **Transaction Tracking**: Proxy stores the client's callback URL with a transaction ID
109
+ 3. **Fixed Provider Callback**: Proxy uses its own fixed callback URL with the provider
110
+ 4. **Server-Side Token Exchange**: Proxy receives the provider's callback and exchanges the authorization code for tokens
111
+ 5. **Client Forwarding**: Proxy redirects to the client's original localhost callback with a new authorization code
112
+ 6. **Token Delivery**: Client exchanges this new code with the proxy to receive the provider's tokens
113
+
114
+ 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.
115
+
116
+ ## Basic Implementation
117
+
118
+ The `OAuthProxy` class provides the complete proxy implementation:
119
+
120
+ <Card icon="code" title="OAuthProxy Constructor Parameters">
121
+ <ParamField body="upstream_authorization_endpoint" type="str" required>
122
+ URL of your OAuth provider's authorization endpoint (e.g., `https://github.com/login/oauth/authorize`)
123
+ </ParamField>
124
+
125
+ <ParamField body="upstream_token_endpoint" type="str" required>
126
+ URL of your OAuth provider's token endpoint (e.g., `https://github.com/login/oauth/access_token`)
127
+ </ParamField>
128
+
129
+ <ParamField body="upstream_client_id" type="str" required>
130
+ Client ID from your registered OAuth application
131
+ </ParamField>
132
+
133
+ <ParamField body="upstream_client_secret" type="str" required>
134
+ Client secret from your registered OAuth application
135
+ </ParamField>
136
+
137
+ <ParamField body="token_verifier" type="TokenVerifier" required>
138
+ A [`TokenVerifier`](/servers/auth/token-verification) instance to validate the provider's tokens
139
+ </ParamField>
140
+
141
+ <ParamField body="base_url" type="AnyHttpUrl | str" required>
142
+ Public URL of your FastMCP server (e.g., `https://your-server.com`)
143
+ </ParamField>
144
+
145
+ <ParamField body="redirect_path" type="str" default="/oauth/callback">
146
+ Path for OAuth callbacks. Must match the redirect URI configured in your OAuth application
147
+ </ParamField>
148
+
149
+ <ParamField body="upstream_revocation_endpoint" type="str | None">
150
+ Optional URL of provider's token revocation endpoint
151
+ </ParamField>
152
+
153
+ <ParamField body="issuer_url" type="AnyHttpUrl | str | None">
154
+ Issuer URL for OAuth metadata (defaults to base_url)
155
+ </ParamField>
156
+
157
+ <ParamField body="service_documentation_url" type="AnyHttpUrl | str | None">
158
+ Optional URL to your service documentation
159
+ </ParamField>
160
+
161
+ <ParamField body="resource_server_url" type="AnyHttpUrl | str | None">
162
+ Resource server URL (defaults to base_url)
163
+ </ParamField>
164
+ </Card>
165
+
166
+ ```python
167
+ from fastmcp import FastMCP
168
+ from fastmcp.server.auth.providers.proxy import OAuthProxy
169
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
170
+
171
+ # Configure token validation for your provider
172
+ token_verifier = JWTVerifier(
173
+ jwks_uri="https://your-provider.com/.well-known/jwks.json",
174
+ issuer="https://your-provider.com",
175
+ audience="your-app-id"
176
+ )
177
+
178
+ # Create the OAuth proxy (accepts strings for URLs)
179
+ auth = OAuthProxy(
180
+ # Upstream provider endpoints
181
+ upstream_authorization_endpoint="https://your-provider.com/oauth/authorize",
182
+ upstream_token_endpoint="https://your-provider.com/oauth/token",
183
+
184
+ # Your registered app credentials
185
+ upstream_client_id="your-registered-client-id",
186
+ upstream_client_secret="your-registered-client-secret",
187
+
188
+ # Token validation
189
+ token_verifier=token_verifier,
190
+
191
+ # Your FastMCP server URL (string automatically converted to AnyHttpUrl)
192
+ base_url="https://your-server.com",
193
+
194
+ # Optional: customize callback path (defaults to "/oauth/callback")
195
+ redirect_path="/oauth/callback"
196
+ )
197
+
198
+ mcp = FastMCP(name="My Server", auth=auth)
199
+ ```
200
+
201
+ ### OAuth Provider Configuration
202
+
203
+ When registering your application with your OAuth provider, configure the redirect/callback URL as:
204
+
205
+ ```
206
+ https://your-server.com/oauth/callback
207
+ ```
208
+
209
+ For local development with providers that support it (like GitHub):
210
+
211
+ ```
212
+ http://localhost:8000/oauth/callback
213
+ ```
214
+
215
+ The proxy automatically:
216
+ - Implements DCR by returning your fixed credentials to any client that registers
217
+ - Handles callback forwarding between dynamic client callbacks and your fixed provider callback
218
+ - Exchanges authorization codes server-side for enhanced security
219
+ - Validates tokens using your provider's public keys or API
220
+ - Maintains PKCE security throughout the flow
221
+
222
+ ## Client Compatibility
223
+
224
+ <Tip>
225
+ 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.
226
+ </Tip>
227
+
228
+ 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.
229
+
230
+ ## Token Verification Strategies
231
+
232
+ Different OAuth providers use different token formats, requiring appropriate verification strategies:
233
+
234
+ ### Provider Token Types
235
+
236
+ | Provider | Token Type | Verification Method | Built-in Support |
237
+ |----------|-----------|--------------------|-----------------|
238
+ | GitHub | Opaque | API validation (`/user` endpoint) | ✅ `GitHubProvider` |
239
+ | Google | JWT | JWKS signature verification | ✅ `GoogleProvider` |
240
+ | Azure AD | JWT | JWKS signature verification | Configure `JWTVerifier` |
241
+ | Discord | Opaque | API validation | ✅ `DiscordOAuthProxyProvider` |
242
+ | Custom | Varies | Implement `TokenVerifier` | Extend base class |
243
+
244
+ ### Using Built-in Providers
245
+
246
+ FastMCP includes pre-configured providers that handle token verification automatically:
247
+
248
+ ```python
249
+ from fastmcp.server.auth.providers.github import GitHubProvider
250
+
251
+ # GitHub provider with automatic API-based token validation
252
+ auth = GitHubProvider(
253
+ client_id="your-github-client-id",
254
+ client_secret="your-github-client-secret",
255
+ base_url="https://your-server.com"
256
+ )
257
+ ```
258
+
259
+ ### Custom Token Verification
260
+
261
+ For providers without built-in support, implement a [`TokenVerifier`](/servers/auth/token-verification):
262
+
263
+ - **JWT tokens**: Use `JWTVerifier` with the provider's JWKS endpoint
264
+ - **Opaque tokens**: Extend `TokenVerifier` to validate via the provider's API
265
+ - **Hybrid approaches**: Combine multiple verification methods as needed
266
+
267
+ ## Environment Configuration
268
+
269
+ OAuth Proxy supports environment-based configuration for production deployments:
270
+
271
+ ```bash
272
+ # Provider selection
273
+ export FASTMCP_SERVER_AUTH=OAUTH_PROXY
274
+
275
+ # OAuth endpoints
276
+ export FASTMCP_SERVER_AUTH_OAUTH_PROXY_UPSTREAM_AUTHORIZATION_ENDPOINT="https://github.com/login/oauth/authorize"
277
+ export FASTMCP_SERVER_AUTH_OAUTH_PROXY_UPSTREAM_TOKEN_ENDPOINT="https://github.com/login/oauth/access_token"
278
+
279
+ # Credentials (use secrets management in production)
280
+ export FASTMCP_SERVER_AUTH_OAUTH_PROXY_UPSTREAM_CLIENT_ID="Ov23li..."
281
+ export FASTMCP_SERVER_AUTH_OAUTH_PROXY_UPSTREAM_CLIENT_SECRET="abc123..."
282
+
283
+ # Token validation
284
+ export FASTMCP_SERVER_AUTH_OAUTH_PROXY_TOKEN_VERIFIER="JWT"
285
+ export FASTMCP_SERVER_AUTH_OAUTH_PROXY_JWKS_URI="https://provider.com/.well-known/jwks.json"
286
+ export FASTMCP_SERVER_AUTH_OAUTH_PROXY_ISSUER="https://provider.com"
287
+ export FASTMCP_SERVER_AUTH_OAUTH_PROXY_AUDIENCE="your-app-id"
288
+
289
+ # Server URL
290
+ export FASTMCP_SERVER_AUTH_OAUTH_PROXY_BASE_URL="https://your-server.com"
291
+ ```
292
+
293
+ With environment variables configured, your code becomes:
294
+
295
+ ```python
296
+ from fastmcp import FastMCP
297
+
298
+ # Authentication automatically configured from environment
299
+ mcp = FastMCP(name="My Server")
300
+ ```
docs/servers/auth/remote-oauth.mdx CHANGED
@@ -10,12 +10,30 @@ import { VersionBadge } from "/snippets/version-badge.mdx"
10
 
11
  <VersionBadge version="2.11.0" />
12
 
13
- Remote OAuth integration allows your FastMCP server to leverage external identity providers while maintaining the automated authentication flows that MCP clients require. This approach provides enterprise-grade authentication features without the complexity of implementing them yourself, making it the recommended pattern for most production applications.
14
 
15
  <Tip>
16
- Remote OAuth requires identity providers that support **Dynamic Client Registration (DCR)**. This enables MCP clients to automatically register and authenticate without manual configuration steps.
 
 
 
 
17
  </Tip>
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  ## The Remote OAuth Challenge
20
 
21
  Traditional OAuth flows assume human users with web browsers who can interact with login forms, consent screens, and redirects. MCP clients operate differently - they're often automated systems that need to authenticate programmatically without human intervention.
 
10
 
11
  <VersionBadge version="2.11.0" />
12
 
13
+ Remote OAuth integration allows your FastMCP server to leverage external identity providers that **support Dynamic Client Registration (DCR)**. With DCR, MCP clients can automatically register themselves with the identity provider and obtain credentials without any manual configuration. This provides enterprise-grade authentication with fully automated flows, making it ideal for production applications with modern identity providers.
14
 
15
  <Tip>
16
+ **When to use RemoteAuthProvider vs OAuth Proxy:**
17
+ - **RemoteAuthProvider**: For providers WITH Dynamic Client Registration (WorkOS AuthKit, modern OIDC providers)
18
+ - **OAuth Proxy**: For providers WITHOUT Dynamic Client Registration (GitHub, Google, Azure, Discord, etc.)
19
+
20
+ RemoteAuthProvider requires DCR support for fully automated client registration and authentication.
21
  </Tip>
22
 
23
+ ## DCR-Enabled Providers
24
+
25
+ RemoteAuthProvider works with identity providers that support **Dynamic Client Registration (DCR)** - a critical capability that enables automated authentication flows:
26
+
27
+ | Feature | DCR Providers (RemoteAuth) | Non-DCR Providers (OAuth Proxy) |
28
+ |---------|---------------------------|--------------------------------|
29
+ | **Client Registration** | Automatic via API | Manual in provider console |
30
+ | **Credentials** | Dynamic per client | Fixed app credentials |
31
+ | **Configuration** | Zero client config | Pre-shared credentials |
32
+ | **Examples** | WorkOS AuthKit, modern OIDC | GitHub, Google, Azure |
33
+ | **FastMCP Class** | `RemoteAuthProvider` | [`OAuthProxy`](/servers/auth/oauth-proxy) |
34
+
35
+ If your provider doesn't support DCR (most traditional OAuth providers), you'll need to use [`OAuth Proxy`](/servers/auth/oauth-proxy) instead, which bridges the gap between MCP's DCR expectations and fixed OAuth credentials.
36
+
37
  ## The Remote OAuth Challenge
38
 
39
  Traditional OAuth flows assume human users with web browsers who can interact with login forms, consent screens, and redirects. MCP clients operate differently - they're often automated systems that need to authenticate programmatically without human intervention.
examples/auth/github_oauth/README.md ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GitHub OAuth Example
2
+
3
+ Demonstrates FastMCP server protection with GitHub OAuth.
4
+
5
+ ## Setup
6
+
7
+ 1. Create a GitHub OAuth App:
8
+ - Go to GitHub Settings > Developer settings > OAuth Apps
9
+ - Set Authorization callback URL to: `http://localhost:8000/oauth/callback`
10
+ - Copy the Client ID and Client Secret
11
+
12
+ 2. Set environment variables:
13
+
14
+ ```bash
15
+ export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID="your-client-id"
16
+ export FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET="your-client-secret"
17
+ ```
18
+
19
+ 3. Run the server:
20
+
21
+ ```bash
22
+ python server.py
23
+ ```
24
+
25
+ 4. In another terminal, run the client:
26
+
27
+ ```bash
28
+ python client.py
29
+ ```
30
+
31
+ The client will open your browser for GitHub authentication.
examples/auth/github_oauth/client.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OAuth client example for connecting to FastMCP servers.
2
+
3
+ This example demonstrates how to connect to an OAuth-protected FastMCP server.
4
+
5
+ To run:
6
+ python client.py
7
+ """
8
+
9
+ import asyncio
10
+
11
+ from fastmcp.client import Client
12
+
13
+ SERVER_URL = "http://127.0.0.1:8000/mcp"
14
+
15
+
16
+ async def main():
17
+ try:
18
+ async with Client(SERVER_URL, auth="oauth") as client:
19
+ assert await client.ping()
20
+ print("✅ Successfully authenticated!")
21
+
22
+ tools = await client.list_tools()
23
+ print(f"🔧 Available tools ({len(tools)}):")
24
+ for tool in tools:
25
+ print(f" - {tool.name}: {tool.description}")
26
+ except Exception as e:
27
+ print(f"❌ Authentication failed: {e}")
28
+ raise
29
+
30
+
31
+ if __name__ == "__main__":
32
+ asyncio.run(main())
examples/auth/github_oauth/server.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GitHub OAuth server example for FastMCP.
2
+
3
+ This example demonstrates how to protect a FastMCP server with GitHub OAuth.
4
+
5
+ Required environment variables:
6
+ - FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID: Your GitHub OAuth app client ID
7
+ - FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET: Your GitHub OAuth app client secret
8
+
9
+ To run:
10
+ python server.py
11
+ """
12
+
13
+ import os
14
+
15
+ from fastmcp import FastMCP
16
+ from fastmcp.server.auth.providers.github import GitHubProvider
17
+
18
+ auth = GitHubProvider(
19
+ client_id=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID") or "",
20
+ client_secret=os.getenv("FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET") or "",
21
+ base_url="http://localhost:8000",
22
+ # redirect_path="/oauth/callback", # Default path - change if using a different callback URL
23
+ )
24
+
25
+ mcp = FastMCP("GitHub OAuth Example Server", auth=auth)
26
+
27
+
28
+ @mcp.tool
29
+ def echo(message: str) -> str:
30
+ """Echo the provided message."""
31
+ return message
32
+
33
+
34
+ if __name__ == "__main__":
35
+ mcp.run(transport="http", port=8000)
examples/auth/google_oauth/README.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Google OAuth Example
2
+
3
+ Demonstrates FastMCP server protection with Google OAuth.
4
+
5
+ ## Setup
6
+
7
+ 1. Create a Google OAuth 2.0 Client:
8
+ - Go to [Google Cloud Console](https://console.cloud.google.com/)
9
+ - Create or select a project
10
+ - Go to APIs & Services > Credentials
11
+ - Create OAuth 2.0 Client ID (Web application)
12
+ - Add Authorized redirect URI: `http://localhost:8000/oauth/callback`
13
+ - Copy the Client ID and Client Secret
14
+
15
+ 2. Set environment variables:
16
+
17
+ ```bash
18
+ export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID="your-client-id.apps.googleusercontent.com"
19
+ export FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET="your-client-secret"
20
+ ```
21
+
22
+ 3. Run the server:
23
+
24
+ ```bash
25
+ python server.py
26
+ ```
27
+
28
+ 4. In another terminal, run the client:
29
+
30
+ ```bash
31
+ python client.py
32
+ ```
33
+
34
+ The client will open your browser for Google authentication.
examples/auth/google_oauth/client.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OAuth client example for connecting to FastMCP servers.
2
+
3
+ This example demonstrates how to connect to an OAuth-protected FastMCP server.
4
+
5
+ To run:
6
+ python client.py
7
+ """
8
+
9
+ import asyncio
10
+
11
+ from fastmcp.client import Client
12
+
13
+ SERVER_URL = "http://127.0.0.1:8000/mcp"
14
+
15
+
16
+ async def main():
17
+ try:
18
+ async with Client(SERVER_URL, auth="oauth") as client:
19
+ assert await client.ping()
20
+ print("✅ Successfully authenticated!")
21
+
22
+ tools = await client.list_tools()
23
+ print(f"🔧 Available tools ({len(tools)}):")
24
+ for tool in tools:
25
+ print(f" - {tool.name}: {tool.description}")
26
+ except Exception as e:
27
+ print(f"❌ Authentication failed: {e}")
28
+ raise
29
+
30
+
31
+ if __name__ == "__main__":
32
+ asyncio.run(main())
examples/auth/google_oauth/server.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Google OAuth server example for FastMCP.
2
+
3
+ This example demonstrates how to protect a FastMCP server with Google OAuth.
4
+
5
+ Required environment variables:
6
+ - FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID: Your Google OAuth client ID
7
+ - FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET: Your Google OAuth client secret
8
+
9
+ To run:
10
+ python server.py
11
+ """
12
+
13
+ import os
14
+
15
+ from fastmcp import FastMCP
16
+ from fastmcp.server.auth.providers.google import GoogleProvider
17
+
18
+ auth = GoogleProvider(
19
+ client_id=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID") or "",
20
+ client_secret=os.getenv("FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET") or "",
21
+ base_url="http://localhost:8000",
22
+ # redirect_path="/oauth/callback", # Default path - change if using a different callback URL
23
+ # Optional: specify required scopes
24
+ # required_scopes=["openid", "https://www.googleapis.com/auth/userinfo.email"],
25
+ )
26
+
27
+ mcp = FastMCP("Google OAuth Example Server", auth=auth)
28
+
29
+
30
+ @mcp.tool
31
+ def echo(message: str) -> str:
32
+ """Echo the provided message."""
33
+ return message
34
+
35
+
36
+ if __name__ == "__main__":
37
+ mcp.run(transport="http", port=8000)
src/fastmcp/server/auth/providers/github.py ADDED
@@ -0,0 +1,267 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """GitHub OAuth provider for FastMCP.
2
+
3
+ This module provides a complete GitHub OAuth integration that's ready to use
4
+ with just a client ID and client secret. It handles all the complexity of
5
+ GitHub's OAuth flow, token validation, and user management.
6
+
7
+ Example:
8
+ ```python
9
+ from fastmcp import FastMCP
10
+ from fastmcp.server.auth.providers.github import GitHubProvider
11
+
12
+ # Simple GitHub OAuth protection
13
+ auth = GitHubProvider(
14
+ client_id="your-github-client-id",
15
+ client_secret="your-github-client-secret"
16
+ )
17
+
18
+ mcp = FastMCP("My Protected Server", auth=auth)
19
+ ```
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import httpx
25
+ from pydantic import AnyHttpUrl, SecretStr
26
+ from pydantic_settings import BaseSettings, SettingsConfigDict
27
+
28
+ from fastmcp.server.auth import TokenVerifier
29
+ from fastmcp.server.auth.auth import AccessToken
30
+ from fastmcp.server.auth.proxy import OAuthProxy
31
+ from fastmcp.server.auth.registry import register_provider
32
+ from fastmcp.utilities.logging import get_logger
33
+ from fastmcp.utilities.types import NotSet, NotSetT
34
+
35
+ logger = get_logger(__name__)
36
+
37
+
38
+ class GitHubProviderSettings(BaseSettings):
39
+ """Settings for GitHub OAuth provider."""
40
+
41
+ model_config = SettingsConfigDict(
42
+ env_prefix="FASTMCP_SERVER_AUTH_GITHUB_",
43
+ env_file=".env",
44
+ extra="ignore",
45
+ )
46
+
47
+ client_id: str | None = None
48
+ client_secret: SecretStr | None = None
49
+ base_url: AnyHttpUrl | str | None = None
50
+ redirect_path: str | None = None
51
+ required_scopes: list[str] | None = None
52
+ timeout_seconds: int | None = None
53
+
54
+
55
+ class GitHubTokenVerifier(TokenVerifier):
56
+ """Token verifier for GitHub OAuth tokens.
57
+
58
+ GitHub OAuth tokens are opaque (not JWTs), so we verify them
59
+ by calling GitHub's API to check if they're valid and get user info.
60
+ """
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ required_scopes: list[str] | None = None,
66
+ timeout_seconds: int = 10,
67
+ ):
68
+ """Initialize the GitHub token verifier.
69
+
70
+ Args:
71
+ required_scopes: Required OAuth scopes (e.g., ['user:email'])
72
+ timeout_seconds: HTTP request timeout
73
+ """
74
+ super().__init__(required_scopes=required_scopes)
75
+ self.timeout_seconds = timeout_seconds
76
+
77
+ async def verify_token(self, token: str) -> AccessToken | None:
78
+ """Verify GitHub OAuth token by calling GitHub API."""
79
+ try:
80
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
81
+ # Get token info from GitHub API
82
+ response = await client.get(
83
+ "https://api.github.com/user",
84
+ headers={
85
+ "Authorization": f"Bearer {token}",
86
+ "Accept": "application/vnd.github.v3+json",
87
+ "User-Agent": "FastMCP-GitHub-OAuth",
88
+ },
89
+ )
90
+
91
+ if response.status_code != 200:
92
+ logger.debug(
93
+ "GitHub token verification failed: %d - %s",
94
+ response.status_code,
95
+ response.text[:200],
96
+ )
97
+ return None
98
+
99
+ user_data = response.json()
100
+
101
+ # Get token scopes from GitHub API
102
+ # GitHub includes scopes in the X-OAuth-Scopes header
103
+ scopes_response = await client.get(
104
+ "https://api.github.com/user/repos", # Any authenticated endpoint
105
+ headers={
106
+ "Authorization": f"Bearer {token}",
107
+ "Accept": "application/vnd.github.v3+json",
108
+ "User-Agent": "FastMCP-GitHub-OAuth",
109
+ },
110
+ )
111
+
112
+ # Extract scopes from X-OAuth-Scopes header if available
113
+ oauth_scopes_header = scopes_response.headers.get("x-oauth-scopes", "")
114
+ token_scopes = [
115
+ scope.strip()
116
+ for scope in oauth_scopes_header.split(",")
117
+ if scope.strip()
118
+ ]
119
+
120
+ # If no scopes in header, assume basic scopes based on successful user API call
121
+ if not token_scopes:
122
+ token_scopes = ["user"] # Basic scope if we can access user info
123
+
124
+ # Check required scopes
125
+ if self.required_scopes:
126
+ token_scopes_set = set(token_scopes)
127
+ required_scopes_set = set(self.required_scopes)
128
+ if not required_scopes_set.issubset(token_scopes_set):
129
+ logger.debug(
130
+ "GitHub token missing required scopes. Has %d, needs %d",
131
+ len(token_scopes_set),
132
+ len(required_scopes_set),
133
+ )
134
+ return None
135
+
136
+ # Create AccessToken with GitHub user info
137
+ return AccessToken(
138
+ token=token,
139
+ client_id=str(user_data.get("id", "unknown")), # Use GitHub user ID
140
+ scopes=token_scopes,
141
+ expires_at=None, # GitHub tokens don't typically expire
142
+ claims={
143
+ "sub": str(user_data["id"]),
144
+ "login": user_data.get("login"),
145
+ "name": user_data.get("name"),
146
+ "email": user_data.get("email"),
147
+ "avatar_url": user_data.get("avatar_url"),
148
+ "github_user_data": user_data,
149
+ },
150
+ )
151
+
152
+ except httpx.RequestError as e:
153
+ logger.debug("Failed to verify GitHub token: %s", e)
154
+ return None
155
+ except Exception as e:
156
+ logger.debug("GitHub token verification error: %s", e)
157
+ return None
158
+
159
+
160
+ @register_provider("GitHub")
161
+ class GitHubProvider(OAuthProxy):
162
+ """Complete GitHub OAuth provider for FastMCP.
163
+
164
+ This provider makes it trivial to add GitHub OAuth protection to any
165
+ FastMCP server. Just provide your GitHub OAuth app credentials and
166
+ a base URL, and you're ready to go.
167
+
168
+ Features:
169
+ - Transparent OAuth proxy to GitHub
170
+ - Automatic token validation via GitHub API
171
+ - User information extraction
172
+ - Minimal configuration required
173
+
174
+ Example:
175
+ ```python
176
+ from fastmcp import FastMCP
177
+ from fastmcp.server.auth.providers.github import GitHubProvider
178
+
179
+ auth = GitHubProvider(
180
+ client_id="Ov23li...",
181
+ client_secret="abc123...",
182
+ base_url="https://my-server.com" # Optional, defaults to http://localhost:8000
183
+ )
184
+
185
+ mcp = FastMCP("My App", auth=auth)
186
+ ```
187
+ """
188
+
189
+ def __init__(
190
+ self,
191
+ *,
192
+ client_id: str | NotSetT = NotSet,
193
+ client_secret: str | NotSetT = NotSet,
194
+ base_url: AnyHttpUrl | str | NotSetT = NotSet,
195
+ redirect_path: str | NotSetT = NotSet,
196
+ required_scopes: list[str] | None | NotSetT = NotSet,
197
+ timeout_seconds: int | NotSetT = NotSet,
198
+ ):
199
+ """Initialize GitHub OAuth provider.
200
+
201
+ Args:
202
+ client_id: GitHub OAuth app client ID (e.g., "Ov23li...")
203
+ client_secret: GitHub OAuth app client secret
204
+ base_url: Public URL of your FastMCP server (for OAuth callbacks)
205
+ redirect_path: Redirect path configured in GitHub OAuth app (defaults to "/oauth/callback")
206
+ required_scopes: Required GitHub scopes (defaults to ["user"])
207
+ timeout_seconds: HTTP request timeout for GitHub API calls
208
+ """
209
+ settings = GitHubProviderSettings.model_validate(
210
+ {
211
+ k: v
212
+ for k, v in {
213
+ "client_id": client_id,
214
+ "client_secret": client_secret,
215
+ "base_url": base_url,
216
+ "redirect_path": redirect_path,
217
+ "required_scopes": required_scopes,
218
+ "timeout_seconds": timeout_seconds,
219
+ }.items()
220
+ if v is not NotSet
221
+ }
222
+ )
223
+
224
+ # Validate required settings
225
+ if not settings.client_id:
226
+ raise ValueError(
227
+ "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID"
228
+ )
229
+ if not settings.client_secret:
230
+ raise ValueError(
231
+ "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET"
232
+ )
233
+
234
+ # Apply defaults
235
+ base_url_final = settings.base_url or "http://localhost:8000"
236
+ redirect_path_final = settings.redirect_path or "/oauth/callback"
237
+ timeout_seconds_final = settings.timeout_seconds or 10
238
+ required_scopes_final = settings.required_scopes or ["user"]
239
+
240
+ # Create GitHub token verifier
241
+ token_verifier = GitHubTokenVerifier(
242
+ required_scopes=required_scopes_final,
243
+ timeout_seconds=timeout_seconds_final,
244
+ )
245
+
246
+ # Extract secret string from SecretStr
247
+ client_secret_str = (
248
+ settings.client_secret.get_secret_value() if settings.client_secret else ""
249
+ )
250
+
251
+ # Initialize OAuth proxy with GitHub endpoints
252
+ super().__init__(
253
+ upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
254
+ upstream_token_endpoint="https://github.com/login/oauth/access_token",
255
+ upstream_client_id=settings.client_id,
256
+ upstream_client_secret=client_secret_str,
257
+ token_verifier=token_verifier,
258
+ base_url=base_url_final,
259
+ redirect_path=redirect_path_final,
260
+ issuer_url=base_url_final, # We act as the issuer for client registration
261
+ )
262
+
263
+ logger.info(
264
+ "Initialized GitHub OAuth provider for client %s with scopes: %s",
265
+ settings.client_id,
266
+ required_scopes_final,
267
+ )
src/fastmcp/server/auth/providers/google.py ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Google OAuth provider for FastMCP.
2
+
3
+ This module provides a complete Google OAuth integration that's ready to use
4
+ with just a client ID and client secret. It handles all the complexity of
5
+ Google's OAuth flow, token validation, and user management.
6
+
7
+ Example:
8
+ ```python
9
+ from fastmcp import FastMCP
10
+ from fastmcp.server.auth.providers.google import GoogleProvider
11
+
12
+ # Simple Google OAuth protection
13
+ auth = GoogleProvider(
14
+ client_id="your-google-client-id.apps.googleusercontent.com",
15
+ client_secret="your-google-client-secret"
16
+ )
17
+
18
+ mcp = FastMCP("My Protected Server", auth=auth)
19
+ ```
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import time
25
+
26
+ import httpx
27
+ from pydantic import AnyHttpUrl, SecretStr
28
+ from pydantic_settings import BaseSettings, SettingsConfigDict
29
+
30
+ from fastmcp.server.auth import TokenVerifier
31
+ from fastmcp.server.auth.auth import AccessToken
32
+ from fastmcp.server.auth.proxy import OAuthProxy
33
+ from fastmcp.server.auth.registry import register_provider
34
+ from fastmcp.utilities.logging import get_logger
35
+ from fastmcp.utilities.types import NotSet, NotSetT
36
+
37
+ logger = get_logger(__name__)
38
+
39
+
40
+ class GoogleProviderSettings(BaseSettings):
41
+ """Settings for Google OAuth provider."""
42
+
43
+ model_config = SettingsConfigDict(
44
+ env_prefix="FASTMCP_SERVER_AUTH_GOOGLE_",
45
+ env_file=".env",
46
+ extra="ignore",
47
+ )
48
+
49
+ client_id: str | None = None
50
+ client_secret: SecretStr | None = None
51
+ base_url: AnyHttpUrl | str | None = None
52
+ redirect_path: str | None = None
53
+ required_scopes: list[str] | None = None
54
+ timeout_seconds: int | None = None
55
+
56
+
57
+ class GoogleTokenVerifier(TokenVerifier):
58
+ """Token verifier for Google OAuth tokens.
59
+
60
+ Google OAuth tokens are opaque (not JWTs), so we verify them
61
+ by calling Google's tokeninfo API to check if they're valid and get user info.
62
+ """
63
+
64
+ def __init__(
65
+ self,
66
+ *,
67
+ required_scopes: list[str] | None = None,
68
+ timeout_seconds: int = 10,
69
+ ):
70
+ """Initialize the Google token verifier.
71
+
72
+ Args:
73
+ required_scopes: Required OAuth scopes (e.g., ['openid', 'email'])
74
+ timeout_seconds: HTTP request timeout
75
+ """
76
+ super().__init__(required_scopes=required_scopes)
77
+ self.timeout_seconds = timeout_seconds
78
+
79
+ async def verify_token(self, token: str) -> AccessToken | None:
80
+ """Verify Google OAuth token by calling Google's tokeninfo API."""
81
+ try:
82
+ async with httpx.AsyncClient(timeout=self.timeout_seconds) as client:
83
+ # Use Google's tokeninfo endpoint to validate the token
84
+ response = await client.get(
85
+ "https://www.googleapis.com/oauth2/v1/tokeninfo",
86
+ params={"access_token": token},
87
+ headers={"User-Agent": "FastMCP-Google-OAuth"},
88
+ )
89
+
90
+ if response.status_code != 200:
91
+ logger.debug(
92
+ "Google token verification failed: %d",
93
+ response.status_code,
94
+ )
95
+ return None
96
+
97
+ token_info = response.json()
98
+
99
+ # Check if token is expired
100
+ expires_in = token_info.get("expires_in")
101
+ if expires_in and int(expires_in) <= 0:
102
+ logger.debug("Google token has expired")
103
+ return None
104
+
105
+ # Extract scopes from token info
106
+ scope_string = token_info.get("scope", "")
107
+ token_scopes = [
108
+ scope.strip() for scope in scope_string.split(" ") if scope.strip()
109
+ ]
110
+
111
+ # Check required scopes
112
+ if self.required_scopes:
113
+ token_scopes_set = set(token_scopes)
114
+ required_scopes_set = set(self.required_scopes)
115
+ if not required_scopes_set.issubset(token_scopes_set):
116
+ logger.debug(
117
+ "Google token missing required scopes. Has %d, needs %d",
118
+ len(token_scopes_set),
119
+ len(required_scopes_set),
120
+ )
121
+ return None
122
+
123
+ # Get additional user info if we have the right scopes
124
+ user_data = {}
125
+ if "openid" in token_scopes or "profile" in token_scopes:
126
+ try:
127
+ userinfo_response = await client.get(
128
+ "https://www.googleapis.com/oauth2/v2/userinfo",
129
+ headers={
130
+ "Authorization": f"Bearer {token}",
131
+ "User-Agent": "FastMCP-Google-OAuth",
132
+ },
133
+ )
134
+ if userinfo_response.status_code == 200:
135
+ user_data = userinfo_response.json()
136
+ except Exception as e:
137
+ logger.debug("Failed to fetch Google user info: %s", e)
138
+
139
+ # Calculate expiration time
140
+ expires_at = None
141
+ if expires_in:
142
+ expires_at = int(time.time() + int(expires_in))
143
+
144
+ # Create AccessToken with Google user info
145
+ access_token = AccessToken(
146
+ token=token,
147
+ client_id=token_info.get(
148
+ "audience", "unknown"
149
+ ), # Use audience as client_id
150
+ scopes=token_scopes,
151
+ expires_at=expires_at,
152
+ claims={
153
+ "sub": user_data.get("id")
154
+ or token_info.get("user_id", "unknown"),
155
+ "email": user_data.get("email"),
156
+ "name": user_data.get("name"),
157
+ "picture": user_data.get("picture"),
158
+ "given_name": user_data.get("given_name"),
159
+ "family_name": user_data.get("family_name"),
160
+ "locale": user_data.get("locale"),
161
+ "google_user_data": user_data,
162
+ "google_token_info": token_info,
163
+ },
164
+ )
165
+ logger.debug("Google token verified successfully")
166
+ return access_token
167
+
168
+ except httpx.RequestError as e:
169
+ logger.debug("Failed to verify Google token: %s", e)
170
+ return None
171
+ except Exception as e:
172
+ logger.debug("Google token verification error: %s", e)
173
+ return None
174
+
175
+
176
+ @register_provider("Google")
177
+ class GoogleProvider(OAuthProxy):
178
+ """Complete Google OAuth provider for FastMCP.
179
+
180
+ This provider makes it trivial to add Google OAuth protection to any
181
+ FastMCP server. Just provide your Google OAuth app credentials and
182
+ a base URL, and you're ready to go.
183
+
184
+ Features:
185
+ - Transparent OAuth proxy to Google
186
+ - Automatic token validation via Google's tokeninfo API
187
+ - User information extraction from Google APIs
188
+ - Minimal configuration required
189
+
190
+ Example:
191
+ ```python
192
+ from fastmcp import FastMCP
193
+ from fastmcp.server.auth.providers.google import GoogleProvider
194
+
195
+ auth = GoogleProvider(
196
+ client_id="123456789.apps.googleusercontent.com",
197
+ client_secret="GOCSPX-abc123...",
198
+ base_url="https://my-server.com" # Optional, defaults to http://localhost:8000
199
+ )
200
+
201
+ mcp = FastMCP("My App", auth=auth)
202
+ ```
203
+ """
204
+
205
+ def __init__(
206
+ self,
207
+ *,
208
+ client_id: str | NotSetT = NotSet,
209
+ client_secret: str | NotSetT = NotSet,
210
+ base_url: AnyHttpUrl | str | NotSetT = NotSet,
211
+ redirect_path: str | NotSetT = NotSet,
212
+ required_scopes: list[str] | None | NotSetT = NotSet,
213
+ timeout_seconds: int | NotSetT = NotSet,
214
+ ):
215
+ """Initialize Google OAuth provider.
216
+
217
+ Args:
218
+ client_id: Google OAuth client ID (e.g., "123456789.apps.googleusercontent.com")
219
+ client_secret: Google OAuth client secret (e.g., "GOCSPX-abc123...")
220
+ base_url: Public URL of your FastMCP server (for OAuth callbacks)
221
+ redirect_path: Redirect path configured in Google OAuth app (defaults to "/oauth/callback")
222
+ required_scopes: Required Google scopes (defaults to []). Common scopes include:
223
+ - "openid" for OpenID Connect
224
+ - "https://www.googleapis.com/auth/userinfo.email" for email access
225
+ - "https://www.googleapis.com/auth/userinfo.profile" for profile info
226
+ timeout_seconds: HTTP request timeout for Google API calls
227
+ """
228
+ settings = GoogleProviderSettings.model_validate(
229
+ {
230
+ k: v
231
+ for k, v in {
232
+ "client_id": client_id,
233
+ "client_secret": client_secret,
234
+ "base_url": base_url,
235
+ "redirect_path": redirect_path,
236
+ "required_scopes": required_scopes,
237
+ "timeout_seconds": timeout_seconds,
238
+ }.items()
239
+ if v is not NotSet
240
+ }
241
+ )
242
+
243
+ # Validate required settings
244
+ if not settings.client_id:
245
+ raise ValueError(
246
+ "client_id is required - set via parameter or FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_ID"
247
+ )
248
+ if not settings.client_secret:
249
+ raise ValueError(
250
+ "client_secret is required - set via parameter or FASTMCP_SERVER_AUTH_GOOGLE_CLIENT_SECRET"
251
+ )
252
+
253
+ # Apply defaults
254
+ base_url_final = settings.base_url or "http://localhost:8000"
255
+ redirect_path_final = settings.redirect_path or "/oauth/callback"
256
+ timeout_seconds_final = settings.timeout_seconds or 10
257
+ required_scopes_final = settings.required_scopes or []
258
+
259
+ # Create Google token verifier
260
+ token_verifier = GoogleTokenVerifier(
261
+ required_scopes=required_scopes_final,
262
+ timeout_seconds=timeout_seconds_final,
263
+ )
264
+
265
+ # Extract secret string from SecretStr
266
+ client_secret_str = (
267
+ settings.client_secret.get_secret_value() if settings.client_secret else ""
268
+ )
269
+
270
+ # Initialize OAuth proxy with Google endpoints
271
+ super().__init__(
272
+ upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
273
+ upstream_token_endpoint="https://oauth2.googleapis.com/token",
274
+ upstream_client_id=settings.client_id,
275
+ upstream_client_secret=client_secret_str,
276
+ token_verifier=token_verifier,
277
+ base_url=base_url_final,
278
+ redirect_path=redirect_path_final,
279
+ issuer_url=base_url_final, # We act as the issuer for client registration
280
+ )
281
+
282
+ logger.info(
283
+ "Initialized Google OAuth provider for client %s with scopes: %s",
284
+ settings.client_id,
285
+ required_scopes_final,
286
+ )
src/fastmcp/server/auth/proxy.py ADDED
@@ -0,0 +1,1059 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OAuth Proxy Provider for FastMCP.
2
+
3
+ This provider acts as a transparent proxy to an upstream OAuth Authorization Server,
4
+ handling Dynamic Client Registration locally while forwarding all other OAuth flows.
5
+ This enables authentication with upstream providers that don't support DCR or have
6
+ restricted client registration policies.
7
+
8
+ Key features:
9
+ - Proxies authorization and token endpoints to upstream server
10
+ - Implements local Dynamic Client Registration with fixed upstream credentials
11
+ - Validates tokens using upstream JWKS
12
+ - Maintains minimal local state for bookkeeping
13
+ - Enhanced logging with request correlation
14
+
15
+ This implementation is based on the OAuth 2.1 specification and is designed for
16
+ production use with enterprise identity providers.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import secrets
22
+ import time
23
+ from typing import TYPE_CHECKING, Any, Final
24
+ from urllib.parse import urlencode
25
+
26
+ import httpx
27
+ from authlib.integrations.httpx_client import AsyncOAuth2Client
28
+ from mcp.server.auth.provider import (
29
+ AccessToken,
30
+ AuthorizationCode,
31
+ AuthorizationParams,
32
+ RefreshToken,
33
+ TokenError,
34
+ )
35
+ from mcp.server.auth.settings import (
36
+ ClientRegistrationOptions,
37
+ RevocationOptions,
38
+ )
39
+ from mcp.shared.auth import OAuthClientInformationFull, OAuthToken
40
+ from pydantic import AnyHttpUrl, AnyUrl, SecretStr
41
+ from starlette.requests import Request
42
+ from starlette.responses import JSONResponse, RedirectResponse
43
+ from starlette.routing import Route
44
+
45
+ from fastmcp.server.auth.auth import OAuthProvider, TokenVerifier
46
+ from fastmcp.utilities.logging import get_logger
47
+
48
+ if TYPE_CHECKING:
49
+ pass
50
+
51
+ logger = get_logger(__name__)
52
+
53
+
54
+ class ProxyDCRClient(OAuthClientInformationFull):
55
+ """Client for DCR proxy that accepts any localhost redirect URI.
56
+
57
+ This special client class is critical for the OAuth proxy to work correctly
58
+ with Dynamic Client Registration (DCR). Here's why it exists:
59
+
60
+ Problem:
61
+ --------
62
+ When MCP clients use OAuth, they dynamically register with random localhost
63
+ ports (e.g., http://localhost:55454/callback). The OAuth proxy needs to:
64
+ 1. Accept these dynamic redirect URIs from clients
65
+ 2. Use its own fixed redirect URI with the upstream provider (Google, GitHub, etc.)
66
+ 3. Forward the authorization code back to the client's dynamic URI
67
+
68
+ Solution:
69
+ ---------
70
+ This class overrides redirect_uri validation to accept ANY localhost URI,
71
+ while the proxy internally uses its own fixed redirect URI with the upstream
72
+ provider. This allows the flow to work even when clients reconnect with
73
+ different ports or when tokens are cached.
74
+
75
+ Without this class, clients would get "Redirect URI not registered" errors
76
+ when trying to authenticate with cached tokens, because the stored client
77
+ would have fixed redirect URIs that don't match the new dynamic port.
78
+ """
79
+
80
+ def validate_redirect_uri(self, redirect_uri: AnyUrl | None) -> AnyUrl:
81
+ """Accept any localhost redirect URI for DCR clients.
82
+
83
+ Since we're acting as a proxy and clients register dynamically,
84
+ we need to accept their localhost redirect URIs even though they're
85
+ not pre-registered with us. This is essential for cached token
86
+ scenarios where the client may reconnect with a different port.
87
+ """
88
+ if redirect_uri is not None:
89
+ # Accept any localhost redirect URI for DCR clients
90
+ uri_str = str(redirect_uri)
91
+ if uri_str.startswith(("http://localhost", "http://127.0.0.1")):
92
+ return redirect_uri
93
+ # Fall back to normal validation for non-localhost URIs
94
+ return super().validate_redirect_uri(redirect_uri)
95
+ # If no redirect_uri provided, use default behavior
96
+ return super().validate_redirect_uri(redirect_uri)
97
+
98
+
99
+ # Default token expiration times
100
+ DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS: Final[int] = 60 * 60 # 1 hour
101
+ DEFAULT_AUTH_CODE_EXPIRY_SECONDS: Final[int] = 5 * 60 # 5 minutes
102
+
103
+ # HTTP client timeout
104
+ HTTP_TIMEOUT_SECONDS: Final[int] = 30
105
+
106
+
107
+ class OAuthProxy(OAuthProvider):
108
+ """OAuth provider that presents a DCR-compliant interface while proxying to non-DCR IDPs.
109
+
110
+ Purpose
111
+ -------
112
+ MCP clients expect OAuth providers to support Dynamic Client Registration (DCR),
113
+ where clients can register themselves dynamically and receive unique credentials.
114
+ Most enterprise IDPs (Google, GitHub, Azure AD, etc.) don't support DCR and require
115
+ pre-registered OAuth applications with fixed credentials.
116
+
117
+ This proxy bridges that gap by:
118
+ - Presenting a full DCR-compliant OAuth interface to MCP clients
119
+ - Translating DCR registration requests to use pre-configured upstream credentials
120
+ - Proxying all OAuth flows to the upstream IDP with appropriate translations
121
+ - Managing the state and security requirements of both protocols
122
+
123
+ Architecture Overview
124
+ --------------------
125
+ The proxy maintains a single OAuth app registration with the upstream provider
126
+ while allowing unlimited MCP clients to register and authenticate dynamically.
127
+ It implements the complete OAuth 2.1 + DCR specification for clients while
128
+ translating to whatever OAuth variant the upstream provider requires.
129
+
130
+ Key Translation Challenges Solved
131
+ ---------------------------------
132
+ 1. Dynamic Client Registration:
133
+ - MCP clients expect to register dynamically and get unique credentials
134
+ - Upstream IDPs require pre-registered apps with fixed credentials
135
+ - Solution: Accept DCR requests, return shared upstream credentials
136
+
137
+ 2. Dynamic Redirect URIs:
138
+ - MCP clients use random localhost ports that change between sessions
139
+ - Upstream IDPs require fixed, pre-registered redirect URIs
140
+ - Solution: Use proxy's fixed callback URL with upstream, forward to client's dynamic URI
141
+
142
+ 3. Authorization Code Mapping:
143
+ - Upstream returns codes for the proxy's redirect URI
144
+ - Clients expect codes for their own redirect URIs
145
+ - Solution: Exchange upstream code server-side, issue new code to client
146
+
147
+ 4. State Parameter Collision:
148
+ - Both client and proxy need to maintain state through the flow
149
+ - Only one state parameter available in OAuth
150
+ - Solution: Use transaction ID as state with upstream, preserve client's state
151
+
152
+ 5. Token Management:
153
+ - Clients may expect different token formats/claims than upstream provides
154
+ - Need to track tokens for revocation and refresh
155
+ - Solution: Store token relationships, forward upstream tokens transparently
156
+
157
+ OAuth Flow Implementation
158
+ ------------------------
159
+ 1. Client Registration (DCR):
160
+ - Accept any client registration request
161
+ - Store ProxyDCRClient that accepts dynamic redirect URIs
162
+ - Return shared upstream credentials to all clients
163
+
164
+ 2. Authorization:
165
+ - Store transaction mapping client details to proxy flow
166
+ - Redirect to upstream with proxy's fixed redirect URI
167
+ - Use transaction ID as state parameter with upstream
168
+
169
+ 3. Upstream Callback:
170
+ - Exchange upstream authorization code for tokens (server-side)
171
+ - Generate new authorization code bound to client's PKCE challenge
172
+ - Redirect to client's original dynamic redirect URI
173
+
174
+ 4. Token Exchange:
175
+ - Validate client's code and PKCE verifier
176
+ - Return previously obtained upstream tokens
177
+ - Clean up one-time use authorization code
178
+
179
+ 5. Token Refresh:
180
+ - Forward refresh requests to upstream using authlib
181
+ - Handle token rotation if upstream issues new refresh token
182
+ - Update local token mappings
183
+
184
+ State Management
185
+ ---------------
186
+ The proxy maintains minimal but crucial state:
187
+ - _clients: DCR registrations (all use ProxyDCRClient for flexibility)
188
+ - _oauth_transactions: Active authorization flows with client context
189
+ - _client_codes: Authorization codes with PKCE challenges and upstream tokens
190
+ - _access_tokens, _refresh_tokens: Token storage for revocation
191
+ - Token relationship mappings for cleanup and rotation
192
+
193
+ Security Considerations
194
+ ----------------------
195
+ - PKCE enforced end-to-end (client to proxy, proxy to upstream)
196
+ - Authorization codes are single-use with short expiry
197
+ - Transaction IDs are cryptographically random
198
+ - All state is cleaned up after use to prevent replay
199
+ - Token validation delegates to upstream provider
200
+
201
+ Provider Compatibility
202
+ ---------------------
203
+ Works with any OAuth 2.0 provider that supports:
204
+ - Authorization code flow
205
+ - Fixed redirect URI (configured in provider's app settings)
206
+ - Standard token endpoint
207
+
208
+ Handles provider-specific requirements:
209
+ - Google: Ensures minimum scope requirements
210
+ - GitHub: Compatible with OAuth Apps and GitHub Apps
211
+ - Azure AD: Handles tenant-specific endpoints
212
+ - Generic: Works with any spec-compliant provider
213
+ """
214
+
215
+ def __init__(
216
+ self,
217
+ *,
218
+ # Upstream server configuration
219
+ upstream_authorization_endpoint: str,
220
+ upstream_token_endpoint: str,
221
+ upstream_client_id: str,
222
+ upstream_client_secret: str,
223
+ upstream_revocation_endpoint: str | None = None,
224
+ # Token validation
225
+ token_verifier: TokenVerifier,
226
+ # FastMCP server configuration
227
+ base_url: AnyHttpUrl | str,
228
+ redirect_path: str = "/oauth/callback",
229
+ issuer_url: AnyHttpUrl | str | None = None,
230
+ service_documentation_url: AnyHttpUrl | str | None = None,
231
+ resource_server_url: AnyHttpUrl | str | None = None,
232
+ ):
233
+ """Initialize the OAuth proxy provider.
234
+
235
+ Args:
236
+ upstream_authorization_endpoint: URL of upstream authorization endpoint
237
+ upstream_token_endpoint: URL of upstream token endpoint
238
+ upstream_client_id: Client ID registered with upstream server
239
+ upstream_client_secret: Client secret for upstream server
240
+ upstream_revocation_endpoint: Optional upstream revocation endpoint
241
+ token_verifier: Token verifier for validating access tokens
242
+ base_url: Public URL of this FastMCP server
243
+ redirect_path: Redirect path configured in upstream OAuth app (defaults to "/oauth/callback")
244
+ issuer_url: Issuer URL for OAuth metadata (defaults to base_url)
245
+ service_documentation_url: Optional service documentation URL
246
+ resource_server_url: Resource server URL (defaults to base_url)
247
+ """
248
+ # Convert string URLs to AnyHttpUrl for parent class
249
+ base_url_parsed = (
250
+ AnyHttpUrl(base_url) if isinstance(base_url, str) else base_url
251
+ )
252
+ issuer_url_parsed = (
253
+ (AnyHttpUrl(issuer_url) if isinstance(issuer_url, str) else issuer_url)
254
+ if issuer_url
255
+ else None
256
+ )
257
+ service_documentation_url_parsed = (
258
+ (
259
+ AnyHttpUrl(service_documentation_url)
260
+ if isinstance(service_documentation_url, str)
261
+ else service_documentation_url
262
+ )
263
+ if service_documentation_url
264
+ else None
265
+ )
266
+ resource_server_url_parsed = (
267
+ (
268
+ AnyHttpUrl(resource_server_url)
269
+ if isinstance(resource_server_url, str)
270
+ else resource_server_url
271
+ )
272
+ if resource_server_url
273
+ else None
274
+ )
275
+
276
+ # Always enable DCR since we implement it locally for MCP clients
277
+ client_registration_options = ClientRegistrationOptions(enabled=True)
278
+
279
+ # Enable revocation only if upstream endpoint provided
280
+ revocation_options = (
281
+ RevocationOptions(enabled=True) if upstream_revocation_endpoint else None
282
+ )
283
+
284
+ super().__init__(
285
+ base_url=base_url_parsed,
286
+ issuer_url=issuer_url_parsed,
287
+ service_documentation_url=service_documentation_url_parsed,
288
+ client_registration_options=client_registration_options,
289
+ revocation_options=revocation_options,
290
+ required_scopes=token_verifier.required_scopes,
291
+ resource_server_url=resource_server_url_parsed,
292
+ )
293
+
294
+ # Store upstream configuration
295
+ self._upstream_authorization_endpoint = upstream_authorization_endpoint
296
+ self._upstream_token_endpoint = upstream_token_endpoint
297
+ self._upstream_client_id = upstream_client_id
298
+ self._upstream_client_secret = SecretStr(upstream_client_secret)
299
+ self._upstream_revocation_endpoint = upstream_revocation_endpoint
300
+
301
+ # Store redirect configuration
302
+ self._redirect_path = (
303
+ redirect_path if redirect_path.startswith("/") else f"/{redirect_path}"
304
+ )
305
+
306
+ # Local state for DCR and token bookkeeping
307
+ self._clients: dict[str, OAuthClientInformationFull] = {}
308
+ self._access_tokens: dict[str, AccessToken] = {}
309
+ self._refresh_tokens: dict[str, RefreshToken] = {}
310
+
311
+ # Token relation mappings for cleanup
312
+ self._access_to_refresh: dict[str, str] = {}
313
+ self._refresh_to_access: dict[str, str] = {}
314
+
315
+ # OAuth transaction storage for IdP callback forwarding
316
+ self._oauth_transactions: dict[
317
+ str, dict[str, Any]
318
+ ] = {} # txn_id -> transaction_data
319
+ self._client_codes: dict[str, dict[str, Any]] = {} # client_code -> code_data
320
+
321
+ # Use the provided token validator
322
+ self._token_validator = token_verifier
323
+
324
+ logger.info(
325
+ "Initialized OAuth proxy provider with upstream server %s",
326
+ self._upstream_authorization_endpoint,
327
+ )
328
+
329
+ # -------------------------------------------------------------------------
330
+ # Client Registration (Local Implementation)
331
+ # -------------------------------------------------------------------------
332
+
333
+ async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
334
+ """Get client information by ID.
335
+
336
+ For unregistered clients, returns a ProxyDCRClient that accepts
337
+ any localhost redirect URI for DCR clients.
338
+
339
+ Even registered clients use ProxyDCRClient to ensure they can
340
+ authenticate with different dynamic ports on reconnection. This
341
+ handles the case where a client with cached tokens reconnects
342
+ on a different port.
343
+ """
344
+ client = self._clients.get(client_id)
345
+
346
+ if client is None:
347
+ # For unregistered DCR clients, create a permissive client
348
+ # that will accept any localhost redirect URI
349
+ # We need at least one URI for Pydantic validation, but our custom
350
+ # validate_redirect_uri will accept any localhost URI
351
+ client = ProxyDCRClient(
352
+ client_id=client_id,
353
+ client_secret=None,
354
+ redirect_uris=[
355
+ AnyUrl("http://localhost")
356
+ ], # Placeholder - we accept any localhost URI
357
+ grant_types=["authorization_code", "refresh_token"],
358
+ token_endpoint_auth_method="none",
359
+ )
360
+ logger.debug("Created ProxyDCRClient for unregistered client %s", client_id)
361
+
362
+ return client
363
+
364
+ async def register_client(self, client_info: OAuthClientInformationFull) -> None:
365
+ """Register a client locally using fixed upstream credentials.
366
+
367
+ This implementation always uses the upstream client_id and client_secret
368
+ regardless of what the client requests. It modifies the client_info object
369
+ in place since the MCP framework ignores return values.
370
+
371
+ This ensures all clients use the same credentials that are registered
372
+ with the upstream server.
373
+
374
+ Implementation Detail:
375
+ We store a ProxyDCRClient (not the original client_info) to ensure
376
+ the client can reconnect with different dynamic redirect URIs. This is
377
+ essential for cached token scenarios where the client port changes.
378
+
379
+ The flow:
380
+ 1. Client provides its desired redirect URIs (dynamic localhost ports)
381
+ 2. We create a ProxyDCRClient that will accept ANY localhost URI
382
+ 3. We store this flexible client for future authentications
383
+ 4. When client reconnects with a different port, ProxyDCRClient accepts it
384
+ """
385
+ # Always use the upstream credentials
386
+ upstream_id = self._upstream_client_id
387
+ upstream_secret = self._upstream_client_secret.get_secret_value()
388
+
389
+ # Create a ProxyDCRClient that accepts any localhost redirect URI
390
+ proxy_client = ProxyDCRClient(
391
+ client_id=upstream_id,
392
+ client_secret=upstream_secret,
393
+ redirect_uris=client_info.redirect_uris or [AnyUrl("http://localhost")],
394
+ grant_types=client_info.grant_types
395
+ or ["authorization_code", "refresh_token"],
396
+ token_endpoint_auth_method="none",
397
+ )
398
+
399
+ # Modify the client_info object in place (framework ignores return values)
400
+ client_info.client_id = upstream_id
401
+ client_info.client_secret = upstream_secret
402
+ client_info.token_endpoint_auth_method = "none"
403
+
404
+ # Ensure correct grant types
405
+ if not client_info.grant_types:
406
+ client_info.grant_types = ["authorization_code", "refresh_token"]
407
+
408
+ # Store the ProxyDCRClient using the upstream ID
409
+ self._clients[upstream_id] = proxy_client
410
+
411
+ logger.info(
412
+ "Registered client %s with %d redirect URIs",
413
+ upstream_id,
414
+ len(proxy_client.redirect_uris),
415
+ )
416
+
417
+ # -------------------------------------------------------------------------
418
+ # Authorization Flow (Proxy to Upstream)
419
+ # -------------------------------------------------------------------------
420
+
421
+ async def authorize(
422
+ self,
423
+ client: OAuthClientInformationFull,
424
+ params: AuthorizationParams,
425
+ ) -> str:
426
+ """Start OAuth transaction and redirect to upstream IdP.
427
+
428
+ This implements the DCR-compliant proxy pattern:
429
+ 1. Store transaction with client details and PKCE challenge
430
+ 2. Use transaction ID as state for IdP
431
+ 3. Redirect to IdP with our fixed callback URL
432
+ """
433
+ # Generate transaction ID for this authorization request
434
+ txn_id = secrets.token_urlsafe(32)
435
+
436
+ # Store transaction data for IdP callback processing
437
+ self._oauth_transactions[txn_id] = {
438
+ "client_id": client.client_id,
439
+ "client_redirect_uri": str(params.redirect_uri),
440
+ "client_state": params.state,
441
+ "code_challenge": params.code_challenge,
442
+ "code_challenge_method": getattr(params, "code_challenge_method", "S256"),
443
+ "scopes": params.scopes or [],
444
+ "created_at": time.time(),
445
+ }
446
+
447
+ # Build query parameters for upstream IdP authorization request
448
+ # Use our fixed IdP callback and transaction ID as state
449
+ query_params: dict[str, Any] = {
450
+ "response_type": "code",
451
+ "client_id": self._upstream_client_id,
452
+ "redirect_uri": f"{str(self.base_url).rstrip('/')}{self._redirect_path}",
453
+ "state": txn_id, # Use txn_id as IdP state
454
+ }
455
+
456
+ # Add scopes - use client scopes or fallback to required scopes
457
+ scopes_to_use = params.scopes or self.required_scopes or []
458
+ # Google requires at least some scope parameter, so provide a minimal one if none specified
459
+ if (
460
+ not scopes_to_use
461
+ and "google" in self._upstream_authorization_endpoint.lower()
462
+ ):
463
+ scopes_to_use = ["openid"] # Minimal scope for Google
464
+
465
+ if scopes_to_use:
466
+ query_params["scope"] = " ".join(scopes_to_use)
467
+
468
+ # Build the upstream authorization URL
469
+ upstream_url = (
470
+ f"{self._upstream_authorization_endpoint}?{urlencode(query_params)}"
471
+ )
472
+
473
+ logger.info(
474
+ "Starting OAuth transaction %s for client %s, redirecting to IdP",
475
+ txn_id,
476
+ client.client_id,
477
+ )
478
+
479
+ return upstream_url
480
+
481
+ # -------------------------------------------------------------------------
482
+ # Authorization Code Handling
483
+ # -------------------------------------------------------------------------
484
+
485
+ async def load_authorization_code(
486
+ self,
487
+ client: OAuthClientInformationFull,
488
+ authorization_code: str,
489
+ ) -> AuthorizationCode | None:
490
+ """Load authorization code for validation.
491
+
492
+ Look up our client code and return authorization code object
493
+ with PKCE challenge for validation.
494
+ """
495
+ # Look up client code data
496
+ code_data = self._client_codes.get(authorization_code)
497
+ if not code_data:
498
+ logger.debug("Authorization code not found: %s", authorization_code)
499
+ return None
500
+
501
+ # Check if code expired
502
+ if time.time() > code_data["expires_at"]:
503
+ logger.debug("Authorization code expired: %s", authorization_code)
504
+ self._client_codes.pop(authorization_code, None)
505
+ return None
506
+
507
+ # Verify client ID matches
508
+ if code_data["client_id"] != client.client_id:
509
+ logger.debug(
510
+ "Authorization code client ID mismatch: %s vs %s",
511
+ code_data["client_id"],
512
+ client.client_id,
513
+ )
514
+ return None
515
+
516
+ # Create authorization code object with PKCE challenge
517
+ return AuthorizationCode(
518
+ code=authorization_code,
519
+ client_id=client.client_id,
520
+ redirect_uri=code_data["redirect_uri"],
521
+ redirect_uri_provided_explicitly=True,
522
+ scopes=code_data["scopes"],
523
+ expires_at=code_data["expires_at"],
524
+ code_challenge=code_data.get("code_challenge", ""),
525
+ )
526
+
527
+ async def exchange_authorization_code(
528
+ self,
529
+ client: OAuthClientInformationFull,
530
+ authorization_code: AuthorizationCode,
531
+ ) -> OAuthToken:
532
+ """Exchange authorization code for stored IdP tokens.
533
+
534
+ For the DCR-compliant proxy flow, we return the IdP tokens that were obtained
535
+ during the IdP callback exchange. PKCE validation is handled by the MCP framework.
536
+ """
537
+ # Look up stored code data
538
+ code_data = self._client_codes.get(authorization_code.code)
539
+ if not code_data:
540
+ logger.error(
541
+ "Authorization code not found in client codes: %s",
542
+ authorization_code.code,
543
+ )
544
+ raise TokenError("invalid_grant", "Authorization code not found")
545
+
546
+ # Get stored IdP tokens
547
+ idp_tokens = code_data["idp_tokens"]
548
+
549
+ # Clean up client code (one-time use)
550
+ self._client_codes.pop(authorization_code.code, None)
551
+
552
+ # Extract token information for local tracking
553
+ access_token_value = idp_tokens["access_token"]
554
+ refresh_token_value = idp_tokens.get("refresh_token")
555
+ expires_in = int(
556
+ idp_tokens.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
557
+ )
558
+ expires_at = int(time.time() + expires_in)
559
+
560
+ # Store access token locally for tracking
561
+ access_token = AccessToken(
562
+ token=access_token_value,
563
+ client_id=client.client_id,
564
+ scopes=authorization_code.scopes,
565
+ expires_at=expires_at,
566
+ )
567
+ self._access_tokens[access_token_value] = access_token
568
+
569
+ # Store refresh token if provided
570
+ if refresh_token_value:
571
+ refresh_token = RefreshToken(
572
+ token=refresh_token_value,
573
+ client_id=client.client_id,
574
+ scopes=authorization_code.scopes,
575
+ expires_at=None, # Refresh tokens typically don't expire
576
+ )
577
+ self._refresh_tokens[refresh_token_value] = refresh_token
578
+
579
+ # Maintain token relationships for cleanup
580
+ self._access_to_refresh[access_token_value] = refresh_token_value
581
+ self._refresh_to_access[refresh_token_value] = access_token_value
582
+
583
+ logger.info(
584
+ "Successfully exchanged client code for stored IdP tokens (client: %s)",
585
+ client.client_id,
586
+ )
587
+
588
+ return OAuthToken(**idp_tokens) # type: ignore[arg-type]
589
+
590
+ # -------------------------------------------------------------------------
591
+ # Refresh Token Flow
592
+ # -------------------------------------------------------------------------
593
+
594
+ async def load_refresh_token(
595
+ self,
596
+ client: OAuthClientInformationFull,
597
+ refresh_token: str,
598
+ ) -> RefreshToken | None:
599
+ """Load refresh token from local storage."""
600
+ return self._refresh_tokens.get(refresh_token)
601
+
602
+ async def exchange_refresh_token(
603
+ self,
604
+ client: OAuthClientInformationFull,
605
+ refresh_token: RefreshToken,
606
+ scopes: list[str],
607
+ ) -> OAuthToken:
608
+ """Exchange refresh token for new access token using authlib."""
609
+
610
+ # Use authlib's AsyncOAuth2Client for refresh token exchange
611
+ oauth_client = AsyncOAuth2Client(
612
+ client_id=self._upstream_client_id,
613
+ client_secret=self._upstream_client_secret.get_secret_value(),
614
+ timeout=HTTP_TIMEOUT_SECONDS,
615
+ )
616
+
617
+ try:
618
+ logger.debug("Using authlib to refresh token from upstream")
619
+
620
+ # Let authlib handle the refresh token exchange
621
+ token_response: dict[str, Any] = await oauth_client.refresh_token( # type: ignore[misc]
622
+ url=self._upstream_token_endpoint,
623
+ refresh_token=refresh_token.token,
624
+ scope=" ".join(scopes) if scopes else None,
625
+ )
626
+
627
+ logger.info(
628
+ "Successfully refreshed access token via authlib (client: %s)",
629
+ client.client_id,
630
+ )
631
+
632
+ except Exception as e:
633
+ logger.error("Authlib refresh token exchange failed: %s", e)
634
+ raise TokenError(
635
+ "invalid_grant", f"Upstream refresh token exchange failed: {e}"
636
+ ) from e
637
+
638
+ # Update local token storage
639
+ new_access_token = token_response["access_token"]
640
+ expires_in = int(
641
+ token_response.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
642
+ )
643
+
644
+ self._access_tokens[new_access_token] = AccessToken(
645
+ token=new_access_token,
646
+ client_id=client.client_id,
647
+ scopes=scopes,
648
+ expires_at=int(time.time() + expires_in),
649
+ )
650
+
651
+ # Handle refresh token rotation if new one provided
652
+ if "refresh_token" in token_response:
653
+ new_refresh_token = token_response["refresh_token"]
654
+ if new_refresh_token != refresh_token.token:
655
+ # Remove old refresh token
656
+ self._refresh_tokens.pop(refresh_token.token, None)
657
+ old_access = self._refresh_to_access.pop(refresh_token.token, None)
658
+ if old_access:
659
+ self._access_to_refresh.pop(old_access, None)
660
+
661
+ # Store new refresh token
662
+ self._refresh_tokens[new_refresh_token] = RefreshToken(
663
+ token=new_refresh_token,
664
+ client_id=client.client_id,
665
+ scopes=scopes,
666
+ expires_at=None,
667
+ )
668
+ self._access_to_refresh[new_access_token] = new_refresh_token
669
+ self._refresh_to_access[new_refresh_token] = new_access_token
670
+
671
+ return OAuthToken(**token_response) # type: ignore[arg-type]
672
+
673
+ # -------------------------------------------------------------------------
674
+ # Token Validation
675
+ # -------------------------------------------------------------------------
676
+
677
+ async def load_access_token(self, token: str) -> AccessToken | None:
678
+ """Validate access token using upstream JWKS.
679
+
680
+ Delegates to the JWT verifier which handles signature validation,
681
+ expiration checking, and claims validation using the upstream JWKS.
682
+ """
683
+ result = await self._token_validator.verify_token(token)
684
+ if result:
685
+ logger.debug("Token validated successfully")
686
+ else:
687
+ logger.debug("Token validation failed")
688
+ return result
689
+
690
+ # -------------------------------------------------------------------------
691
+ # Token Revocation
692
+ # -------------------------------------------------------------------------
693
+
694
+ async def revoke_token(self, token: AccessToken | RefreshToken) -> None:
695
+ """Revoke token locally and with upstream server if supported.
696
+
697
+ Removes tokens from local storage and attempts to revoke them with
698
+ the upstream server if a revocation endpoint is configured.
699
+ """
700
+ # Clean up local token storage
701
+ if isinstance(token, AccessToken):
702
+ self._access_tokens.pop(token.token, None)
703
+ # Also remove associated refresh token
704
+ paired_refresh = self._access_to_refresh.pop(token.token, None)
705
+ if paired_refresh:
706
+ self._refresh_tokens.pop(paired_refresh, None)
707
+ self._refresh_to_access.pop(paired_refresh, None)
708
+ else: # RefreshToken
709
+ self._refresh_tokens.pop(token.token, None)
710
+ # Also remove associated access token
711
+ paired_access = self._refresh_to_access.pop(token.token, None)
712
+ if paired_access:
713
+ self._access_tokens.pop(paired_access, None)
714
+ self._access_to_refresh.pop(paired_access, None)
715
+
716
+ # Attempt upstream revocation if endpoint is configured
717
+ if self._upstream_revocation_endpoint:
718
+ try:
719
+ async with httpx.AsyncClient(
720
+ timeout=HTTP_TIMEOUT_SECONDS
721
+ ) as http_client:
722
+ await http_client.post(
723
+ self._upstream_revocation_endpoint,
724
+ data={"token": token.token},
725
+ auth=(
726
+ self._upstream_client_id,
727
+ self._upstream_client_secret.get_secret_value(),
728
+ ),
729
+ )
730
+ logger.info("Successfully revoked token with upstream server")
731
+ except Exception as e:
732
+ logger.warning("Failed to revoke token with upstream server: %s", e)
733
+ else:
734
+ logger.debug("No upstream revocation endpoint configured")
735
+
736
+ logger.info("Token revoked successfully")
737
+
738
+ # -------------------------------------------------------------------------
739
+ # Custom Route Handling
740
+ # -------------------------------------------------------------------------
741
+
742
+ async def _handle_proxy_token_request(self, request: Request) -> JSONResponse:
743
+ """Custom token endpoint using authlib for upstream requests.
744
+
745
+ This handler uses authlib's OAuth2Client to forward token requests to the
746
+ upstream OAuth server, automatically handling response format differences.
747
+ """
748
+ try:
749
+ # Parse the incoming request form data
750
+ form_data = await request.form()
751
+
752
+ # Log the incoming request (with sensitive data redacted)
753
+ redacted_form = {
754
+ k: (
755
+ str(v)[:8] + "..."
756
+ if k in {"code", "code_verifier", "client_secret", "refresh_token"}
757
+ and v
758
+ else str(v)
759
+ )
760
+ for k, v in form_data.items()
761
+ }
762
+ logger.debug("Proxy token request form data: %s", redacted_form)
763
+
764
+ # Create authlib OAuth2 client
765
+ oauth_client = AsyncOAuth2Client(
766
+ client_id=self._upstream_client_id,
767
+ client_secret=self._upstream_client_secret.get_secret_value(),
768
+ timeout=HTTP_TIMEOUT_SECONDS,
769
+ )
770
+
771
+ grant_type = str(form_data.get("grant_type", ""))
772
+
773
+ if grant_type == "authorization_code":
774
+ # Authorization code grant
775
+ try:
776
+ token_data: dict[str, Any] = await oauth_client.fetch_token( # type: ignore[misc]
777
+ url=self._upstream_token_endpoint,
778
+ code=str(form_data.get("code", "")),
779
+ redirect_uri=str(form_data.get("redirect_uri", "")),
780
+ code_verifier=str(form_data.get("code_verifier"))
781
+ if "code_verifier" in form_data
782
+ else None,
783
+ )
784
+
785
+ # Store tokens locally for tracking
786
+ if "access_token" in token_data:
787
+ self._store_tokens_from_response(token_data)
788
+
789
+ logger.info(
790
+ "Successfully proxied authorization code exchange via authlib"
791
+ )
792
+
793
+ except Exception as e:
794
+ logger.error("Authlib authorization code exchange failed: %s", e)
795
+ return JSONResponse(
796
+ content={
797
+ "error": "invalid_grant",
798
+ "error_description": f"Authorization code exchange failed: {e}",
799
+ },
800
+ status_code=400,
801
+ )
802
+
803
+ elif grant_type == "refresh_token":
804
+ # Refresh token grant
805
+ try:
806
+ token_data: dict[str, Any] = await oauth_client.refresh_token( # type: ignore[misc]
807
+ url=self._upstream_token_endpoint,
808
+ refresh_token=str(form_data.get("refresh_token", "")),
809
+ scope=str(form_data.get("scope"))
810
+ if "scope" in form_data
811
+ else None,
812
+ )
813
+
814
+ logger.info(
815
+ "Successfully proxied refresh token exchange via authlib"
816
+ )
817
+
818
+ except Exception as e:
819
+ logger.error("Authlib refresh token exchange failed: %s", e)
820
+ return JSONResponse(
821
+ content={
822
+ "error": "invalid_grant",
823
+ "error_description": f"Refresh token exchange failed: {e}",
824
+ },
825
+ status_code=400,
826
+ )
827
+ else:
828
+ # Unsupported grant type
829
+ logger.error("Unsupported grant type: %s", grant_type)
830
+ return JSONResponse(
831
+ content={
832
+ "error": "unsupported_grant_type",
833
+ "error_description": f"Grant type '{grant_type}' not supported by proxy",
834
+ },
835
+ status_code=400,
836
+ )
837
+
838
+ return JSONResponse(content=token_data)
839
+
840
+ except Exception as e:
841
+ logger.error("Error in proxy token handler: %s", e, exc_info=True)
842
+ return JSONResponse(
843
+ content={
844
+ "error": "server_error",
845
+ "error_description": "Internal server error",
846
+ },
847
+ status_code=500,
848
+ )
849
+
850
+ def _store_tokens_from_response(self, token_data: dict[str, Any]) -> None:
851
+ """Store tokens from upstream response for local tracking."""
852
+ try:
853
+ access_token_value = token_data.get("access_token")
854
+ refresh_token_value = token_data.get("refresh_token")
855
+ expires_in = int(
856
+ token_data.get("expires_in", DEFAULT_ACCESS_TOKEN_EXPIRY_SECONDS)
857
+ )
858
+ expires_at = int(time.time() + expires_in)
859
+
860
+ if access_token_value:
861
+ access_token = AccessToken(
862
+ token=access_token_value,
863
+ client_id=self._upstream_client_id,
864
+ scopes=[], # Will be determined by token validation
865
+ expires_at=expires_at,
866
+ )
867
+ self._access_tokens[access_token_value] = access_token
868
+
869
+ if refresh_token_value:
870
+ refresh_token = RefreshToken(
871
+ token=refresh_token_value,
872
+ client_id=self._upstream_client_id,
873
+ scopes=[],
874
+ expires_at=None,
875
+ )
876
+ self._refresh_tokens[refresh_token_value] = refresh_token
877
+
878
+ # Maintain token relationships
879
+ self._access_to_refresh[access_token_value] = refresh_token_value
880
+ self._refresh_to_access[refresh_token_value] = access_token_value
881
+
882
+ logger.debug("Stored tokens from upstream response for tracking")
883
+
884
+ except Exception as e:
885
+ logger.warning("Failed to store tokens from upstream response: %s", e)
886
+
887
+ def get_routes(self) -> list[Route]:
888
+ """Get OAuth routes with custom proxy token handler.
889
+
890
+ This method creates standard OAuth routes and replaces the token endpoint
891
+ with our proxy handler that forwards requests to the upstream OAuth server.
892
+ """
893
+ # Get standard OAuth routes from parent class
894
+ routes = super().get_routes()
895
+ custom_routes = []
896
+ token_route_found = False
897
+
898
+ logger.info(
899
+ f"get_routes called - configuring OAuth routes in {len(routes)} routes"
900
+ )
901
+
902
+ for i, route in enumerate(routes):
903
+ logger.debug(
904
+ f"Route {i}: {route} - path: {getattr(route, 'path', 'N/A')}, methods: {getattr(route, 'methods', 'N/A')}"
905
+ )
906
+
907
+ # Keep all standard OAuth routes unchanged - our DCR-compliant flow handles everything
908
+ custom_routes.append(route)
909
+
910
+ if (
911
+ isinstance(route, Route)
912
+ and route.path == "/token"
913
+ and route.methods is not None
914
+ and "POST" in route.methods
915
+ ):
916
+ token_route_found = True
917
+ logger.info("✅ KEEPING standard token endpoint for DCR-compliant flow")
918
+
919
+ if not token_route_found:
920
+ logger.warning("⚠️ No /token POST route found!")
921
+ # This shouldn't happen with standard OAuth provider
922
+
923
+ # Add OAuth callback endpoint for forwarding to client callbacks
924
+ custom_routes.append(
925
+ Route(
926
+ path=self._redirect_path,
927
+ endpoint=self._handle_idp_callback,
928
+ methods=["GET"],
929
+ )
930
+ )
931
+
932
+ logger.info(
933
+ f"✅ OAuth routes configured: token_endpoint={token_route_found}, total routes={len(custom_routes)} (includes OAuth callback)"
934
+ )
935
+ return custom_routes
936
+
937
+ # -------------------------------------------------------------------------
938
+ # IdP Callback Forwarding
939
+ # -------------------------------------------------------------------------
940
+
941
+ async def _handle_idp_callback(self, request: Request) -> RedirectResponse:
942
+ """Handle callback from upstream IdP and forward to client.
943
+
944
+ This implements the DCR-compliant callback forwarding:
945
+ 1. Receive IdP callback with code and txn_id as state
946
+ 2. Exchange IdP code for tokens (server-side)
947
+ 3. Generate our own client code bound to PKCE challenge
948
+ 4. Redirect to client's callback with client code and original state
949
+ """
950
+ try:
951
+ idp_code = request.query_params.get("code")
952
+ txn_id = request.query_params.get("state")
953
+ error = request.query_params.get("error")
954
+
955
+ if error:
956
+ logger.error(
957
+ "IdP callback error: %s - %s",
958
+ error,
959
+ request.query_params.get("error_description"),
960
+ )
961
+ # TODO: Forward error to client callback
962
+ return RedirectResponse(
963
+ url=f"data:text/html,<h1>OAuth Error</h1><p>{error}: {request.query_params.get('error_description', 'Unknown error')}</p>",
964
+ status_code=302,
965
+ )
966
+
967
+ if not idp_code or not txn_id:
968
+ logger.error("IdP callback missing code or transaction ID")
969
+ return RedirectResponse(
970
+ url="data:text/html,<h1>OAuth Error</h1><p>Missing authorization code or transaction ID</p>",
971
+ status_code=302,
972
+ )
973
+
974
+ # Look up transaction data
975
+ transaction = self._oauth_transactions.get(txn_id)
976
+ if not transaction:
977
+ logger.error("IdP callback with invalid transaction ID: %s", txn_id)
978
+ return RedirectResponse(
979
+ url="data:text/html,<h1>OAuth Error</h1><p>Invalid or expired transaction</p>",
980
+ status_code=302,
981
+ )
982
+
983
+ # Exchange IdP code for tokens (server-side)
984
+ oauth_client = AsyncOAuth2Client(
985
+ client_id=self._upstream_client_id,
986
+ client_secret=self._upstream_client_secret.get_secret_value(),
987
+ timeout=HTTP_TIMEOUT_SECONDS,
988
+ )
989
+
990
+ try:
991
+ idp_redirect_uri = (
992
+ f"{str(self.base_url).rstrip('/')}{self._redirect_path}"
993
+ )
994
+ logger.debug(
995
+ f"Exchanging IdP code for tokens with redirect_uri: {idp_redirect_uri}"
996
+ )
997
+
998
+ idp_tokens: dict[str, Any] = await oauth_client.fetch_token( # type: ignore[misc]
999
+ url=self._upstream_token_endpoint,
1000
+ code=idp_code,
1001
+ redirect_uri=idp_redirect_uri,
1002
+ )
1003
+
1004
+ logger.info(
1005
+ f"Successfully exchanged IdP code for tokens (transaction: {txn_id})"
1006
+ )
1007
+
1008
+ except Exception as e:
1009
+ logger.error("IdP token exchange failed: %s", e)
1010
+ # TODO: Forward error to client callback
1011
+ return RedirectResponse(
1012
+ url=f"data:text/html,<h1>OAuth Error</h1><p>Token exchange failed: {e}</p>",
1013
+ status_code=302,
1014
+ )
1015
+
1016
+ # Generate our own authorization code for the client
1017
+ client_code = secrets.token_urlsafe(32)
1018
+ code_expires_at = int(time.time() + DEFAULT_AUTH_CODE_EXPIRY_SECONDS)
1019
+
1020
+ # Store client code with PKCE challenge and IdP tokens
1021
+ self._client_codes[client_code] = {
1022
+ "client_id": transaction["client_id"],
1023
+ "redirect_uri": transaction["client_redirect_uri"],
1024
+ "code_challenge": transaction["code_challenge"],
1025
+ "code_challenge_method": transaction["code_challenge_method"],
1026
+ "scopes": transaction["scopes"],
1027
+ "idp_tokens": idp_tokens,
1028
+ "expires_at": code_expires_at,
1029
+ "created_at": time.time(),
1030
+ }
1031
+
1032
+ # Clean up transaction
1033
+ self._oauth_transactions.pop(txn_id, None)
1034
+
1035
+ # Build client callback URL with our code and original state
1036
+ client_redirect_uri = transaction["client_redirect_uri"]
1037
+ client_state = transaction["client_state"]
1038
+
1039
+ callback_params = {
1040
+ "code": client_code,
1041
+ "state": client_state,
1042
+ }
1043
+
1044
+ # Add query parameters to client redirect URI
1045
+ separator = "&" if "?" in client_redirect_uri else "?"
1046
+ client_callback_url = (
1047
+ f"{client_redirect_uri}{separator}{urlencode(callback_params)}"
1048
+ )
1049
+
1050
+ logger.debug(f"Forwarding to client callback for transaction {txn_id}")
1051
+
1052
+ return RedirectResponse(url=client_callback_url, status_code=302)
1053
+
1054
+ except Exception as e:
1055
+ logger.error("Error in IdP callback handler: %s", e, exc_info=True)
1056
+ return RedirectResponse(
1057
+ url="data:text/html,<h1>OAuth Error</h1><p>Internal server error during IdP callback</p>",
1058
+ status_code=302,
1059
+ )
src/fastmcp/utilities/tests.py CHANGED
@@ -76,6 +76,8 @@ def run_server_in_process(
76
  server_fn: Callable[..., None],
77
  *args,
78
  provide_host_and_port: bool = True,
 
 
79
  **kwargs,
80
  ) -> Generator[str, None, None]:
81
  """
@@ -87,13 +89,16 @@ def run_server_in_process(
87
  not pickleable, so we need a function that creates and runs one.
88
  *args: Arguments to pass to the server function.
89
  provide_host_and_port: Whether to provide the host and port to the server function as kwargs.
 
 
90
  **kwargs: Keyword arguments to pass to the server function.
91
 
92
  Returns:
93
  The server URL.
94
  """
95
- host = "127.0.0.1"
96
- port = find_available_port()
 
97
 
98
  if provide_host_and_port:
99
  kwargs |= {"host": host, "port": port}
 
76
  server_fn: Callable[..., None],
77
  *args,
78
  provide_host_and_port: bool = True,
79
+ host: str = "127.0.0.1",
80
+ port: int | None = None,
81
  **kwargs,
82
  ) -> Generator[str, None, None]:
83
  """
 
89
  not pickleable, so we need a function that creates and runs one.
90
  *args: Arguments to pass to the server function.
91
  provide_host_and_port: Whether to provide the host and port to the server function as kwargs.
92
+ host: Host to bind the server to (default: "127.0.0.1").
93
+ port: Port to bind the server to (default: find available port).
94
  **kwargs: Keyword arguments to pass to the server function.
95
 
96
  Returns:
97
  The server URL.
98
  """
99
+ # Use provided port or find an available one
100
+ if port is None:
101
+ port = find_available_port()
102
 
103
  if provide_host_and_port:
104
  kwargs |= {"host": host, "port": port}
test_github_oauth.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from fastmcp import FastMCP
4
+ from fastmcp.server.auth.providers.github import GitHubProvider
5
+
6
+ auth = GitHubProvider(
7
+ client_id=os.getenv("FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID") or "",
8
+ client_secret=os.getenv("FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET") or "",
9
+ base_url="http://localhost:8000",
10
+ )
11
+
12
+ mcp = FastMCP("GitHub OAuth Test Server", auth=auth)
13
+
14
+
15
+ @mcp.tool
16
+ def echo(message: str) -> str:
17
+ return message
18
+
19
+
20
+ if __name__ == "__main__":
21
+ mcp.run(transport="http", port=8000)
test_google_oauth.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ from fastmcp import FastMCP
4
+ from fastmcp.server.auth.providers.google import GoogleProvider
5
+
6
+ auth = GoogleProvider(
7
+ client_id=os.getenv("FASTMCP_TEST_AUTH_GOOGLE_CLIENT_ID") or "",
8
+ client_secret=os.getenv("FASTMCP_TEST_AUTH_GOOGLE_CLIENT_SECRET") or "",
9
+ base_url="http://localhost:8000",
10
+ required_scopes=["openid"],
11
+ )
12
+
13
+ mcp = FastMCP("Google OAuth Test Server", auth=auth)
14
+
15
+
16
+ @mcp.tool
17
+ def echo(message: str) -> str:
18
+ return message
19
+
20
+
21
+ if __name__ == "__main__":
22
+ mcp.run(transport="http", port=8000)
tests/integration_tests/auth/__init__.py ADDED
File without changes
tests/integration_tests/auth/test_github_provider_integration.py ADDED
@@ -0,0 +1,356 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Integration tests for GitHub OAuth Provider.
2
+
3
+ Tests the complete GitHub OAuth flow using HeadlessOAuth to bypass browser interaction.
4
+
5
+ This test requires a GitHub OAuth app to be created at https://github.com/settings/developers
6
+ with the following configuration:
7
+ - Redirect URL: http://127.0.0.1:9100/oauth/callback
8
+ - Client ID and Client Secret should be set as environment variables:
9
+ - FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID
10
+ - FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET
11
+ """
12
+
13
+ import os
14
+ from collections.abc import Generator
15
+ from urllib.parse import parse_qs, urlparse
16
+
17
+ import httpx
18
+ import pytest
19
+
20
+ from fastmcp import FastMCP
21
+ from fastmcp.client import Client
22
+ from fastmcp.server.auth.providers.github import GitHubProvider
23
+ from fastmcp.utilities.tests import HeadlessOAuth, run_server_in_process
24
+
25
+ FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID = os.getenv("FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID")
26
+ FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET = os.getenv(
27
+ "FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET"
28
+ )
29
+
30
+ # Skip tests if no GitHub OAuth credentials are available
31
+ pytestmark = pytest.mark.xfail(
32
+ not FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID
33
+ or not FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET,
34
+ reason="FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID and FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET environment variables are not set or empty",
35
+ )
36
+
37
+
38
+ def create_github_server(host: str = "127.0.0.1", port: int = 9100, **kwargs) -> None:
39
+ """Create FastMCP server with GitHub OAuth protection."""
40
+ assert FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID is not None
41
+ assert FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET is not None
42
+
43
+ # Create GitHub OAuth provider
44
+ auth = GitHubProvider(
45
+ client_id=FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID,
46
+ client_secret=FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET,
47
+ base_url=f"http://{host}:{port}",
48
+ )
49
+
50
+ # Create FastMCP server with GitHub authentication
51
+ server = FastMCP("GitHub OAuth Integration Test Server", auth=auth)
52
+
53
+ @server.tool
54
+ def get_protected_data() -> str:
55
+ """Returns protected data - requires GitHub OAuth."""
56
+ return "🔐 This data requires GitHub OAuth authentication!"
57
+
58
+ @server.tool
59
+ def get_user_info() -> str:
60
+ """Returns user info from OAuth context."""
61
+ return "📝 GitHub OAuth user authenticated successfully"
62
+
63
+ # Run the server
64
+ server.run(host=host, port=port, **kwargs)
65
+
66
+
67
+ def create_github_server_with_mock_callback(
68
+ host: str = "127.0.0.1", port: int = 9100, **kwargs
69
+ ) -> None:
70
+ """Create FastMCP server with GitHub OAuth that mocks the callback for testing."""
71
+ assert FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID is not None
72
+ assert FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET is not None
73
+
74
+ # Create GitHub OAuth provider
75
+ auth = GitHubProvider(
76
+ client_id=FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID,
77
+ client_secret=FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET,
78
+ base_url=f"http://{host}:{port}",
79
+ )
80
+
81
+ # Mock the authorize method to return a fake code instead of redirecting to GitHub
82
+ async def mock_authorize(client, params):
83
+ # Instead of redirecting to GitHub, simulate an immediate callback
84
+ import secrets
85
+ import time
86
+
87
+ # Generate a fake authorization code
88
+ fake_code = secrets.token_urlsafe(32)
89
+
90
+ # Create mock token response (simulating what GitHub would return)
91
+ mock_tokens = {
92
+ "access_token": f"gho_mock_token_{secrets.token_hex(16)}",
93
+ "token_type": "bearer",
94
+ "expires_in": 3600,
95
+ }
96
+
97
+ # Store the mock tokens in the proxy's client codes
98
+ auth._client_codes[fake_code] = {
99
+ "client_id": client.client_id,
100
+ "redirect_uri": str(params.redirect_uri),
101
+ "code_challenge": params.code_challenge,
102
+ "code_challenge_method": getattr(params, "code_challenge_method", "S256"),
103
+ "scopes": params.scopes or [],
104
+ "idp_tokens": mock_tokens,
105
+ "expires_at": int(time.time() + 300), # 5 minutes
106
+ "created_at": time.time(),
107
+ }
108
+
109
+ # Return the redirect to the client's callback with the fake code
110
+ callback_params = {
111
+ "code": fake_code,
112
+ "state": params.state,
113
+ }
114
+ from urllib.parse import urlencode
115
+
116
+ separator = "&" if "?" in str(params.redirect_uri) else "?"
117
+ return f"{params.redirect_uri}{separator}{urlencode(callback_params)}"
118
+
119
+ auth.authorize = mock_authorize
120
+
121
+ # Mock the token verifier to accept our fake tokens
122
+ original_verify_token = auth._token_validator.verify_token
123
+
124
+ async def mock_verify_token(token: str):
125
+ if token.startswith("gho_mock_token_"):
126
+ # Return a mock AccessToken for our fake tokens
127
+ import time
128
+
129
+ from fastmcp.server.auth.auth import AccessToken
130
+
131
+ return AccessToken(
132
+ token=token,
133
+ client_id=FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID or "test-client",
134
+ scopes=["user"],
135
+ expires_at=int(time.time() + 3600),
136
+ )
137
+ # Fall back to original verification for other tokens
138
+ return await original_verify_token(token)
139
+
140
+ auth._token_validator.verify_token = mock_verify_token
141
+
142
+ # Create FastMCP server with mocked GitHub authentication
143
+ server = FastMCP("GitHub OAuth Integration Test Server (Mock)", auth=auth)
144
+
145
+ @server.tool
146
+ def get_protected_data() -> str:
147
+ """Returns protected data - requires GitHub OAuth."""
148
+ return "🔐 This data requires GitHub OAuth authentication!"
149
+
150
+ @server.tool
151
+ def get_user_info() -> str:
152
+ """Returns user info from OAuth context."""
153
+ return "📝 GitHub OAuth user authenticated successfully"
154
+
155
+ # Run the server
156
+ server.run(host=host, port=port, **kwargs)
157
+
158
+
159
+ @pytest.fixture(scope="module")
160
+ def github_server() -> Generator[str, None, None]:
161
+ """Start GitHub OAuth server in background process on fixed port 9100."""
162
+ with run_server_in_process(
163
+ create_github_server, transport="http", host="127.0.0.1", port=9100
164
+ ) as url:
165
+ yield f"{url}/mcp"
166
+
167
+
168
+ @pytest.fixture(scope="module")
169
+ def github_server_with_mock() -> Generator[str, None, None]:
170
+ """Start GitHub OAuth server with mocked callback in background process on port 9101."""
171
+ with run_server_in_process(
172
+ create_github_server_with_mock_callback,
173
+ transport="http",
174
+ host="127.0.0.1",
175
+ port=9101,
176
+ ) as url:
177
+ yield f"{url}/mcp"
178
+
179
+
180
+ @pytest.fixture
181
+ def github_client(github_server: str) -> Client:
182
+ """Create FastMCP client with HeadlessOAuth for GitHub server."""
183
+ return Client(
184
+ github_server,
185
+ auth=HeadlessOAuth(mcp_url=github_server),
186
+ )
187
+
188
+
189
+ @pytest.fixture
190
+ def github_client_with_mock(github_server_with_mock: str) -> Client:
191
+ """Create FastMCP client with HeadlessOAuth for mocked GitHub server."""
192
+ return Client(
193
+ github_server_with_mock,
194
+ auth=HeadlessOAuth(mcp_url=github_server_with_mock),
195
+ )
196
+
197
+
198
+ async def test_github_oauth_credentials_available():
199
+ """Test that GitHub OAuth credentials are available for testing."""
200
+ assert FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID is not None
201
+ assert FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET is not None
202
+ assert len(FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID) > 0
203
+ assert len(FASTMCP_TEST_AUTH_GITHUB_CLIENT_SECRET) > 0
204
+
205
+
206
+ async def test_github_oauth_authorization_redirect(github_server: str):
207
+ """Test that GitHub OAuth authorization redirects to GitHub correctly.
208
+
209
+ Since HeadlessOAuth can't handle real GitHub redirects, we test that:
210
+ 1. DCR client registration works
211
+ 2. Authorization endpoint redirects to GitHub with correct parameters
212
+ """
213
+ # Extract base URL
214
+ parsed = urlparse(github_server)
215
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
216
+
217
+ async with httpx.AsyncClient() as http_client:
218
+ # Step 1: Register OAuth client (DCR)
219
+ register_response = await http_client.post(
220
+ f"{base_url}/register",
221
+ json={
222
+ "client_name": "Integration Test Client",
223
+ "redirect_uris": ["http://localhost:12345/callback"],
224
+ "grant_types": ["authorization_code", "refresh_token"],
225
+ "response_types": ["code"],
226
+ "token_endpoint_auth_method": "client_secret_post",
227
+ },
228
+ )
229
+ if register_response.status_code != 201:
230
+ print(f"Registration failed: {register_response.status_code}")
231
+ print(f"Response: {register_response.text}")
232
+ assert register_response.status_code == 201
233
+
234
+ client_info = register_response.json()
235
+ client_id = client_info["client_id"]
236
+ assert client_id is not None
237
+
238
+ # Step 2: Test authorization endpoint redirects to GitHub
239
+ auth_url = f"{base_url}/authorize"
240
+ auth_params = {
241
+ "response_type": "code",
242
+ "client_id": client_id,
243
+ "redirect_uri": "http://localhost:12345/callback",
244
+ "state": "test-state-123",
245
+ "code_challenge": "test-challenge",
246
+ "code_challenge_method": "S256",
247
+ }
248
+
249
+ auth_response = await http_client.get(
250
+ auth_url, params=auth_params, follow_redirects=False
251
+ )
252
+
253
+ # Should redirect to GitHub
254
+ assert auth_response.status_code == 302
255
+ redirect_location = auth_response.headers["location"]
256
+
257
+ # Parse redirect URL - should be GitHub
258
+ redirect_parsed = urlparse(redirect_location)
259
+ assert redirect_parsed.hostname == "github.com"
260
+ assert redirect_parsed.path == "/login/oauth/authorize"
261
+
262
+ # Check that GitHub gets the right parameters
263
+ github_params = parse_qs(redirect_parsed.query)
264
+ assert "client_id" in github_params
265
+ assert github_params["client_id"][0] == FASTMCP_TEST_AUTH_GITHUB_CLIENT_ID
266
+ assert "redirect_uri" in github_params
267
+ # The redirect_uri should be our proxy's callback, not the client's
268
+ proxy_callback = github_params["redirect_uri"][0]
269
+ assert proxy_callback.startswith(base_url)
270
+ assert proxy_callback.endswith("/oauth/callback")
271
+
272
+
273
+ async def test_github_oauth_server_metadata(github_server: str):
274
+ """Test OAuth server metadata discovery."""
275
+ from urllib.parse import urlparse
276
+
277
+ import httpx
278
+
279
+ # Extract base URL from server URL
280
+ parsed = urlparse(github_server)
281
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
282
+
283
+ async with httpx.AsyncClient() as http_client:
284
+ # Test OAuth authorization server metadata
285
+ metadata_response = await http_client.get(
286
+ f"{base_url}/.well-known/oauth-authorization-server"
287
+ )
288
+ assert metadata_response.status_code == 200
289
+
290
+ metadata = metadata_response.json()
291
+ assert "authorization_endpoint" in metadata
292
+ assert "token_endpoint" in metadata
293
+ assert "registration_endpoint" in metadata
294
+ assert "issuer" in metadata
295
+
296
+ # Verify endpoints are properly formed
297
+ assert metadata["authorization_endpoint"].startswith(base_url)
298
+ assert metadata["token_endpoint"].startswith(base_url)
299
+ assert metadata["registration_endpoint"].startswith(base_url)
300
+
301
+
302
+ async def test_github_oauth_unauthorized_access(github_server: str):
303
+ """Test that unauthenticated requests are rejected."""
304
+ import httpx
305
+
306
+ from fastmcp.client.transports import StreamableHttpTransport
307
+
308
+ # Create client without OAuth authentication
309
+ unauthorized_client = Client(transport=StreamableHttpTransport(github_server))
310
+
311
+ # Attempt to connect without authentication should fail
312
+ with pytest.raises(httpx.HTTPStatusError, match="401 Unauthorized"):
313
+ async with unauthorized_client:
314
+ pass
315
+
316
+
317
+ async def test_github_oauth_with_mock(github_client_with_mock: Client):
318
+ """Test complete GitHub OAuth flow with mocked callback."""
319
+ async with github_client_with_mock:
320
+ # Test that we can ping the server (requires successful OAuth)
321
+ assert await github_client_with_mock.ping()
322
+
323
+ # Test that we can call protected tools
324
+ result = await github_client_with_mock.call_tool("get_protected_data", {})
325
+ assert "🔐 This data requires GitHub OAuth authentication!" in str(result.data)
326
+
327
+ # Test that we can call user info tool
328
+ result = await github_client_with_mock.call_tool("get_user_info", {})
329
+ assert "📝 GitHub OAuth user authenticated successfully" in str(result.data)
330
+
331
+
332
+ async def test_github_oauth_mock_only_accepts_mock_tokens(github_server_with_mock: str):
333
+ """Test that the mock token verifier only accepts mock tokens, not real ones."""
334
+ from urllib.parse import urlparse
335
+
336
+ import httpx
337
+
338
+ # Extract base URL
339
+ parsed = urlparse(github_server_with_mock)
340
+ base_url = f"{parsed.scheme}://{parsed.netloc}"
341
+
342
+ async with httpx.AsyncClient() as http_client:
343
+ # Test that a fake "real" GitHub token is rejected
344
+ fake_real_token = "gho_real_token_should_be_rejected"
345
+
346
+ auth_response = await http_client.post(
347
+ f"{base_url}/mcp",
348
+ headers={
349
+ "Authorization": f"Bearer {fake_real_token}",
350
+ "Content-Type": "application/json",
351
+ },
352
+ json={"jsonrpc": "2.0", "id": 1, "method": "ping"},
353
+ )
354
+
355
+ # Should be unauthorized because it's not a mock token
356
+ assert auth_response.status_code == 401
tests/server/auth/providers/test_github.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Unit tests for GitHub OAuth provider."""
2
+
3
+ import os
4
+ from unittest.mock import MagicMock, patch
5
+
6
+ import pytest
7
+
8
+ from fastmcp.server.auth.providers.github import (
9
+ GitHubProvider,
10
+ GitHubProviderSettings,
11
+ GitHubTokenVerifier,
12
+ )
13
+
14
+
15
+ class TestGitHubProviderSettings:
16
+ """Test settings for GitHub OAuth provider."""
17
+
18
+ def test_settings_from_env_vars(self):
19
+ """Test that settings can be loaded from environment variables."""
20
+ with patch.dict(
21
+ os.environ,
22
+ {
23
+ "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID": "env_client_id",
24
+ "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET": "env_secret",
25
+ "FASTMCP_SERVER_AUTH_GITHUB_BASE_URL": "https://example.com",
26
+ "FASTMCP_SERVER_AUTH_GITHUB_REDIRECT_PATH": "/custom/callback",
27
+ "FASTMCP_SERVER_AUTH_GITHUB_TIMEOUT_SECONDS": "30",
28
+ },
29
+ ):
30
+ settings = GitHubProviderSettings()
31
+
32
+ assert settings.client_id == "env_client_id"
33
+ assert (
34
+ settings.client_secret
35
+ and settings.client_secret.get_secret_value() == "env_secret"
36
+ )
37
+ assert settings.base_url == "https://example.com"
38
+ assert settings.redirect_path == "/custom/callback"
39
+ assert settings.timeout_seconds == 30
40
+
41
+ def test_settings_explicit_override_env(self):
42
+ """Test that explicit settings override environment variables."""
43
+ with patch.dict(
44
+ os.environ,
45
+ {
46
+ "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID": "env_client_id",
47
+ "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET": "env_secret",
48
+ },
49
+ ):
50
+ settings = GitHubProviderSettings.model_validate(
51
+ {
52
+ "client_id": "explicit_client_id",
53
+ "client_secret": "explicit_secret",
54
+ }
55
+ )
56
+
57
+ assert settings.client_id == "explicit_client_id"
58
+ assert (
59
+ settings.client_secret
60
+ and settings.client_secret.get_secret_value() == "explicit_secret"
61
+ )
62
+
63
+
64
+ class TestGitHubProvider:
65
+ """Test GitHubProvider initialization."""
66
+
67
+ def test_init_with_explicit_params(self):
68
+ """Test initialization with explicit parameters."""
69
+ provider = GitHubProvider(
70
+ client_id="test_client",
71
+ client_secret="test_secret",
72
+ base_url="https://example.com",
73
+ redirect_path="/custom/callback",
74
+ required_scopes=["user", "repo"],
75
+ timeout_seconds=30,
76
+ )
77
+
78
+ # Check that the provider was initialized correctly
79
+ assert provider._upstream_client_id == "test_client"
80
+ assert provider._upstream_client_secret.get_secret_value() == "test_secret"
81
+ assert (
82
+ str(provider.base_url) == "https://example.com/"
83
+ ) # URLs get normalized with trailing slash
84
+ assert provider._redirect_path == "/custom/callback"
85
+
86
+ def test_init_with_env_vars(self):
87
+ """Test initialization with environment variables."""
88
+ with patch.dict(
89
+ os.environ,
90
+ {
91
+ "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID": "env_client_id",
92
+ "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET": "env_secret",
93
+ "FASTMCP_SERVER_AUTH_GITHUB_BASE_URL": "https://env-example.com",
94
+ },
95
+ ):
96
+ provider = GitHubProvider()
97
+
98
+ assert provider._upstream_client_id == "env_client_id"
99
+ assert provider._upstream_client_secret.get_secret_value() == "env_secret"
100
+ assert str(provider.base_url) == "https://env-example.com/"
101
+
102
+ def test_init_explicit_overrides_env(self):
103
+ """Test that explicit parameters override environment variables."""
104
+ with patch.dict(
105
+ os.environ,
106
+ {
107
+ "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_ID": "env_client_id",
108
+ "FASTMCP_SERVER_AUTH_GITHUB_CLIENT_SECRET": "env_secret",
109
+ },
110
+ ):
111
+ provider = GitHubProvider(
112
+ client_id="explicit_client",
113
+ client_secret="explicit_secret",
114
+ )
115
+
116
+ assert provider._upstream_client_id == "explicit_client"
117
+ assert (
118
+ provider._upstream_client_secret.get_secret_value() == "explicit_secret"
119
+ )
120
+
121
+ def test_init_missing_client_id_raises_error(self):
122
+ """Test that missing client_id raises ValueError."""
123
+ with pytest.raises(ValueError, match="client_id is required"):
124
+ GitHubProvider(client_secret="test_secret")
125
+
126
+ def test_init_missing_client_secret_raises_error(self):
127
+ """Test that missing client_secret raises ValueError."""
128
+ with pytest.raises(ValueError, match="client_secret is required"):
129
+ GitHubProvider(client_id="test_client")
130
+
131
+ def test_init_defaults(self):
132
+ """Test that default values are applied correctly."""
133
+ provider = GitHubProvider(
134
+ client_id="test_client",
135
+ client_secret="test_secret",
136
+ )
137
+
138
+ # Check defaults
139
+ assert str(provider.base_url) == "http://localhost:8000/"
140
+ assert provider._redirect_path == "/oauth/callback"
141
+ # The required_scopes should be passed to the token verifier
142
+ assert provider._token_validator.required_scopes == ["user"]
143
+
144
+
145
+ class TestGitHubTokenVerifier:
146
+ """Test GitHubTokenVerifier."""
147
+
148
+ def test_init_with_custom_scopes(self):
149
+ """Test initialization with custom required scopes."""
150
+ verifier = GitHubTokenVerifier(
151
+ required_scopes=["user", "repo"],
152
+ timeout_seconds=30,
153
+ )
154
+
155
+ assert verifier.required_scopes == ["user", "repo"]
156
+ assert verifier.timeout_seconds == 30
157
+
158
+ def test_init_defaults(self):
159
+ """Test initialization with defaults."""
160
+ verifier = GitHubTokenVerifier()
161
+
162
+ assert (
163
+ verifier.required_scopes == []
164
+ ) # Parent TokenVerifier sets empty list as default
165
+ assert verifier.timeout_seconds == 10
166
+
167
+ @pytest.mark.asyncio
168
+ async def test_verify_token_github_api_failure(self):
169
+ """Test token verification when GitHub API returns error."""
170
+ verifier = GitHubTokenVerifier()
171
+
172
+ # Mock httpx.AsyncClient to simulate GitHub API failure
173
+ with patch("httpx.AsyncClient") as mock_client_class:
174
+ mock_client = MagicMock()
175
+ mock_client_class.return_value.__aenter__.return_value = mock_client
176
+
177
+ # Simulate 401 response from GitHub
178
+ mock_response = MagicMock()
179
+ mock_response.status_code = 401
180
+ mock_response.text = "Bad credentials"
181
+ mock_client.get.return_value = mock_response
182
+
183
+ result = await verifier.verify_token("invalid_token")
184
+ assert result is None
185
+
186
+ @pytest.mark.asyncio
187
+ async def test_verify_token_success(self):
188
+ """Test successful token verification."""
189
+ from unittest.mock import AsyncMock
190
+
191
+ verifier = GitHubTokenVerifier(required_scopes=["user"])
192
+
193
+ # Mock the httpx.AsyncClient directly
194
+ mock_client = AsyncMock()
195
+
196
+ # Mock successful user API response
197
+ user_response = MagicMock()
198
+ user_response.status_code = 200
199
+ user_response.json.return_value = {
200
+ "id": 12345,
201
+ "login": "testuser",
202
+ "name": "Test User",
203
+ "email": "test@example.com",
204
+ "avatar_url": "https://github.com/testuser.png",
205
+ }
206
+
207
+ # Mock successful scopes API response
208
+ scopes_response = MagicMock()
209
+ scopes_response.headers = {"x-oauth-scopes": "user,repo"}
210
+
211
+ # Set up the mock client to return our responses
212
+ mock_client.get.side_effect = [user_response, scopes_response]
213
+
214
+ # Patch the AsyncClient context manager
215
+ with patch(
216
+ "fastmcp.server.auth.providers.github.httpx.AsyncClient"
217
+ ) as mock_client_class:
218
+ mock_client_class.return_value.__aenter__.return_value = mock_client
219
+
220
+ result = await verifier.verify_token("valid_token")
221
+
222
+ assert result is not None
223
+ assert result.token == "valid_token"
224
+ assert result.client_id == "12345"
225
+ assert result.scopes == ["user", "repo"]
226
+ assert result.claims["login"] == "testuser"
227
+ assert result.claims["name"] == "Test User"
tests/server/auth/test_oauth_proxy.py ADDED
@@ -0,0 +1,548 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Comprehensive tests for OAuth Proxy Provider functionality."""
2
+
3
+ import time
4
+ from unittest.mock import Mock
5
+ from urllib.parse import parse_qs, urlparse
6
+
7
+ import pytest
8
+ from mcp.server.auth.provider import AuthorizationParams
9
+ from mcp.shared.auth import OAuthClientInformationFull
10
+ from pydantic import AnyUrl
11
+
12
+ from fastmcp.server.auth.auth import AccessToken
13
+ from fastmcp.server.auth.providers.jwt import JWTVerifier
14
+ from fastmcp.server.auth.proxy import OAuthProxy
15
+
16
+
17
+ class TestOAuthProxyComprehensive:
18
+ """Comprehensive test suite for OAuthProxy provider functionality."""
19
+
20
+ @pytest.fixture
21
+ def jwt_verifier(self):
22
+ """Create a mock JWT verifier for testing."""
23
+ verifier = Mock(spec=JWTVerifier)
24
+ verifier.required_scopes = ["read", "write"]
25
+ verifier.verify_token = Mock(return_value=None)
26
+ return verifier
27
+
28
+ @pytest.fixture
29
+ def oauth_proxy(self, jwt_verifier):
30
+ """Create an OAuthProxy instance for testing."""
31
+ return OAuthProxy(
32
+ upstream_authorization_endpoint="https://github.com/login/oauth/authorize",
33
+ upstream_token_endpoint="https://github.com/login/oauth/access_token",
34
+ upstream_client_id="test-client-id",
35
+ upstream_client_secret="test-client-secret",
36
+ token_verifier=jwt_verifier,
37
+ base_url="https://myserver.com",
38
+ redirect_path="/oauth/callback",
39
+ )
40
+
41
+ def test_initialization_with_string_urls(self, jwt_verifier):
42
+ """Test OAuthProxy initialization with string URLs (not AnyHttpUrl objects)."""
43
+ proxy = OAuthProxy(
44
+ upstream_authorization_endpoint="https://auth.example.com/authorize",
45
+ upstream_token_endpoint="https://auth.example.com/token",
46
+ upstream_client_id="client-123",
47
+ upstream_client_secret="secret-456",
48
+ token_verifier=jwt_verifier,
49
+ base_url="https://api.example.com", # String instead of AnyHttpUrl
50
+ issuer_url="https://issuer.example.com", # String
51
+ service_documentation_url="https://docs.example.com", # String
52
+ resource_server_url="https://resources.example.com", # String
53
+ )
54
+
55
+ # Should work fine and convert internally to AnyHttpUrl
56
+ assert str(proxy.base_url) == "https://api.example.com/"
57
+ assert str(proxy.issuer_url) == "https://issuer.example.com/"
58
+ assert str(proxy.service_documentation_url) == "https://docs.example.com/"
59
+ assert str(proxy.resource_server_url) == "https://resources.example.com/"
60
+
61
+ def test_initialization_with_all_parameters(self, jwt_verifier):
62
+ """Test OAuthProxy initialization with all optional parameters."""
63
+ proxy = OAuthProxy(
64
+ upstream_authorization_endpoint="https://auth.example.com/authorize",
65
+ upstream_token_endpoint="https://auth.example.com/token",
66
+ upstream_client_id="client-123",
67
+ upstream_client_secret="secret-456",
68
+ upstream_revocation_endpoint="https://auth.example.com/revoke",
69
+ token_verifier=jwt_verifier,
70
+ base_url="https://api.example.com",
71
+ redirect_path="/auth/callback",
72
+ issuer_url="https://issuer.example.com",
73
+ service_documentation_url="https://docs.example.com",
74
+ resource_server_url="https://resources.example.com",
75
+ )
76
+
77
+ # Verify all parameters are set correctly
78
+ assert (
79
+ proxy._upstream_authorization_endpoint
80
+ == "https://auth.example.com/authorize"
81
+ )
82
+ assert proxy._upstream_token_endpoint == "https://auth.example.com/token"
83
+ assert proxy._upstream_client_id == "client-123"
84
+ assert proxy._upstream_client_secret.get_secret_value() == "secret-456"
85
+ assert proxy._upstream_revocation_endpoint == "https://auth.example.com/revoke"
86
+ assert proxy._redirect_path == "/auth/callback"
87
+ assert str(proxy.issuer_url) == "https://issuer.example.com/"
88
+ assert str(proxy.service_documentation_url) == "https://docs.example.com/"
89
+ assert str(proxy.resource_server_url) == "https://resources.example.com/"
90
+
91
+ def test_redirect_path_normalization(self, jwt_verifier):
92
+ """Test that redirect_path is normalized to start with /."""
93
+ # Without leading slash
94
+ proxy1 = OAuthProxy(
95
+ upstream_authorization_endpoint="https://auth.com/authorize",
96
+ upstream_token_endpoint="https://auth.com/token",
97
+ upstream_client_id="client",
98
+ upstream_client_secret="secret",
99
+ token_verifier=jwt_verifier,
100
+ base_url="https://server.com",
101
+ redirect_path="oauth/callback",
102
+ )
103
+ assert proxy1._redirect_path == "/oauth/callback"
104
+
105
+ # With leading slash
106
+ proxy2 = OAuthProxy(
107
+ upstream_authorization_endpoint="https://auth.com/authorize",
108
+ upstream_token_endpoint="https://auth.com/token",
109
+ upstream_client_id="client",
110
+ upstream_client_secret="secret",
111
+ token_verifier=jwt_verifier,
112
+ base_url="https://server.com",
113
+ redirect_path="/oauth/callback",
114
+ )
115
+ assert proxy2._redirect_path == "/oauth/callback"
116
+
117
+ def test_dcr_always_enabled(self, jwt_verifier):
118
+ """Test that DCR is always enabled for OAuth Proxy."""
119
+ proxy = OAuthProxy(
120
+ upstream_authorization_endpoint="https://auth.com/authorize",
121
+ upstream_token_endpoint="https://auth.com/token",
122
+ upstream_client_id="client",
123
+ upstream_client_secret="secret",
124
+ token_verifier=jwt_verifier,
125
+ base_url="https://server.com",
126
+ )
127
+
128
+ assert proxy.client_registration_options is not None
129
+ assert proxy.client_registration_options.enabled is True
130
+
131
+ def test_revocation_enabled_with_endpoint(self, jwt_verifier):
132
+ """Test that revocation is enabled when upstream endpoint is provided."""
133
+ proxy = OAuthProxy(
134
+ upstream_authorization_endpoint="https://auth.com/authorize",
135
+ upstream_token_endpoint="https://auth.com/token",
136
+ upstream_client_id="client",
137
+ upstream_client_secret="secret",
138
+ upstream_revocation_endpoint="https://auth.com/revoke",
139
+ token_verifier=jwt_verifier,
140
+ base_url="https://server.com",
141
+ )
142
+
143
+ assert proxy.revocation_options is not None
144
+ assert proxy.revocation_options.enabled is True
145
+ assert proxy._upstream_revocation_endpoint == "https://auth.com/revoke"
146
+
147
+ def test_revocation_disabled_without_endpoint(self, jwt_verifier):
148
+ """Test that revocation is disabled when no upstream endpoint is provided."""
149
+ proxy = OAuthProxy(
150
+ upstream_authorization_endpoint="https://auth.com/authorize",
151
+ upstream_token_endpoint="https://auth.com/token",
152
+ upstream_client_id="client",
153
+ upstream_client_secret="secret",
154
+ token_verifier=jwt_verifier,
155
+ base_url="https://server.com",
156
+ )
157
+
158
+ assert proxy.revocation_options is None
159
+ assert proxy._upstream_revocation_endpoint is None
160
+
161
+ @pytest.mark.asyncio
162
+ async def test_register_client(self, oauth_proxy):
163
+ """Test client registration always uses upstream credentials."""
164
+ client_info = OAuthClientInformationFull(
165
+ client_id="original-client-id",
166
+ client_secret="original-secret",
167
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
168
+ grant_types=["authorization_code"],
169
+ token_endpoint_auth_method="client_secret_post",
170
+ )
171
+
172
+ await oauth_proxy.register_client(client_info)
173
+
174
+ # Verify client was modified to use upstream credentials
175
+ assert client_info.client_id == "test-client-id"
176
+ assert client_info.client_secret == "test-client-secret"
177
+ assert client_info.token_endpoint_auth_method == "none"
178
+ assert "authorization_code" in client_info.grant_types
179
+ # refresh_token is only added if grant_types was empty
180
+
181
+ # Verify client was stored
182
+ stored_client = oauth_proxy._clients.get("test-client-id")
183
+ assert stored_client is not None
184
+ assert stored_client.client_id == "test-client-id"
185
+
186
+ @pytest.mark.asyncio
187
+ async def test_register_client_empty_grant_types(self, oauth_proxy):
188
+ """Test client registration adds grant types when empty."""
189
+ client_info = OAuthClientInformationFull(
190
+ client_id="original-client-id",
191
+ client_secret="original-secret",
192
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
193
+ grant_types=[], # Empty grant types list
194
+ )
195
+
196
+ await oauth_proxy.register_client(client_info)
197
+
198
+ # Should add both authorization_code and refresh_token
199
+ assert client_info.grant_types == ["authorization_code", "refresh_token"]
200
+
201
+ @pytest.mark.asyncio
202
+ async def test_get_client_existing(self, oauth_proxy):
203
+ """Test getting an existing registered client."""
204
+ # Register a client first
205
+ client_info = OAuthClientInformationFull(
206
+ client_id="test-id",
207
+ client_secret="test-secret",
208
+ redirect_uris=[AnyUrl("http://localhost:12345/callback")],
209
+ )
210
+ await oauth_proxy.register_client(client_info)
211
+
212
+ # Get the client
213
+ retrieved = await oauth_proxy.get_client("test-client-id")
214
+ assert retrieved is not None
215
+ assert retrieved.client_id == "test-client-id"
216
+
217
+ @pytest.mark.asyncio
218
+ async def test_get_client_temporary(self, oauth_proxy):
219
+ """Test getting a temporary client for unregistered client ID."""
220
+ # Get a client that hasn't been registered
221
+ temp_client = await oauth_proxy.get_client("unknown-client-id")
222
+
223
+ assert temp_client is not None
224
+ assert temp_client.client_id == "unknown-client-id"
225
+ assert temp_client.client_secret is None
226
+ assert temp_client.token_endpoint_auth_method == "none"
227
+ assert len(temp_client.redirect_uris) >= 1
228
+ # ProxyDCRClient uses a placeholder URL but accepts any localhost URI
229
+ assert str(temp_client.redirect_uris[0]) == "http://localhost/"
230
+
231
+ # Test that it accepts any localhost redirect URI
232
+ from pydantic import AnyUrl
233
+
234
+ test_uri = temp_client.validate_redirect_uri(
235
+ AnyUrl("http://localhost:55454/callback")
236
+ )
237
+ assert str(test_uri) == "http://localhost:55454/callback"
238
+
239
+ @pytest.mark.asyncio
240
+ async def test_authorize_creates_transaction(self, oauth_proxy):
241
+ """Test that authorize creates a transaction and returns upstream URL."""
242
+ client = OAuthClientInformationFull(
243
+ client_id="test-client",
244
+ client_secret="test-secret",
245
+ redirect_uris=[AnyUrl("http://localhost:54321/callback")],
246
+ )
247
+
248
+ params = AuthorizationParams(
249
+ redirect_uri=AnyUrl("http://localhost:54321/callback"),
250
+ redirect_uri_provided_explicitly=True,
251
+ state="client-state-123",
252
+ code_challenge="challenge-abc",
253
+ scopes=["read", "write"],
254
+ )
255
+
256
+ # Call authorize
257
+ redirect_url = await oauth_proxy.authorize(client, params)
258
+
259
+ # Parse the redirect URL
260
+ parsed = urlparse(redirect_url)
261
+ query_params = parse_qs(parsed.query)
262
+
263
+ # Verify it's redirecting to upstream
264
+ assert parsed.scheme == "https"
265
+ assert parsed.netloc == "github.com"
266
+ assert parsed.path == "/login/oauth/authorize"
267
+
268
+ # Verify query parameters
269
+ assert query_params["response_type"] == ["code"]
270
+ assert query_params["client_id"] == ["test-client-id"]
271
+ assert query_params["redirect_uri"] == ["https://myserver.com/oauth/callback"]
272
+ assert "state" in query_params # This should be the transaction ID
273
+ assert query_params["scope"] == ["read write"]
274
+
275
+ # Verify transaction was stored
276
+ txn_id = query_params["state"][0]
277
+ transaction = oauth_proxy._oauth_transactions.get(txn_id)
278
+ assert transaction is not None
279
+ assert transaction["client_id"] == "test-client"
280
+ assert transaction["client_redirect_uri"] == "http://localhost:54321/callback"
281
+ assert transaction["client_state"] == "client-state-123"
282
+ assert transaction["code_challenge"] == "challenge-abc"
283
+ assert transaction["code_challenge_method"] == "S256"
284
+ assert transaction["scopes"] == ["read", "write"]
285
+
286
+ @pytest.mark.asyncio
287
+ async def test_authorize_without_scopes(self, oauth_proxy):
288
+ """Test authorize without scopes uses required scopes from verifier."""
289
+ client = OAuthClientInformationFull(
290
+ client_id="test-client",
291
+ client_secret="test-secret",
292
+ redirect_uris=[AnyUrl("http://localhost:54321/callback")],
293
+ )
294
+
295
+ params = AuthorizationParams(
296
+ redirect_uri=AnyUrl("http://localhost:54321/callback"),
297
+ redirect_uri_provided_explicitly=True,
298
+ state="client-state",
299
+ code_challenge="challenge",
300
+ scopes=[], # Empty scopes to test fallback
301
+ )
302
+
303
+ redirect_url = await oauth_proxy.authorize(client, params)
304
+
305
+ parsed = urlparse(redirect_url)
306
+ query_params = parse_qs(parsed.query)
307
+
308
+ # Should use required_scopes from token_verifier
309
+ assert query_params["scope"] == ["read write"]
310
+
311
+ @pytest.mark.asyncio
312
+ async def test_authorize_google_minimal_scope(self, jwt_verifier):
313
+ """Test that Google OAuth gets minimal scope when none specified."""
314
+ # Create proxy with Google endpoints
315
+ proxy = OAuthProxy(
316
+ upstream_authorization_endpoint="https://accounts.google.com/o/oauth2/v2/auth",
317
+ upstream_token_endpoint="https://oauth2.googleapis.com/token",
318
+ upstream_client_id="google-client",
319
+ upstream_client_secret="google-secret",
320
+ token_verifier=Mock(required_scopes=None), # No required scopes
321
+ base_url="https://myserver.com",
322
+ )
323
+
324
+ client = OAuthClientInformationFull(
325
+ client_id="test-client",
326
+ client_secret="test-secret",
327
+ redirect_uris=[AnyUrl("http://localhost:54321/callback")],
328
+ )
329
+
330
+ params = AuthorizationParams(
331
+ redirect_uri=AnyUrl("http://localhost:54321/callback"),
332
+ redirect_uri_provided_explicitly=True,
333
+ state="state",
334
+ code_challenge="challenge",
335
+ scopes=[], # Empty scopes to test Google fallback
336
+ )
337
+
338
+ redirect_url = await proxy.authorize(client, params)
339
+
340
+ parsed = urlparse(redirect_url)
341
+ query_params = parse_qs(parsed.query)
342
+
343
+ # Should add minimal scope for Google
344
+ assert query_params["scope"] == ["openid"]
345
+
346
+ @pytest.mark.asyncio
347
+ async def test_load_authorization_code_valid(self, oauth_proxy):
348
+ """Test loading a valid authorization code."""
349
+ # Store a client code
350
+ code = "test-auth-code"
351
+ oauth_proxy._client_codes[code] = {
352
+ "client_id": "test-client-id",
353
+ "redirect_uri": "http://localhost:54321/callback",
354
+ "code_challenge": "challenge-123",
355
+ "scopes": ["read", "write"],
356
+ "expires_at": time.time() + 300, # 5 minutes from now
357
+ "idp_tokens": {"access_token": "token-123"},
358
+ }
359
+
360
+ client = OAuthClientInformationFull(
361
+ client_id="test-client-id",
362
+ client_secret="secret",
363
+ redirect_uris=[AnyUrl("http://localhost:54321/callback")],
364
+ )
365
+
366
+ # Load the code
367
+ auth_code = await oauth_proxy.load_authorization_code(client, code)
368
+
369
+ assert auth_code is not None
370
+ assert auth_code.code == code
371
+ assert auth_code.client_id == "test-client-id"
372
+ assert str(auth_code.redirect_uri) == "http://localhost:54321/callback"
373
+ assert auth_code.code_challenge == "challenge-123"
374
+ assert auth_code.scopes == ["read", "write"]
375
+
376
+ @pytest.mark.asyncio
377
+ async def test_load_authorization_code_expired(self, oauth_proxy):
378
+ """Test loading an expired authorization code returns None."""
379
+ code = "expired-code"
380
+ oauth_proxy._client_codes[code] = {
381
+ "client_id": "test-client-id",
382
+ "redirect_uri": "http://localhost:54321/callback",
383
+ "expires_at": time.time() - 60, # Expired 1 minute ago
384
+ }
385
+
386
+ client = OAuthClientInformationFull(
387
+ client_id="test-client-id",
388
+ client_secret="secret",
389
+ redirect_uris=[AnyUrl("http://localhost:54321/callback")],
390
+ )
391
+
392
+ auth_code = await oauth_proxy.load_authorization_code(client, code)
393
+ assert auth_code is None
394
+ # Code should be cleaned up
395
+ assert code not in oauth_proxy._client_codes
396
+
397
+ @pytest.mark.asyncio
398
+ async def test_load_authorization_code_wrong_client(self, oauth_proxy):
399
+ """Test loading authorization code with wrong client ID returns None."""
400
+ code = "test-code"
401
+ oauth_proxy._client_codes[code] = {
402
+ "client_id": "correct-client-id",
403
+ "redirect_uri": "http://localhost:54321/callback",
404
+ "expires_at": time.time() + 300,
405
+ }
406
+
407
+ wrong_client = OAuthClientInformationFull(
408
+ client_id="wrong-client-id",
409
+ client_secret="secret",
410
+ redirect_uris=[AnyUrl("http://localhost:54321/callback")],
411
+ )
412
+
413
+ auth_code = await oauth_proxy.load_authorization_code(wrong_client, code)
414
+ assert auth_code is None
415
+
416
+ @pytest.mark.asyncio
417
+ async def test_load_access_token_delegates_to_verifier(
418
+ self, oauth_proxy, jwt_verifier
419
+ ):
420
+ """Test that load_access_token delegates to the token verifier."""
421
+ token = "test-access-token"
422
+ expected_result = AccessToken(
423
+ token=token,
424
+ client_id="test-client",
425
+ scopes=["read"],
426
+ expires_at=int(time.time() + 3600),
427
+ )
428
+
429
+ # Mock the async method properly
430
+ async def mock_verify(token):
431
+ return expected_result
432
+
433
+ jwt_verifier.verify_token = mock_verify
434
+
435
+ result = await oauth_proxy.load_access_token(token)
436
+
437
+ assert result == expected_result
438
+ # Can't assert on the mock function call in this case
439
+
440
+ def test_get_routes_includes_callback(self, oauth_proxy):
441
+ """Test that get_routes includes the OAuth callback route."""
442
+ routes = oauth_proxy.get_routes()
443
+
444
+ # Find the callback route
445
+ callback_routes = [
446
+ r for r in routes if hasattr(r, "path") and r.path == "/oauth/callback"
447
+ ]
448
+
449
+ assert len(callback_routes) == 1
450
+ callback_route = callback_routes[0]
451
+ assert "GET" in callback_route.methods
452
+ assert callback_route.endpoint == oauth_proxy._handle_idp_callback
453
+
454
+ def test_get_routes_preserves_standard_routes(self, oauth_proxy):
455
+ """Test that get_routes preserves standard OAuth routes."""
456
+ routes = oauth_proxy.get_routes()
457
+
458
+ # Should have standard OAuth routes
459
+ paths = [r.path for r in routes if hasattr(r, "path")]
460
+
461
+ # Standard OAuth endpoints should be present
462
+ assert "/authorize" in paths
463
+ assert "/token" in paths
464
+ assert "/.well-known/oauth-authorization-server" in paths
465
+
466
+ # Plus our custom callback
467
+ assert "/oauth/callback" in paths
468
+
469
+ @pytest.mark.asyncio
470
+ async def test_revoke_token_access_token(self, oauth_proxy):
471
+ """Test revoking an access token cleans up local storage."""
472
+ # Store tokens
473
+ access_token = "access-123"
474
+ refresh_token = "refresh-456"
475
+
476
+ oauth_proxy._access_tokens[access_token] = AccessToken(
477
+ token=access_token,
478
+ client_id="client",
479
+ scopes=[],
480
+ expires_at=int(time.time() + 3600),
481
+ )
482
+ oauth_proxy._refresh_tokens[refresh_token] = Mock(token=refresh_token)
483
+ oauth_proxy._access_to_refresh[access_token] = refresh_token
484
+ oauth_proxy._refresh_to_access[refresh_token] = access_token
485
+
486
+ # Revoke access token
487
+ await oauth_proxy.revoke_token(oauth_proxy._access_tokens[access_token])
488
+
489
+ # Verify cleanup
490
+ assert access_token not in oauth_proxy._access_tokens
491
+ assert refresh_token not in oauth_proxy._refresh_tokens
492
+ assert access_token not in oauth_proxy._access_to_refresh
493
+ assert refresh_token not in oauth_proxy._refresh_to_access
494
+
495
+ @pytest.mark.asyncio
496
+ async def test_exchange_authorization_code_stores_tokens(self, oauth_proxy):
497
+ """Test that exchange_authorization_code stores tokens locally."""
498
+ from mcp.server.auth.provider import AuthorizationCode
499
+
500
+ # Set up client code with IdP tokens
501
+ code = "client-code-123"
502
+ idp_tokens = {
503
+ "access_token": "idp-access-token",
504
+ "refresh_token": "idp-refresh-token",
505
+ "expires_in": 3600,
506
+ "token_type": "Bearer",
507
+ }
508
+
509
+ oauth_proxy._client_codes[code] = {
510
+ "client_id": "test-client",
511
+ "redirect_uri": "http://localhost:54321/callback",
512
+ "scopes": ["read", "write"],
513
+ "idp_tokens": idp_tokens,
514
+ "expires_at": time.time() + 300,
515
+ }
516
+
517
+ client = OAuthClientInformationFull(
518
+ client_id="test-client",
519
+ client_secret="secret",
520
+ redirect_uris=[AnyUrl("http://localhost:54321/callback")],
521
+ )
522
+
523
+ auth_code = AuthorizationCode(
524
+ code=code,
525
+ client_id="test-client",
526
+ redirect_uri=AnyUrl("http://localhost:54321/callback"),
527
+ redirect_uri_provided_explicitly=True,
528
+ scopes=["read", "write"],
529
+ expires_at=time.time() + 300,
530
+ code_challenge="test-challenge",
531
+ )
532
+
533
+ # Exchange the code
534
+ result = await oauth_proxy.exchange_authorization_code(client, auth_code)
535
+
536
+ # Verify result
537
+ assert result.access_token == "idp-access-token"
538
+ assert result.refresh_token == "idp-refresh-token"
539
+ assert result.expires_in == 3600
540
+
541
+ # Verify tokens were stored locally
542
+ assert "idp-access-token" in oauth_proxy._access_tokens
543
+ assert "idp-refresh-token" in oauth_proxy._refresh_tokens
544
+ assert oauth_proxy._access_to_refresh["idp-access-token"] == "idp-refresh-token"
545
+ assert oauth_proxy._refresh_to_access["idp-refresh-token"] == "idp-access-token"
546
+
547
+ # Verify code was cleaned up
548
+ assert code not in oauth_proxy._client_codes