Skip to content

Commit 67cef91

Browse files
authored
Merge pull request #26 from mjunaidca/014-mcp-oauth-standardization
feat(oauth): MCP OAuth Standardization with RFC 8414 Metadata
2 parents b07d4ed + 3820c54 commit 67cef91

37 files changed

Lines changed: 2807 additions & 286 deletions

File tree

.mcp.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
{
22
"mcpServers": {
3+
"taskflow": {
4+
"type": "http",
5+
"url": "http://0.0.0.0:8001/mcp"
6+
},
37
"context7": {
48
"type": "stdio",
59
"command": "npx",

helm/taskflow/templates/configmap.yaml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,8 +58,13 @@ metadata:
5858
{{- include "taskflow.componentLabels" (dict "root" . "component" "mcp") | nindent 4 }}
5959
data:
6060
ENV: {{ .Values.mcpServer.env.ENV | quote }}
61-
SSO_URL: {{ .Values.mcpServer.env.SSO_URL | quote }}
61+
# MCP uses TASKFLOW_ prefix for all env vars (see config.py env_prefix)
6262
TASKFLOW_API_URL: {{ .Values.mcpServer.env.TASKFLOW_API_URL | quote }}
63+
# SSO Platform URL for OAuth/JWT verification (014-mcp-oauth-standardization)
64+
TASKFLOW_SSO_URL: {{ .Values.mcpServer.env.TASKFLOW_SSO_URL | quote }}
65+
# Production mode - require JWT or API key auth
66+
TASKFLOW_DEV_MODE: {{ .Values.mcpServer.env.TASKFLOW_DEV_MODE | default "false" | quote }}
67+
# Database config (shared with API)
6368
DATABASE_HOST: {{ .Values.mcpServer.database.host | quote }}
6469
DATABASE_PORT: {{ .Values.mcpServer.database.port | quote }}
6570
DATABASE_NAME: {{ .Values.mcpServer.database.name | quote }}

helm/taskflow/values.yaml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,9 +198,12 @@ mcpServer:
198198

199199
env:
200200
ENV: production
201-
SSO_URL: http://sso-platform:3001
202201
# MCP uses TASKFLOW_ prefix for env vars (see config.py env_prefix)
203202
TASKFLOW_API_URL: http://taskflow-api:8000
203+
# SSO Platform URL for OAuth/JWT verification (014-mcp-oauth-standardization)
204+
TASKFLOW_SSO_URL: http://sso-platform:3001
205+
# Production mode - require JWT or API key auth
206+
TASKFLOW_DEV_MODE: "false"
204207

205208
resources:
206209
requests:
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
---
2+
id: 0001
3+
title: MCP OAuth 2.0 Standardization Implementation
4+
stage: green
5+
date: 2025-12-11
6+
surface: agent
7+
model: Claude Opus 4.5
8+
feature: 014-mcp-oauth-standardization
9+
branch: main
10+
user: mjs
11+
command: /sp.orchestrate /sp.specify
12+
labels: [oauth, mcp, device-flow, api-keys, authentication, agents]
13+
links:
14+
spec: specs/014-mcp-oauth-standardization/spec.md
15+
ticket: null
16+
adr: null
17+
pr: null
18+
files:
19+
- sso-platform/src/lib/auth.ts
20+
- sso-platform/src/lib/trusted-clients.ts
21+
- sso-platform/src/app/auth/device/page.tsx
22+
- sso-platform/src/app/auth/device/success/page.tsx
23+
- packages/mcp-server/src/taskflow_mcp/auth.py
24+
- packages/mcp-server/src/taskflow_mcp/config.py
25+
- packages/mcp-server/src/taskflow_mcp/models.py
26+
- packages/mcp-server/src/taskflow_mcp/server.py
27+
- packages/mcp-server/src/taskflow_mcp/tools/tasks.py
28+
- packages/mcp-server/tests/test_auth.py
29+
- packages/mcp-server/tests/test_models.py
30+
- packages/api/src/taskflow_api/services/chatkit_server.py
31+
- packages/api/src/taskflow_api/services/chat_agent.py
32+
tests:
33+
- packages/mcp-server/tests/test_auth.py (12 tests)
34+
- packages/mcp-server/tests/test_models.py (17 tests)
35+
---
36+
37+
## Prompt
38+
39+
Implement OAuth 2.0 Device Flow for MCP server to enable Claude Code, Cursor, and any MCP client to authenticate using standard HTTP Authorization headers instead of embedding access_token in tool parameters.
40+
41+
Key requirements:
42+
1. SSO Platform: Enable Device Authorization Flow plugin, register MCP clients
43+
2. MCP Server: Add auth middleware extracting JWT from Authorization header
44+
3. MCP Server: Add OAuth metadata and JWKS endpoints
45+
4. Tool Signatures: Remove user_id/access_token from tool params
46+
5. ChatKit: Update to pass Authorization header to MCP calls
47+
48+
## Response snapshot
49+
50+
Successfully implemented OAuth 2.0 Device Flow standardization for MCP authentication:
51+
52+
**SSO Platform Changes:**
53+
- Enabled Device Authorization Flow plugin in Better Auth config
54+
- Registered 4 MCP clients: Claude Code, Cursor, MCP Inspector, Windsurf
55+
- Created Device Flow UI pages at /auth/device and /auth/device/success
56+
57+
**MCP Server Changes:**
58+
- Created new auth.py module with JWT/API key validation via JWKS
59+
- Implemented AuthMiddleware extracting tokens from Authorization header
60+
- Added /.well-known/oauth-authorization-server metadata endpoint
61+
- Added /.well-known/jwks.json endpoint
62+
- Updated config.py with sso_url for JWKS discovery
63+
- Refactored models.py: Removed AuthenticatedInput, created V2 models without auth params
64+
- Updated all tool functions to use get_current_user() from auth middleware
65+
66+
**ChatKit Integration:**
67+
- Updated MCPServerStreamableHttp initialization to pass Authorization header
68+
- Migrated all direct mcp_server.call_tool() calls to use _call_mcp_tool() helper
69+
- Removed redundant user_id/access_token from tool arguments
70+
- Cleaned up system prompt - no longer instructs agent to pass auth params
71+
- Fixed bug: priority and assignee_id now properly passed in task creation
72+
73+
**Tests:**
74+
- 29 tests pass (12 auth tests + 17 model tests)
75+
- All lint checks pass
76+
77+
## Outcome
78+
79+
- ✅ Impact: MCP server now accepts standard OAuth 2.0 tokens via Authorization header, enabling CLI agents (Claude Code, Cursor) to authenticate
80+
- 🧪 Tests: 29 passed (test_auth.py: 12, test_models.py: 17)
81+
- 📁 Files: 13 files modified/created
82+
- 🔁 Next prompts: Integration testing with actual Claude Code/Cursor clients
83+
- 🧠 Reflection: Token standardization (body → header) is a critical step for MCP ecosystem compatibility
84+
85+
## Evaluation notes (flywheel)
86+
87+
- Failure modes observed: MCPServerStreamableHttp SDK requires headers in params dict, not separate argument
88+
- Graders run and results (PASS/FAIL): Lint PASS, pytest PASS (29/29)
89+
- Prompt variant (if applicable): N/A
90+
- Next experiment (smallest change to try): Test Device Flow end-to-end with MCP Inspector
91+
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
id: 002
3+
title: Commit PR MCP OAuth Standardization
4+
stage: green
5+
date: 2025-12-11
6+
surface: agent
7+
model: claude-opus-4-5-20251101
8+
feature: mcp-oauth-standardization
9+
branch: 014-mcp-oauth-standardization
10+
user: mjs
11+
command: /sp.git.commit_pr
12+
labels: ["oauth", "mcp", "rfc8414", "commit", "pr"]
13+
links:
14+
spec: null
15+
ticket: null
16+
adr: null
17+
pr: https://github.com/mjunaidca/taskforce/pull/26
18+
files:
19+
- .mcp.json
20+
- packages/mcp-server/src/taskflow_mcp/server.py
21+
- sso-platform/src/app/.well-known/oauth-authorization-server/route.ts
22+
tests:
23+
- null
24+
---
25+
26+
## Prompt
27+
28+
/sp.git.commit_pr
29+
30+
## Response snapshot
31+
32+
Executed autonomous git workflow:
33+
1. Analyzed staged changes: 3 files (2 modified, 1 new)
34+
2. Committed with message: "feat(oauth): standardize scopes and add RFC 8414 metadata endpoint"
35+
3. Pushed to origin/014-mcp-oauth-standardization
36+
4. Created PR #26: https://github.com/mjunaidca/taskforce/pull/26
37+
38+
Changes:
39+
- Removed custom taskflow:read/write scopes, standardized to OIDC scopes only
40+
- Added RFC 8414 OAuth AS metadata endpoint for MCP client discovery
41+
- Added taskflow MCP server config to .mcp.json
42+
43+
## Outcome
44+
45+
- ✅ Impact: PR created for OAuth standardization with RFC 8414 compliance
46+
- 🧪 Tests: Not run (commit-only workflow)
47+
- 📁 Files: 3 files changed
48+
- 🔁 Next prompts: Merge PR, test MCP client discovery flow
49+
- 🧠 Reflection: Clean autonomous git workflow execution
50+
51+
## Evaluation notes (flywheel)
52+
53+
- Failure modes observed: None
54+
- Graders run and results (PASS/FAIL): N/A
55+
- Prompt variant (if applicable): N/A
56+
- Next experiment (smallest change to try): N/A

packages/api/src/taskflow_api/auth.py

Lines changed: 107 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
"""JWT/JWKS authentication against Better Auth SSO.
22
3-
Flow:
4-
1. Frontend gets JWT via OAuth2 PKCE from SSO
5-
2. Frontend sends: Authorization: Bearer <JWT>
3+
Supports two token types:
4+
1. JWT (id_token) - Verified locally using JWKS public keys
5+
2. Opaque (access_token) - Verified via SSO userinfo endpoint
6+
7+
Flow for JWT:
8+
1. Frontend/MCP gets JWT via OAuth2 PKCE from SSO
9+
2. Sends: Authorization: Bearer <JWT>
610
3. Backend fetches JWKS public keys from SSO (cached 1 hour)
711
4. Backend verifies JWT signature locally (no SSO call per request)
12+
13+
Flow for Opaque Token (e.g., Gemini CLI bug sends access_token):
14+
1. MCP client gets access_token via OAuth2 from SSO
15+
2. Sends: Authorization: Bearer <opaque_token>
16+
3. Backend validates via SSO userinfo endpoint
817
"""
918

1019
import logging
@@ -132,6 +141,65 @@ async def verify_jwt(token: str) -> dict[str, Any]:
132141
) from e
133142

134143

144+
async def verify_opaque_token(token: str) -> dict[str, Any]:
145+
"""Verify opaque access token via SSO userinfo endpoint.
146+
147+
When OAuth clients (like Gemini CLI) send opaque access_tokens instead of JWTs,
148+
we validate them by calling the SSO's userinfo endpoint.
149+
150+
Args:
151+
token: Opaque access token from OAuth flow
152+
153+
Returns:
154+
User claims from userinfo response
155+
156+
Raises:
157+
HTTPException: If token is invalid or expired
158+
"""
159+
userinfo_url = f"{settings.sso_url}/api/auth/oauth2/userinfo"
160+
token_preview = f"{token[:10]}...{token[-10:]}" if len(token) > 25 else "[short]"
161+
logger.info("[AUTH] Validating opaque token via userinfo: %s", token_preview)
162+
163+
try:
164+
async with httpx.AsyncClient(timeout=10.0) as client:
165+
response = await client.get(
166+
userinfo_url,
167+
headers={"Authorization": f"Bearer {token}"},
168+
)
169+
170+
if response.status_code == 401:
171+
logger.warning("[AUTH] Userinfo returned 401 - token invalid or expired")
172+
raise HTTPException(
173+
status_code=status.HTTP_401_UNAUTHORIZED,
174+
detail="Token invalid or expired",
175+
headers={"WWW-Authenticate": "Bearer"},
176+
)
177+
178+
if response.status_code != 200:
179+
logger.error("[AUTH] Userinfo returned %d", response.status_code)
180+
raise HTTPException(
181+
status_code=status.HTTP_401_UNAUTHORIZED,
182+
detail=f"Userinfo request failed: {response.status_code}",
183+
headers={"WWW-Authenticate": "Bearer"},
184+
)
185+
186+
data = response.json()
187+
logger.info(
188+
"[AUTH] Opaque token verified - sub: %s, email: %s, client: %s",
189+
data.get("sub"),
190+
data.get("email"),
191+
data.get("client_name"),
192+
)
193+
return data
194+
195+
except httpx.RequestError as e:
196+
logger.error("[AUTH] Userinfo request failed: %s", e)
197+
raise HTTPException(
198+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
199+
detail=f"Authentication service unavailable: {e}",
200+
) from e
201+
202+
135203
class CurrentUser:
136204
"""Authenticated user extracted from JWT claims.
137205
@@ -142,6 +210,8 @@ class CurrentUser:
142210
- role: "user" | "admin"
143211
- tenant_id: Primary organization (optional)
144212
- organization_id: Alternative tenant claim (optional)
213+
- client_id: OAuth client ID (for audit: which tool was used)
214+
- client_name: OAuth client name (e.g., "Claude Code")
145215
"""
146216

147217
def __init__(self, payload: dict[str, Any]) -> None:
@@ -153,15 +223,22 @@ def __init__(self, payload: dict[str, Any]) -> None:
153223
self.tenant_id: str | None = (
154224
payload.get("tenant_id") or payload.get("organization_id") or None
155225
)
226+
# OAuth client identity for audit trail (e.g., "@user via Claude Code")
227+
self.client_id: str | None = payload.get("client_id")
228+
self.client_name: str | None = payload.get("client_name")
156229

157230
def __repr__(self) -> str:
158-
return f"CurrentUser(id={self.id!r}, email={self.email!r})"
231+
client_info = f", client={self.client_name!r}" if self.client_name else ""
232+
return f"CurrentUser(id={self.id!r}, email={self.email!r}{client_info})"
159233

160234

161235
async def get_current_user(
162236
credentials: HTTPAuthorizationCredentials = Depends(security),
163237
) -> CurrentUser:
164-
"""FastAPI dependency to get authenticated user from JWT.
238+
"""FastAPI dependency to get authenticated user from token.
239+
240+
Supports both JWT (id_token) and opaque (access_token) tokens.
241+
Tries JWT first, falls back to opaque token validation via userinfo.
165242
166243
Usage in routes:
167244
@router.get("/api/projects")
@@ -170,7 +247,7 @@ async def list_projects(user: CurrentUser = Depends(get_current_user)):
170247
"""
171248
# Dev mode bypass for local development
172249
if settings.dev_mode:
173-
logger.debug("[AUTH] Dev mode enabled, bypassing JWT verification")
250+
logger.debug("[AUTH] Dev mode enabled, bypassing token verification")
174251
return CurrentUser(
175252
{
176253
"sub": settings.dev_user_id,
@@ -180,12 +257,31 @@ async def list_projects(user: CurrentUser = Depends(get_current_user)):
180257
}
181258
)
182259

183-
logger.debug("[AUTH] Production mode, verifying JWT...")
184-
185-
# Production: Verify JWT using JWKS
186-
payload = await verify_jwt(credentials.credentials)
260+
token = credentials.credentials
261+
token_parts = token.count(".")
262+
263+
# Detect token type: JWT has 3 dot-separated segments
264+
logger.debug(
265+
"[AUTH] Token validation - segments: %d, type: %s",
266+
token_parts + 1,
267+
"JWT" if token_parts == 2 else "opaque",
268+
)
269+
270+
# Try JWT first if it looks like a JWT
271+
if token_parts == 2:
272+
try:
273+
payload = await verify_jwt(token)
274+
user = CurrentUser(payload)
275+
logger.info("[AUTH] Authenticated via JWT: %s", user)
276+
return user
277+
except HTTPException:
278+
# JWT validation failed, try opaque as fallback
279+
logger.debug("[AUTH] JWT validation failed, trying opaque token...")
280+
281+
# Opaque token validation via userinfo endpoint
282+
payload = await verify_opaque_token(token)
187283
user = CurrentUser(payload)
188-
logger.info("[AUTH] Authenticated user: %s", user)
284+
logger.info("[AUTH] Authenticated via opaque token: %s", user)
189285
return user
190286

191287

0 commit comments

Comments
 (0)