Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
{
"mcpServers": {
"taskflow": {
"type": "http",
"url": "http://0.0.0.0:8001/mcp"
},
"context7": {
"type": "stdio",
"command": "npx",
Expand Down
7 changes: 6 additions & 1 deletion helm/taskflow/templates/configmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,13 @@ metadata:
{{- include "taskflow.componentLabels" (dict "root" . "component" "mcp") | nindent 4 }}
data:
ENV: {{ .Values.mcpServer.env.ENV | quote }}
SSO_URL: {{ .Values.mcpServer.env.SSO_URL | quote }}
# MCP uses TASKFLOW_ prefix for all env vars (see config.py env_prefix)
TASKFLOW_API_URL: {{ .Values.mcpServer.env.TASKFLOW_API_URL | quote }}
# SSO Platform URL for OAuth/JWT verification (014-mcp-oauth-standardization)
TASKFLOW_SSO_URL: {{ .Values.mcpServer.env.TASKFLOW_SSO_URL | quote }}
# Production mode - require JWT or API key auth
TASKFLOW_DEV_MODE: {{ .Values.mcpServer.env.TASKFLOW_DEV_MODE | default "false" | quote }}
# Database config (shared with API)
DATABASE_HOST: {{ .Values.mcpServer.database.host | quote }}
DATABASE_PORT: {{ .Values.mcpServer.database.port | quote }}
DATABASE_NAME: {{ .Values.mcpServer.database.name | quote }}
Expand Down
5 changes: 4 additions & 1 deletion helm/taskflow/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -198,9 +198,12 @@ mcpServer:

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

resources:
requests:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
---
id: 0001
title: MCP OAuth 2.0 Standardization Implementation
stage: green
date: 2025-12-11
surface: agent
model: Claude Opus 4.5
feature: 014-mcp-oauth-standardization
branch: main
user: mjs
command: /sp.orchestrate /sp.specify
labels: [oauth, mcp, device-flow, api-keys, authentication, agents]
links:
spec: specs/014-mcp-oauth-standardization/spec.md
ticket: null
adr: null
pr: null
files:
- sso-platform/src/lib/auth.ts
- sso-platform/src/lib/trusted-clients.ts
- sso-platform/src/app/auth/device/page.tsx
- sso-platform/src/app/auth/device/success/page.tsx
- packages/mcp-server/src/taskflow_mcp/auth.py
- packages/mcp-server/src/taskflow_mcp/config.py
- packages/mcp-server/src/taskflow_mcp/models.py
- packages/mcp-server/src/taskflow_mcp/server.py
- packages/mcp-server/src/taskflow_mcp/tools/tasks.py
- packages/mcp-server/tests/test_auth.py
- packages/mcp-server/tests/test_models.py
- packages/api/src/taskflow_api/services/chatkit_server.py
- packages/api/src/taskflow_api/services/chat_agent.py
tests:
- packages/mcp-server/tests/test_auth.py (12 tests)
- packages/mcp-server/tests/test_models.py (17 tests)
---

## Prompt

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.

Key requirements:
1. SSO Platform: Enable Device Authorization Flow plugin, register MCP clients
2. MCP Server: Add auth middleware extracting JWT from Authorization header
3. MCP Server: Add OAuth metadata and JWKS endpoints
4. Tool Signatures: Remove user_id/access_token from tool params
5. ChatKit: Update to pass Authorization header to MCP calls

## Response snapshot

Successfully implemented OAuth 2.0 Device Flow standardization for MCP authentication:

**SSO Platform Changes:**
- Enabled Device Authorization Flow plugin in Better Auth config
- Registered 4 MCP clients: Claude Code, Cursor, MCP Inspector, Windsurf
- Created Device Flow UI pages at /auth/device and /auth/device/success

**MCP Server Changes:**
- Created new auth.py module with JWT/API key validation via JWKS
- Implemented AuthMiddleware extracting tokens from Authorization header
- Added /.well-known/oauth-authorization-server metadata endpoint
- Added /.well-known/jwks.json endpoint
- Updated config.py with sso_url for JWKS discovery
- Refactored models.py: Removed AuthenticatedInput, created V2 models without auth params
- Updated all tool functions to use get_current_user() from auth middleware

**ChatKit Integration:**
- Updated MCPServerStreamableHttp initialization to pass Authorization header
- Migrated all direct mcp_server.call_tool() calls to use _call_mcp_tool() helper
- Removed redundant user_id/access_token from tool arguments
- Cleaned up system prompt - no longer instructs agent to pass auth params
- Fixed bug: priority and assignee_id now properly passed in task creation

**Tests:**
- 29 tests pass (12 auth tests + 17 model tests)
- All lint checks pass

## Outcome

- ✅ Impact: MCP server now accepts standard OAuth 2.0 tokens via Authorization header, enabling CLI agents (Claude Code, Cursor) to authenticate
- 🧪 Tests: 29 passed (test_auth.py: 12, test_models.py: 17)
- 📁 Files: 13 files modified/created
- 🔁 Next prompts: Integration testing with actual Claude Code/Cursor clients
- 🧠 Reflection: Token standardization (body → header) is a critical step for MCP ecosystem compatibility

## Evaluation notes (flywheel)

- Failure modes observed: MCPServerStreamableHttp SDK requires headers in params dict, not separate argument
- Graders run and results (PASS/FAIL): Lint PASS, pytest PASS (29/29)
- Prompt variant (if applicable): N/A
- Next experiment (smallest change to try): Test Device Flow end-to-end with MCP Inspector

Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
id: 002
title: Commit PR MCP OAuth Standardization
stage: green
date: 2025-12-11
surface: agent
model: claude-opus-4-5-20251101
feature: mcp-oauth-standardization
branch: 014-mcp-oauth-standardization
user: mjs
command: /sp.git.commit_pr
labels: ["oauth", "mcp", "rfc8414", "commit", "pr"]
links:
spec: null
ticket: null
adr: null
pr: https://github.com/mjunaidca/taskforce/pull/26
files:
- .mcp.json
- packages/mcp-server/src/taskflow_mcp/server.py
- sso-platform/src/app/.well-known/oauth-authorization-server/route.ts
tests:
- null
---

## Prompt

/sp.git.commit_pr

## Response snapshot

Executed autonomous git workflow:
1. Analyzed staged changes: 3 files (2 modified, 1 new)
2. Committed with message: "feat(oauth): standardize scopes and add RFC 8414 metadata endpoint"
3. Pushed to origin/014-mcp-oauth-standardization
4. Created PR #26: https://github.com/mjunaidca/taskforce/pull/26

Changes:
- Removed custom taskflow:read/write scopes, standardized to OIDC scopes only
- Added RFC 8414 OAuth AS metadata endpoint for MCP client discovery
- Added taskflow MCP server config to .mcp.json

## Outcome

- ✅ Impact: PR created for OAuth standardization with RFC 8414 compliance
- 🧪 Tests: Not run (commit-only workflow)
- 📁 Files: 3 files changed
- 🔁 Next prompts: Merge PR, test MCP client discovery flow
- 🧠 Reflection: Clean autonomous git workflow execution

## Evaluation notes (flywheel)

- Failure modes observed: None
- Graders run and results (PASS/FAIL): N/A
- Prompt variant (if applicable): N/A
- Next experiment (smallest change to try): N/A
118 changes: 107 additions & 11 deletions packages/api/src/taskflow_api/auth.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
"""JWT/JWKS authentication against Better Auth SSO.

Flow:
1. Frontend gets JWT via OAuth2 PKCE from SSO
2. Frontend sends: Authorization: Bearer <JWT>
Supports two token types:
1. JWT (id_token) - Verified locally using JWKS public keys
2. Opaque (access_token) - Verified via SSO userinfo endpoint

Flow for JWT:
1. Frontend/MCP gets JWT via OAuth2 PKCE from SSO
2. Sends: Authorization: Bearer <JWT>
3. Backend fetches JWKS public keys from SSO (cached 1 hour)
4. Backend verifies JWT signature locally (no SSO call per request)

Flow for Opaque Token (e.g., Gemini CLI bug sends access_token):
1. MCP client gets access_token via OAuth2 from SSO
2. Sends: Authorization: Bearer <opaque_token>
3. Backend validates via SSO userinfo endpoint
"""

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


async def verify_opaque_token(token: str) -> dict[str, Any]:
"""Verify opaque access token via SSO userinfo endpoint.

When OAuth clients (like Gemini CLI) send opaque access_tokens instead of JWTs,
we validate them by calling the SSO's userinfo endpoint.

Args:
token: Opaque access token from OAuth flow

Returns:
User claims from userinfo response

Raises:
HTTPException: If token is invalid or expired
"""
userinfo_url = f"{settings.sso_url}/api/auth/oauth2/userinfo"
token_preview = f"{token[:10]}...{token[-10:]}" if len(token) > 25 else "[short]"
logger.info("[AUTH] Validating opaque token via userinfo: %s", token_preview)

try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.get(
userinfo_url,
headers={"Authorization": f"Bearer {token}"},
)

if response.status_code == 401:
logger.warning("[AUTH] Userinfo returned 401 - token invalid or expired")
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token invalid or expired",
headers={"WWW-Authenticate": "Bearer"},
)

if response.status_code != 200:
logger.error("[AUTH] Userinfo returned %d", response.status_code)
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Userinfo request failed: {response.status_code}",
headers={"WWW-Authenticate": "Bearer"},
)

data = response.json()
logger.info(
"[AUTH] Opaque token verified - sub: %s, email: %s, client: %s",
data.get("sub"),
data.get("email"),
data.get("client_name"),
)
return data

except httpx.RequestError as e:
logger.error("[AUTH] Userinfo request failed: %s", e)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Authentication service unavailable: {e}",
) from e


class CurrentUser:
"""Authenticated user extracted from JWT claims.

Expand All @@ -142,6 +210,8 @@ class CurrentUser:
- role: "user" | "admin"
- tenant_id: Primary organization (optional)
- organization_id: Alternative tenant claim (optional)
- client_id: OAuth client ID (for audit: which tool was used)
- client_name: OAuth client name (e.g., "Claude Code")
"""

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

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


async def get_current_user(
credentials: HTTPAuthorizationCredentials = Depends(security),
) -> CurrentUser:
"""FastAPI dependency to get authenticated user from JWT.
"""FastAPI dependency to get authenticated user from token.

Supports both JWT (id_token) and opaque (access_token) tokens.
Tries JWT first, falls back to opaque token validation via userinfo.

Usage in routes:
@router.get("/api/projects")
Expand All @@ -170,7 +247,7 @@ async def list_projects(user: CurrentUser = Depends(get_current_user)):
"""
# Dev mode bypass for local development
if settings.dev_mode:
logger.debug("[AUTH] Dev mode enabled, bypassing JWT verification")
logger.debug("[AUTH] Dev mode enabled, bypassing token verification")
return CurrentUser(
{
"sub": settings.dev_user_id,
Expand All @@ -180,12 +257,31 @@ async def list_projects(user: CurrentUser = Depends(get_current_user)):
}
)

logger.debug("[AUTH] Production mode, verifying JWT...")

# Production: Verify JWT using JWKS
payload = await verify_jwt(credentials.credentials)
token = credentials.credentials
token_parts = token.count(".")

# Detect token type: JWT has 3 dot-separated segments
logger.debug(
"[AUTH] Token validation - segments: %d, type: %s",
token_parts + 1,
"JWT" if token_parts == 2 else "opaque",
)

# Try JWT first if it looks like a JWT
if token_parts == 2:
try:
payload = await verify_jwt(token)
user = CurrentUser(payload)
logger.info("[AUTH] Authenticated via JWT: %s", user)
return user
except HTTPException:
# JWT validation failed, try opaque as fallback
logger.debug("[AUTH] JWT validation failed, trying opaque token...")

# Opaque token validation via userinfo endpoint
payload = await verify_opaque_token(token)
user = CurrentUser(payload)
logger.info("[AUTH] Authenticated user: %s", user)
logger.info("[AUTH] Authenticated via opaque token: %s", user)
return user


Expand Down
Loading
Loading