feat(auth): implement OAuth AS proxy to fix audience mismatch (ADR-023)

MCP clients like Claude Code were unable to use tools because tokens
obtained directly from Nextcloud had the wrong audience claim. The MCP
server now acts as its own OAuth Authorization Server, proxying auth
to Nextcloud with its own client_id so tokens have the correct audience.

New endpoints: /.well-known/oauth-authorization-server, /oauth/token,
/oauth/register. Modified /oauth/authorize from pass-through to
intermediary pattern. PRM now points authorization_servers to the MCP
server instead of Nextcloud.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-02 11:25:10 +01:00
parent d09ebf20cc
commit 9d1a84af5a
5 changed files with 849 additions and 106 deletions
+169
View File
@@ -0,0 +1,169 @@
# ADR-023: OAuth Authorization Server Proxy
## Status
Accepted
## Date
2026-03-02
## Context
When the MCP server operates in OAuth mode (e.g., `mcp-login-flow` profile), MCP clients like Claude Code need to authenticate before calling any tools. The server advertises itself as an OAuth Protected Resource via RFC 9728 (Protected Resource Metadata / PRM), which tells clients where to find the Authorization Server.
### The Problem
The original design used a **pass-through** pattern for Flow 1 (client authentication):
1. PRM at `/.well-known/oauth-protected-resource` pointed `authorization_servers` to Nextcloud's public URL
2. Claude Code performed OIDC discovery on Nextcloud, used DCR to register its own client, and obtained tokens directly from Nextcloud
3. Tokens issued by Nextcloud had Claude Code's `client_id` as the `aud` (audience) claim
This caused an audience mismatch:
```
Token rejected: Missing MCP audience.
Got klehQp8uHCK9fu... (Claude Code's client_id),
need 8ilzB5ZPWr2Qt4... (MCP server's client_id) or http://localhost:8004
```
The `_has_mcp_audience()` check in `unified_verifier.py` correctly requires tokens to contain either the MCP server's `client_id` or its URL as the audience — but tokens obtained directly from Nextcloud by a third-party client will never have that audience.
This meant Claude Code could never authenticate → could never call `nc_auth_provision_access` → Login Flow v2 never triggered → the server was unusable.
### Why Not Just Relax Audience Validation?
Audience validation exists for security (RFC 7519 §4.1.3). Removing it would allow any valid Nextcloud token to access the MCP server, including tokens issued for completely different purposes.
## Decision
Make the MCP server act as its own **OAuth Authorization Server proxy** (intermediary pattern). The MCP server advertises itself as the AS, handles client registration and authorization, but proxies the actual authentication to Nextcloud using its own credentials. This ensures all tokens have the correct audience.
### Flow Overview
```
Client MCP Server (AS Proxy) Nextcloud (IdP)
| | |
|-- POST /oauth/register ----->| ---- proxy DCR --------------->|
|<---- client_id, etc. --------|<---- client_id, etc. ----------|
| | |
|-- GET /oauth/authorize ----->| (store client params) |
| (client_id, redirect, | redirect with MCP's client_id |
| code_challenge, state) |------- GET /authorize -------->|
| | (MCP client_id, MCP callback) |
| | |
| | [user authenticates] |
| | |
| |<------ code + state -----------|
| | (exchange code server-side) |
| |------- POST /token ----------->|
| | (code, MCP client_id+secret) |
| |<------ NC token (aud=MCP) -----|
| | |
| | (generate proxy_code, store |
| | mapping to NC token) |
|<-- redirect to client -------| |
| (proxy_code, state) | |
| | |
|-- POST /oauth/token -------->| (verify PKCE, lookup code) |
| (proxy_code, code_verifier) | return stored NC token |
|<---- access_token -----------| |
| | |
|-- POST /mcp (Bearer token) ->| verify_access_token() |
| (NC token with aud=MCP ✓) | _has_mcp_audience() → PASS |
```
### Key Design Decisions
#### 1. PKCE Handling — Local Verification
The MCP server receives the client's `code_challenge` but does **not** forward it to Nextcloud. Instead:
- **Nextcloud side**: MCP server authenticates as a confidential client (`client_id` + `client_secret`), so PKCE is not required
- **Client side**: MCP server verifies PKCE locally when the client exchanges the proxy code at `/oauth/token`
This avoids the impossible situation where the server would need the `code_verifier` to exchange code with Nextcloud but doesn't have it (only the client does).
#### 2. In-Memory Proxy Code Storage
Proxy codes (the authorization codes issued by the AS proxy to clients) use in-memory storage rather than SQLite because:
- They have a 60-second TTL
- They are single-use (deleted on exchange)
- They only exist during the brief OAuth flow
- The MCP server is single-instance
#### 3. PRM Points to MCP Server
The `authorization_servers` field in the PRM response now points to the MCP server URL instead of Nextcloud's public URL. This is what triggers the entire proxy flow — clients discover the MCP server as their AS.
#### 4. DCR Proxy
Client registration requests at `/oauth/register` are proxied to Nextcloud's DCR endpoint. The resulting `client_id` is stored in the local `ClientRegistry` so that `/oauth/authorize` can validate it. The client receives the same DCR response it would get from Nextcloud directly.
## Alternatives Considered
### 1. Relax Audience Validation
Remove `_has_mcp_audience()` check entirely. **Rejected**: Violates RFC 7519 security model.
### 2. Client Pre-Registration
Require clients to register directly with Nextcloud and configure the MCP server with their `client_id`. **Rejected**: Poor UX, doesn't work with DCR-based clients like Claude Code.
### 3. Token Exchange (RFC 8693)
The MCP server could accept any Nextcloud token and exchange it for one with the correct audience. **Rejected**: Nextcloud's OIDC app doesn't support RFC 8693 token exchange. This was already explored in ADR-005.
### 4. Custom Audience Configuration
Add configuration to accept specific external `client_id` values as valid audiences. **Rejected**: Requires manual configuration per client, doesn't scale with DCR.
## New Endpoints
| Endpoint | Method | Purpose |
|----------|--------|---------|
| `/.well-known/oauth-authorization-server` | GET | RFC 8414 AS metadata |
| `/oauth/authorize` | GET | Authorization (modified: intermediary, not pass-through) |
| `/oauth/token` | POST | Token exchange (proxy codes + refresh token proxy) |
| `/oauth/register` | POST | DCR proxy to Nextcloud |
## Files Modified
| File | Changes |
|------|---------|
| `nextcloud_mcp_server/auth/oauth_routes.py` | New: `oauth_as_metadata`, `oauth_register_proxy`, `oauth_token_endpoint`, `_oauth_callback_as_proxy`. Modified: `oauth_authorize` (intermediary pattern), `oauth_callback` (AS proxy routing) |
| `nextcloud_mcp_server/app.py` | New routes, PRM `authorization_servers` → MCP server URL, `app.state.supported_scopes` |
| `nextcloud_mcp_server/auth/client_registry.py` | New: `register_proxy_client()`, wildcard scope support |
## Consequences
### Positive
- Tokens always have the correct audience — `_has_mcp_audience()` passes
- Works with any MCP client that implements RFC 9728 (PRM) discovery
- No changes needed to Nextcloud's OIDC configuration
- DCR still works transparently (clients register via proxy)
- Existing Flow 2 (resource provisioning) and browser login are unaffected
### Negative
- MCP server is now stateful during the OAuth flow (in-memory proxy codes)
- Extra network hop for token exchange (MCP server → Nextcloud → back)
- Token refresh requires proxying through the MCP server
- Single-instance limitation for proxy code storage (acceptable for current deployment model)
### Risks
- In-memory proxy codes are lost on server restart (mitigated by 60s TTL — user just retries)
- Discovery endpoint fetch during OAuth flow adds latency (could be cached)
## References
- [RFC 8414 — OAuth 2.0 Authorization Server Metadata](https://tools.ietf.org/html/rfc8414)
- [RFC 9728 — OAuth 2.0 Protected Resource Metadata](https://tools.ietf.org/html/rfc9728)
- [RFC 7636 — PKCE](https://tools.ietf.org/html/rfc7636)
- [RFC 7591 — Dynamic Client Registration](https://tools.ietf.org/html/rfc7591)
- ADR-004 — MCP Application OAuth (progressive consent architecture)
- ADR-005 — Token Audience Validation
+29 -9
View File
@@ -66,10 +66,13 @@ from nextcloud_mcp_server.auth.browser_oauth_routes import (
from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client
from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient
from nextcloud_mcp_server.auth.oauth_routes import (
oauth_as_metadata,
oauth_authorize,
oauth_authorize_nextcloud,
oauth_callback,
oauth_callback_nextcloud,
oauth_register_proxy,
oauth_token_endpoint,
)
from nextcloud_mcp_server.auth.session_backend import SessionAuthBackend
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage, get_shared_storage
@@ -2317,14 +2320,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
The 'resource' field is set to the MCP server's public URL (RFC 9728 requires a URL).
This is used as the audience in access tokens via the resource parameter (RFC 8707).
The introspection controller matches this URL to the MCP server's client via resource_url field.
"""
# Use PUBLIC_ISSUER_URL for authorization server since external clients
# (like Claude) need the publicly accessible URL, not internal Docker URLs
public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if not public_issuer_url:
# Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set
public_issuer_url = os.getenv("NEXTCLOUD_HOST", "")
ADR-023: authorization_servers points to the MCP server itself (AS proxy)
so that clients authenticate through the proxy and tokens have correct audience.
"""
# RFC 9728 requires resource to be a URL (not a client ID)
# Use the MCP server's public URL
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL")
@@ -2336,11 +2335,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# This provides a single source of truth based on @require_scopes decorators
supported_scopes = discover_all_scopes(mcp)
# ADR-023: Point authorization_servers to the MCP server itself.
# The MCP server acts as an OAuth AS proxy, forwarding to Nextcloud
# with its own client_id so tokens have the correct audience.
return JSONResponse(
{
"resource": f"{mcp_server_url}/mcp", # RFC 9728: must be a URL
"scopes_supported": supported_scopes,
"authorization_servers": [public_issuer_url],
"authorization_servers": [mcp_server_url],
"bearer_methods_supported": ["header"],
"resource_signing_alg_values_supported": ["RS256"],
}
@@ -2397,7 +2399,21 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
if oauth_enabled:
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
# ADR-023: AS proxy endpoints — MCP server acts as its own OAuth AS
routes.append(Route("/oauth/token", oauth_token_endpoint, methods=["POST"]))
routes.append(Route("/oauth/register", oauth_register_proxy, methods=["POST"]))
routes.append(
Route(
"/.well-known/oauth-authorization-server",
oauth_as_metadata,
methods=["GET"],
)
)
logger.info(
"OAuth AS proxy routes enabled: /oauth/authorize, /oauth/token, "
"/oauth/register, /.well-known/oauth-authorization-server (ADR-023)"
)
# Add browser OAuth login routes for Management API access
# Available in OAuth modes AND multi-user BasicAuth with offline access
@@ -2506,6 +2522,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"Routes: /user/* with SessionAuth, /mcp with FastMCP OAuth Bearer tokens"
)
# Store supported scopes on app.state for AS metadata endpoint (ADR-023)
if oauth_enabled:
app.state.supported_scopes = discover_all_scopes(mcp)
# Add debugging middleware to log Authorization headers and client capabilities
@app.middleware("http")
async def log_auth_headers(request, call_next):
+25 -2
View File
@@ -142,8 +142,8 @@ class ClientRegistry:
if not self._validate_redirect_uri(client, redirect_uri):
return False, f"Invalid redirect_uri for client {client_id}"
# Validate scopes if provided
if scopes:
# Validate scopes if provided (wildcard "*" allows all scopes)
if scopes and "*" not in client.allowed_scopes:
invalid_scopes = set(scopes) - set(client.allowed_scopes)
if invalid_scopes:
return False, f"Invalid scopes for client {client_id}: {invalid_scopes}"
@@ -202,6 +202,29 @@ class ClientRegistry:
# In production, would persist to database
return True
def register_proxy_client(
self, client_id: str, redirect_uris: list[str], name: str = ""
) -> None:
"""Register a client discovered via DCR proxy.
When the MCP server acts as an OAuth AS proxy, clients register via
the proxy's /oauth/register endpoint. This method stores the client
locally so /oauth/authorize can validate it.
Args:
client_id: Client identifier from Nextcloud DCR response
redirect_uris: Allowed redirect URIs
name: Optional human-readable name
"""
self._clients[client_id] = MCPClientInfo(
client_id=client_id,
name=name or f"DCR-{client_id[:8]}",
redirect_uris=redirect_uris or ["http://localhost:*", "http://127.0.0.1:*"],
allowed_scopes=["*"], # Nextcloud enforces actual scopes
is_public=True,
)
logger.info(f"Registered proxy client: {client_id}")
def get_client(self, client_id: str) -> Optional[MCPClientInfo]:
"""
Get client information.
+625 -94
View File
@@ -1,13 +1,13 @@
"""
OAuth 2.0 Login Routes for ADR-004 (Offline Access Architecture)
OAuth 2.0 Login Routes for ADR-004 (Offline Access Architecture) and ADR-023 (AS Proxy)
Implements dual OAuth flows with optional offline access provisioning:
Flow 1: Client Authentication - MCP client authenticates directly to IdP
- Client requests: Nextcloud MCP resource scopes (notes:*, calendar:*, etc.)
- Token audience (aud): "mcp-server"
- No server interception - IdP redirects directly to client
- Client receives resource-scoped token for MCP session
Flow 1: Client Authentication (AS Proxy mode, ADR-023)
- MCP server acts as its own OAuth Authorization Server
- Proxies DCR, authorization, and token endpoints to Nextcloud
- Uses MCP server's own client_id so tokens have correct audience
- Client exchanges proxy authorization code for Nextcloud token
Flow 2: Resource Provisioning - MCP server gets delegated Nextcloud access
- Triggered by user calling provision_nextcloud_access tool
@@ -25,6 +25,7 @@ import os
import secrets
import time
from base64 import urlsafe_b64encode
from dataclasses import dataclass, field
from urllib.parse import urlencode
from urllib.parse import urlparse as parse_url
@@ -41,13 +42,88 @@ from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# In-memory proxy code store for AS proxy flow (ADR-023)
# Proxy codes are ephemeral (60s TTL), single-instance, so in-memory is fine.
# ---------------------------------------------------------------------------
@dataclass
class ProxyCodeEntry:
"""Stores state for a proxy authorization code issued by the AS proxy."""
client_id: str
client_redirect_uri: str
client_state: str
code_challenge: str
code_challenge_method: str
nc_token_response: dict # Full JSON token response from Nextcloud
created_at: float = field(default_factory=time.time)
expires_at: float = 0.0
def __post_init__(self):
if self.expires_at == 0.0:
self.expires_at = self.created_at + 60 # 60 second TTL
@property
def is_expired(self) -> bool:
return time.time() > self.expires_at
# Server-side state for AS proxy authorize → callback mapping
@dataclass
class ASProxySession:
"""Stores state between /oauth/authorize and the Nextcloud callback."""
client_id: str
client_redirect_uri: str
client_state: str
code_challenge: str
code_challenge_method: str
requested_scopes: str
created_at: float = field(default_factory=time.time)
expires_at: float = 0.0
def __post_init__(self):
if self.expires_at == 0.0:
self.expires_at = self.created_at + 600 # 10 minute TTL
@property
def is_expired(self) -> bool:
return time.time() > self.expires_at
# In-memory stores (single-instance, ephemeral)
_proxy_codes: dict[str, ProxyCodeEntry] = {}
_as_proxy_sessions: dict[str, ASProxySession] = {}
def _cleanup_expired_proxy_codes() -> None:
"""Remove expired proxy codes and sessions."""
now = time.time()
expired_codes = [k for k, v in _proxy_codes.items() if now > v.expires_at]
for k in expired_codes:
del _proxy_codes[k]
expired_sessions = [k for k, v in _as_proxy_sessions.items() if now > v.expires_at]
for k in expired_sessions:
del _as_proxy_sessions[k]
async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
"""
OAuth authorization endpoint for Flow 1: Client Authentication.
OAuth authorization endpoint — AS Proxy intermediary (ADR-023).
The client authenticates directly to the IdP with its own client_id.
The server validates the client is authorized but does NOT intercept the callback.
IdP redirects directly back to the client's redirect_uri.
The MCP server acts as its own OAuth Authorization Server, proxying
the authorization to Nextcloud. This ensures tokens have the correct
audience (MCP server's client_id) instead of the MCP client's client_id.
Flow:
1. Client sends authorize request with its own client_id + PKCE
2. Server stores client params, generates server-side state
3. Server redirects to Nextcloud with MCP server's own client_id
4. Nextcloud callback returns to /oauth/callback (flow_type=as_proxy)
5. Server exchanges code, generates proxy_code for client
6. Client exchanges proxy_code at /oauth/token
Query parameters:
response_type: Must be "code"
@@ -59,8 +135,11 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
code_challenge_method: PKCE method, must be "S256" (required)
Returns:
302 redirect to IdP authorization endpoint
302 redirect to Nextcloud authorization endpoint
"""
# Clean up expired entries periodically
_cleanup_expired_proxy_codes()
# Extract parameters
response_type = request.query_params.get("response_type")
client_id = request.query_params.get("client_id")
@@ -125,7 +204,7 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
status_code=400,
)
# Validate client_id (required for Flow 1)
# Validate client_id (required)
if not client_id:
return JSONResponse(
{
@@ -166,102 +245,92 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
status_code=500,
)
oauth_client = oauth_ctx["oauth_client"]
oauth_config = oauth_ctx["config"]
# Flow 1: Client authenticates directly to IdP WITHOUT server interception
# CRITICAL: This is a direct pass-through to IdP
# The IdP will redirect directly back to the client's callback
# The MCP server does NOT see the IdP authorization code!
# AS Proxy: Store client's params and redirect to Nextcloud with MCP server's credentials
# PKCE is validated locally when the client exchanges the proxy_code at /oauth/token.
# We do NOT forward PKCE to Nextcloud — the MCP server is a confidential client.
server_state = secrets.token_urlsafe(32)
logger.info(
f"Starting Flow 1 - no server session needed, "
f"client will handle IdP response directly at {redirect_uri}"
)
# Use client's redirect_uri for DIRECT callback (bypasses server)
callback_uri = redirect_uri
# Request resource scopes for MCP tools access
# The token will have aud: "mcp-server" claim
# Build scopes from NEXTCLOUD_OIDC_SCOPES config
requested_scope = request.query_params.get("scope", "")
default_scopes = "openid profile email"
resource_scopes = oauth_config.get("scopes", "")
scopes = f"{default_scopes} {resource_scopes}".strip()
if requested_scope:
# Merge client-requested scopes with server defaults
all_scopes = set(scopes.split()) | set(requested_scope.split())
scopes = " ".join(sorted(all_scopes))
# Pass through client's state directly
idp_state = state
# Store session for callback
_as_proxy_sessions[server_state] = ASProxySession(
client_id=client_id,
client_redirect_uri=redirect_uri,
client_state=state,
code_challenge=code_challenge,
code_challenge_method=code_challenge_method,
requested_scopes=scopes,
)
# Use client's own client_id (client must be pre-registered at IdP)
idp_client_id = client_id
# Use MCP server's own client_id with Nextcloud
mcp_server_client_id = os.getenv(
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
)
mcp_server_url = oauth_config["mcp_server_url"]
callback_uri = f"{mcp_server_url}/oauth/callback"
logger.info("Flow 1: Direct client auth to IdP")
logger.info(f" Client ID: {client_id}")
logger.info(f" Client will receive IdP code directly at: {callback_uri}")
logger.info(f" Scopes: {scopes} (resource access for MCP tools)")
logger.info("AS Proxy: Intermediary authorization flow")
logger.info(f" Client: {client_id}")
logger.info(f" MCP server client_id: {mcp_server_client_id}")
logger.info(f" Server callback: {callback_uri}")
logger.info(f" Scopes: {scopes}")
# Get authorization endpoint from OAuth client
if oauth_client:
# External IdP mode (Keycloak) - use oauth_client
auth_url = await oauth_client.get_authorization_url(
state=idp_state,
code_challenge="", # Server doesn't use PKCE with IdP
# Discover Nextcloud authorization endpoint
discovery_url = oauth_config.get("discovery_url")
if not discovery_url:
return JSONResponse(
{
"error": "server_error",
"error_description": "OAuth discovery URL not configured",
},
status_code=500,
)
logger.info(f"Redirecting to external IdP: {auth_url.split('?')[0]}")
else:
# Integrated mode (Nextcloud OIDC) - build URL directly
discovery_url = oauth_config.get("discovery_url")
if not discovery_url:
return JSONResponse(
{
"error": "server_error",
"error_description": "OAuth discovery URL not configured",
},
status_code=500,
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
authorization_endpoint = discovery["authorization_endpoint"]
# Replace internal Docker hostname with public URL for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
if auth_parsed.hostname == internal_parsed.hostname:
public_parsed = parse_url(public_issuer)
authorization_endpoint = (
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
)
if auth_parsed.query:
authorization_endpoint += f"?{auth_parsed.query}"
logger.info(
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
)
# Fetch authorization endpoint from discovery
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
authorization_endpoint = discovery["authorization_endpoint"]
# Redirect to Nextcloud with MCP server's own client_id (no PKCE — confidential client)
idp_params = {
"client_id": mcp_server_client_id,
"redirect_uri": callback_uri,
"response_type": "code",
"scope": scopes,
"state": server_state,
"prompt": "consent",
"resource": f"{mcp_server_url}/mcp", # MCP server audience
}
# IMPORTANT: Replace internal Docker hostname with public URL for browser access
# The discovery endpoint returns http://app/apps/oidc/authorize (internal)
# But browsers need http://localhost:8080/apps/oidc/authorize (public)
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
# Parse internal and authorization endpoint to compare hostnames
internal_parsed = parse_url(oauth_config["nextcloud_host"])
auth_parsed = parse_url(authorization_endpoint)
# Check if authorization endpoint uses internal hostname
if auth_parsed.hostname == internal_parsed.hostname:
# Replace internal hostname+port with public URL
# Keep the path from authorization_endpoint
public_parsed = parse_url(public_issuer)
authorization_endpoint = (
f"{public_parsed.scheme}://{public_parsed.netloc}{auth_parsed.path}"
)
if auth_parsed.query:
authorization_endpoint += f"?{auth_parsed.query}"
logger.info(
f"Rewrote authorization endpoint for browser access: {authorization_endpoint}"
)
idp_params = {
"client_id": idp_client_id,
"redirect_uri": callback_uri,
"response_type": "code",
"scope": scopes,
"state": idp_state,
"prompt": "consent", # Ensure refresh token
"resource": f"{oauth_config['mcp_server_url']}/mcp", # MCP server audience
}
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
logger.info(f"Redirecting to Nextcloud OIDC: {auth_url.split('?')[0]}")
auth_url = f"{authorization_endpoint}?{urlencode(idp_params)}"
logger.info(f"Redirecting to Nextcloud OIDC: {auth_url.split('?')[0]}")
return RedirectResponse(auth_url, status_code=302)
@@ -599,6 +668,11 @@ async def oauth_callback(request: Request):
status_code=400,
)
# Check AS proxy sessions first (in-memory, ADR-023)
if state in _as_proxy_sessions:
logger.info("Routing to AS proxy callback (ADR-023)")
return await _oauth_callback_as_proxy(request, state)
# Lookup OAuth session to determine flow type
oauth_ctx = request.app.state.oauth_context
if not oauth_ctx:
@@ -641,3 +715,460 @@ async def oauth_callback(request: Request):
},
status_code=400,
)
# ---------------------------------------------------------------------------
# AS Proxy endpoints (ADR-023)
# ---------------------------------------------------------------------------
async def _oauth_callback_as_proxy(
request: Request, server_state: str
) -> RedirectResponse | JSONResponse:
"""
Handle Nextcloud callback for the AS proxy flow.
Exchanges the Nextcloud auth code for tokens server-side, generates a
proxy authorization code, and redirects back to the client.
"""
# Check for errors from Nextcloud
error = request.query_params.get("error")
if error:
error_description = request.query_params.get(
"error_description", "Authorization failed"
)
logger.error(f"AS proxy callback error: {error} - {error_description}")
# Retrieve session to redirect back to client with error
session = _as_proxy_sessions.pop(server_state, None)
if session:
params = urlencode(
{
"error": error,
"error_description": error_description,
"state": session.client_state,
}
)
return RedirectResponse(
f"{session.client_redirect_uri}?{params}", status_code=302
)
return JSONResponse(
{"error": error, "error_description": error_description},
status_code=400,
)
code = request.query_params.get("code")
if not code:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "code parameter is required",
},
status_code=400,
)
# Retrieve and consume the session (one-time use)
session = _as_proxy_sessions.pop(server_state, None)
if not session:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Unknown or expired server state",
},
status_code=400,
)
if session.is_expired:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Authorization session expired",
},
status_code=400,
)
# Get OAuth context
oauth_ctx = request.app.state.oauth_context
oauth_config = oauth_ctx["config"]
mcp_server_client_id = os.getenv(
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
)
mcp_server_client_secret = os.getenv(
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret")
)
mcp_server_url = oauth_config["mcp_server_url"]
callback_uri = f"{mcp_server_url}/oauth/callback"
# Discover token endpoint
discovery_url = oauth_config.get("discovery_url")
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
token_endpoint = discovery["token_endpoint"]
# Exchange auth code with Nextcloud (server-side, confidential client, no PKCE)
token_params = {
"grant_type": "authorization_code",
"code": code,
"redirect_uri": callback_uri,
"client_id": mcp_server_client_id,
"client_secret": mcp_server_client_secret,
}
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(token_endpoint, data=token_params)
if response.status_code != 200:
logger.error(
f"AS proxy token exchange failed: {response.status_code} {response.text}"
)
params = urlencode(
{
"error": "server_error",
"error_description": "Failed to exchange authorization code",
"state": session.client_state,
}
)
return RedirectResponse(
f"{session.client_redirect_uri}?{params}", status_code=302
)
nc_token_response = response.json()
logger.info(
"AS proxy: Successfully exchanged code for Nextcloud token "
f"(token_type={nc_token_response.get('token_type')})"
)
# Generate a proxy authorization code for the client
proxy_code = secrets.token_urlsafe(32)
_proxy_codes[proxy_code] = ProxyCodeEntry(
client_id=session.client_id,
client_redirect_uri=session.client_redirect_uri,
client_state=session.client_state,
code_challenge=session.code_challenge,
code_challenge_method=session.code_challenge_method,
nc_token_response=nc_token_response,
)
# Redirect back to client with proxy_code and client's original state
redirect_params = urlencode({"code": proxy_code, "state": session.client_state})
redirect_url = f"{session.client_redirect_uri}?{redirect_params}"
logger.info(
f"AS proxy: Redirecting to client with proxy_code (client_id={session.client_id})"
)
return RedirectResponse(redirect_url, status_code=302)
def _verify_pkce_s256(code_verifier: str, code_challenge: str) -> bool:
"""Verify PKCE S256 code_verifier against stored code_challenge.
Per RFC 7636 Section 4.6:
code_challenge = BASE64URL(SHA256(ASCII(code_verifier)))
"""
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
computed_challenge = urlsafe_b64encode(digest).decode("ascii").rstrip("=")
return secrets.compare_digest(computed_challenge, code_challenge)
async def oauth_token_endpoint(request: Request) -> JSONResponse:
"""
OAuth token endpoint for AS proxy (ADR-023).
Handles:
- grant_type=authorization_code: Exchange proxy_code for Nextcloud token
- grant_type=refresh_token: Proxy refresh request to Nextcloud
Form parameters:
grant_type: "authorization_code" or "refresh_token"
code: Proxy authorization code (for authorization_code grant)
redirect_uri: Must match the original redirect_uri
code_verifier: PKCE verifier (for authorization_code grant)
client_id: Client identifier
client_secret: Client secret (optional for public clients)
refresh_token: Refresh token (for refresh_token grant)
"""
# Parse form body
form = await request.form()
grant_type = form.get("grant_type")
if grant_type == "authorization_code":
return await _token_authorization_code(request, form)
elif grant_type == "refresh_token":
return await _token_refresh(request, form)
else:
return JSONResponse(
{
"error": "unsupported_grant_type",
"error_description": f"Unsupported grant_type: {grant_type}",
},
status_code=400,
)
async def _token_authorization_code(request: Request, form) -> JSONResponse:
"""Handle authorization_code grant type at the token endpoint."""
code = form.get("code")
redirect_uri = form.get("redirect_uri")
code_verifier = form.get("code_verifier")
client_id = form.get("client_id")
if not code:
return JSONResponse(
{"error": "invalid_request", "error_description": "code is required"},
status_code=400,
)
# Look up and consume proxy code (one-time use)
entry = _proxy_codes.pop(code, None)
if not entry:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Invalid or expired authorization code",
},
status_code=400,
)
if entry.is_expired:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Authorization code has expired",
},
status_code=400,
)
# Validate client_id matches
if client_id and client_id != entry.client_id:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "client_id mismatch",
},
status_code=400,
)
# Validate redirect_uri matches
if redirect_uri and redirect_uri != entry.client_redirect_uri:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "redirect_uri mismatch",
},
status_code=400,
)
# Verify PKCE
if entry.code_challenge:
if not code_verifier:
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "code_verifier is required (PKCE)",
},
status_code=400,
)
if not _verify_pkce_s256(code_verifier, entry.code_challenge):
logger.warning(f"PKCE verification failed for client {entry.client_id}")
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "PKCE verification failed",
},
status_code=400,
)
logger.info(
f"AS proxy token: Returning Nextcloud token for client {entry.client_id}"
)
# Return the stored Nextcloud token response directly
return JSONResponse(entry.nc_token_response)
async def _token_refresh(request: Request, form) -> JSONResponse:
"""Handle refresh_token grant type by proxying to Nextcloud."""
refresh_token = form.get("refresh_token")
if not refresh_token:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "refresh_token is required",
},
status_code=400,
)
# Get OAuth context
oauth_ctx = request.app.state.oauth_context
if not oauth_ctx:
return JSONResponse(
{
"error": "server_error",
"error_description": "OAuth not configured on server",
},
status_code=500,
)
oauth_config = oauth_ctx["config"]
mcp_server_client_id = os.getenv(
"MCP_SERVER_CLIENT_ID", oauth_config.get("client_id")
)
mcp_server_client_secret = os.getenv(
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret")
)
mcp_server_url = oauth_config["mcp_server_url"]
# Discover token endpoint
discovery_url = oauth_config.get("discovery_url")
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
token_endpoint = discovery["token_endpoint"]
# Proxy refresh request to Nextcloud
token_params = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": mcp_server_client_id,
"client_secret": mcp_server_client_secret,
"resource": f"{mcp_server_url}/mcp",
}
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(token_endpoint, data=token_params)
if response.status_code != 200:
logger.error(
f"AS proxy token refresh failed: {response.status_code} {response.text}"
)
return JSONResponse(
{
"error": "invalid_grant",
"error_description": "Token refresh failed",
},
status_code=response.status_code,
)
return JSONResponse(response.json())
async def oauth_register_proxy(request: Request) -> JSONResponse:
"""
DCR proxy endpoint for AS proxy (ADR-023).
Proxies Dynamic Client Registration requests to Nextcloud's OIDC endpoint
and registers the resulting client in the local ClientRegistry.
This allows MCP clients to register via the MCP server (their AS) rather
than directly with Nextcloud (which would produce tokens with wrong audience).
"""
# Parse JSON body
try:
body = await request.json()
except Exception:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "Request body must be valid JSON",
},
status_code=400,
)
# Get OAuth context for Nextcloud endpoint
oauth_ctx = request.app.state.oauth_context
if not oauth_ctx:
return JSONResponse(
{
"error": "server_error",
"error_description": "OAuth not configured on server",
},
status_code=500,
)
oauth_config = oauth_ctx["config"]
nextcloud_host = oauth_config["nextcloud_host"]
# Proxy DCR to Nextcloud
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
logger.info(f"DCR proxy: Forwarding registration to {registration_endpoint}")
async with nextcloud_httpx_client() as http_client:
response = await http_client.post(
registration_endpoint,
json=body,
headers={"Content-Type": "application/json"},
)
if response.status_code not in (200, 201):
logger.error(
f"DCR proxy: Nextcloud registration failed: {response.status_code} {response.text}"
)
return JSONResponse(
response.json()
if response.headers.get("content-type", "").startswith("application/json")
else {
"error": "server_error",
"error_description": f"Upstream registration failed: {response.status_code}",
},
status_code=response.status_code,
)
nc_response = response.json()
new_client_id = nc_response.get("client_id")
if new_client_id:
# Register in local ClientRegistry so /oauth/authorize accepts it
redirect_uris = nc_response.get("redirect_uris", [])
client_name = nc_response.get("client_name", "")
registry = get_client_registry()
registry.register_proxy_client(
client_id=new_client_id,
redirect_uris=redirect_uris,
name=client_name,
)
logger.info(f"DCR proxy: Registered client {new_client_id} in local registry")
return JSONResponse(nc_response, status_code=response.status_code)
async def oauth_as_metadata(request: Request) -> JSONResponse:
"""
RFC 8414 OAuth Authorization Server Metadata endpoint (ADR-023).
Advertises the MCP server as its own OAuth Authorization Server so that
MCP clients (e.g., Claude Code) authenticate through the proxy rather
than directly with Nextcloud.
"""
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
# Dynamically discover scopes from registered tools if available
scopes_supported = ["openid", "profile", "email"]
app_scopes = getattr(request.app.state, "supported_scopes", None)
if app_scopes:
scopes_supported = app_scopes
return JSONResponse(
{
"issuer": mcp_server_url,
"authorization_endpoint": f"{mcp_server_url}/oauth/authorize",
"token_endpoint": f"{mcp_server_url}/oauth/token",
"registration_endpoint": f"{mcp_server_url}/oauth/register",
"response_types_supported": ["code"],
"grant_types_supported": ["authorization_code", "refresh_token"],
"code_challenge_methods_supported": ["S256"],
"token_endpoint_auth_methods_supported": [
"client_secret_post",
"client_secret_basic",
"none",
],
"scopes_supported": scopes_supported,
}
)