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:
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
)
|
||||
|
||||
Vendored
+1
-1
Submodule third_party/astrolabe updated: 672f572d5f...af53f1c02f
Reference in New Issue
Block a user