fix: address review feedback — security, caching, CI 429 retry

- Add 429 retry with exponential backoff to register_client() (fixes CI
  oauth matrix failures from parallel DCR requests)
- Make client_id, redirect_uri, and PKCE mandatory at token endpoint
- Add null-checks for discovery_url and OAuth credentials in proxy flows
- Add OIDC discovery document caching with 5-min TTL
- Add per-IP rate limiting on /oauth/register DCR proxy
- Discover DCR endpoint from OIDC discovery instead of hardcoding
- Extract extract_user_id_from_token to auth/token_utils.py (breaks
  circular imports between server/ and auth/ layers)
- Add TTL scope cache in scope_authorization.py (avoids DB hit per tool)
- Add defense-in-depth scope validation in storage layer
- Broaden elicitation exception handling with graceful fallback
- Add idempotentHint to nc_auth_check_status, return "pending" status
  after accepted elicitation, add polling interval to description
- Change ALL_SUPPORTED_SCOPES from tuple to frozenset for O(1) lookups
- Replace Optional[str] with str | None throughout config.py
- Use default_factory for ProxyCodeEntry/ASProxySession dataclasses
- Add proxy code/session cleanup to background loop
- Fix OIDC verification CI step to only run for oauth/login-flow modes
- Add unit tests for access.py REST endpoints (10 tests)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-02 17:22:23 +01:00
parent 0a53aa5fcd
commit f43343356e
17 changed files with 727 additions and 247 deletions
+11 -2
View File
@@ -14,6 +14,7 @@ from nextcloud_mcp_server.api.passwords import (
_extract_basic_auth,
_get_app_password_storage,
)
from nextcloud_mcp_server.auth.scope_authorization import invalidate_scope_cache
from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
logger = logging.getLogger(__name__)
@@ -79,6 +80,11 @@ async def update_user_scopes(request: Request) -> JSONResponse:
This only updates the stored scopes, not the app password itself.
The app password remains valid; scope enforcement is application-level.
Security note: This endpoint allows direct scope modification without
re-authenticating via Login Flow. The caller must authenticate with
valid BasicAuth credentials (user_id + app_password), which serves
as the authorization check.
"""
path_user_id = request.path_params.get("user_id")
if not path_user_id:
@@ -113,7 +119,7 @@ async def update_user_scopes(request: Request) -> JSONResponse:
{
"success": False,
"error": f"Invalid scopes: {', '.join(invalid)}",
"valid_scopes": ALL_SUPPORTED_SCOPES,
"valid_scopes": sorted(ALL_SUPPORTED_SCOPES),
},
status_code=400,
)
@@ -137,6 +143,9 @@ async def update_user_scopes(request: Request) -> JSONResponse:
scopes=scopes,
)
# Invalidate scope cache so subsequent tool calls see updated scopes
invalidate_scope_cache(username)
return JSONResponse(
{
"success": True,
@@ -159,6 +168,6 @@ async def list_supported_scopes(_: Request) -> JSONResponse:
return JSONResponse(
{
"success": True,
"scopes": ALL_SUPPORTED_SCOPES,
"scopes": sorted(ALL_SUPPORTED_SCOPES),
}
)
+7 -1
View File
@@ -1532,13 +1532,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
mcp_app = mcp.streamable_http_app()
async def _login_flow_cleanup_loop() -> None:
"""Periodically clean up expired Login Flow v2 sessions."""
"""Periodically clean up expired Login Flow v2 sessions and proxy codes."""
from nextcloud_mcp_server.auth.oauth_routes import ( # noqa: PLC0415
_cleanup_expired_proxy_codes,
)
while True:
try:
storage = await get_shared_storage()
count = await storage.delete_expired_login_flow_sessions()
if count:
logger.info(f"Cleaned up {count} expired login flow sessions")
# Also clean up expired AS proxy codes/sessions
_cleanup_expired_proxy_codes()
except Exception as e:
logger.warning(f"Login flow cleanup error: {e}")
await anyio.sleep(3600) # Every hour
@@ -83,6 +83,7 @@ async def register_client(
scopes: str = "openid profile email",
token_type: str | None = "Bearer",
resource_url: str | None = None,
max_retries: int = 3,
) -> ClientInfo:
"""
Register a new OAuth client using RFC 7591 Dynamic Client Registration.
@@ -98,6 +99,7 @@ async def register_client(
token_type: Type of access tokens (default: "Bearer", supports "JWT" for Nextcloud).
Set to None to omit this field (required for Keycloak and other standard providers).
resource_url: OAuth 2.0 Protected Resource URL (RFC 9728) - used for token introspection authorization
max_retries: Maximum number of retries for 429 responses (default: 3)
Returns:
ClientInfo with registration details
@@ -135,57 +137,91 @@ async def register_client(
logger.debug(f"Registration endpoint: {registration_endpoint}")
async with nextcloud_httpx_client(timeout=30.0) as client:
try:
response = await client.post(
registration_endpoint,
json=client_metadata,
headers={"Content-Type": "application/json"},
)
response.raise_for_status()
client_info = response.json()
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
# Log if RFC 7592 fields are present
has_reg_token = "registration_access_token" in client_info
has_reg_uri = "registration_client_uri" in client_info
if has_reg_token and has_reg_uri:
logger.info(
"RFC 7592 management fields received - client deletion will be supported"
for attempt in range(max_retries):
try:
response = await client.post(
registration_endpoint,
json=client_metadata,
headers={"Content-Type": "application/json"},
)
else:
logger.warning("RFC 7592 fields missing - client deletion may not work")
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
client_id_issued_at=client_info.get(
"client_id_issued_at", int(time.time())
),
client_secret_expires_at=client_info.get(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
registration_access_token=client_info.get("registration_access_token"),
registration_client_uri=client_info.get("registration_client_uri"),
)
if response.status_code == 429:
# Rate limited - retry with exponential backoff
if attempt < max_retries - 1:
retry_after = int(response.headers.get("Retry-After", 2))
wait_time = min(retry_after, 2**attempt)
logger.warning(
f"Rate limited (429) registering client, "
f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
)
await anyio.sleep(wait_time)
continue
else:
logger.error(
f"Failed to register client after {max_retries} attempts: Rate limited (429)"
)
response.raise_for_status()
except httpx.HTTPStatusError as e:
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
logger.error(f"Response: {e.response.text}")
raise
except KeyError as e:
logger.error(f"Invalid response from registration endpoint: missing {e}")
raise ValueError(f"Invalid registration response: missing {e}")
response.raise_for_status()
client_info = response.json()
logger.info(
f"Successfully registered client: {client_info.get('client_id')}"
)
expires_at = dt.datetime.fromtimestamp(
client_info.get("client_secret_expires_at")
)
logger.info(
f"Client expires at: {expires_at} "
f"(in {client_info.get('client_secret_expires_at', 0) - int(time.time())} seconds)"
)
# Log if RFC 7592 fields are present
has_reg_token = "registration_access_token" in client_info
has_reg_uri = "registration_client_uri" in client_info
if has_reg_token and has_reg_uri:
logger.info(
"RFC 7592 management fields received - client deletion will be supported"
)
else:
logger.warning(
"RFC 7592 fields missing - client deletion may not work"
)
return ClientInfo(
client_id=client_info["client_id"],
client_secret=client_info["client_secret"],
client_id_issued_at=client_info.get(
"client_id_issued_at", int(time.time())
),
client_secret_expires_at=client_info.get(
"client_secret_expires_at", int(time.time()) + 3600
),
redirect_uris=client_info.get("redirect_uris", redirect_uris),
registration_access_token=client_info.get(
"registration_access_token"
),
registration_client_uri=client_info.get("registration_client_uri"),
)
except httpx.HTTPStatusError as e:
logger.error(
f"Failed to register client: HTTP {e.response.status_code}"
)
logger.error(f"Response: {e.response.text}")
raise
except KeyError as e:
logger.error(
f"Invalid response from registration endpoint: missing {e}"
)
raise ValueError(f"Invalid registration response: missing {e}")
# Should not reach here, but raise if we do
raise httpx.HTTPStatusError(
"Registration failed after retries",
request=httpx.Request("POST", registration_endpoint),
response=httpx.Response(429),
)
async def delete_client(
+7 -3
View File
@@ -76,9 +76,13 @@ async def present_login_url(
logger.info("User cancelled login flow")
return "cancelled"
except NotImplementedError as e:
except NotImplementedError:
# Elicitation not supported by this client/SDK - fall back to message
logger.debug(
f"Elicitation not available ({type(e).__name__}: {e}), returning URL in message"
logger.debug("Elicitation not available, returning URL in message")
return "message_only"
except Exception as e:
logger.warning(
f"Elicitation failed unexpectedly ({type(e).__name__}: {e}), "
"falling back to message"
)
return "message_only"
+174 -63
View File
@@ -26,6 +26,7 @@ import secrets
import time
from base64 import urlsafe_b64encode
from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlencode
from urllib.parse import urlparse as parse_url
@@ -50,20 +51,21 @@ logger = logging.getLogger(__name__)
@dataclass
class ProxyCodeEntry:
"""Stores state for a proxy authorization code issued by the AS proxy."""
"""Stores state for a proxy authorization code issued by the AS proxy.
Proxy codes have a 60-second TTL as a security mitigation: they are
single-use, ephemeral codes that bridge the AS proxy callback and the
client's token exchange. The short window limits replay risk.
"""
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
nc_token_response: dict[str, Any] # 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
expires_at: float = field(default_factory=lambda: time.time() + 60)
@property
def is_expired(self) -> bool:
@@ -73,7 +75,11 @@ class ProxyCodeEntry:
# Server-side state for AS proxy authorize → callback mapping
@dataclass
class ASProxySession:
"""Stores state between /oauth/authorize and the Nextcloud callback."""
"""Stores state between /oauth/authorize and the Nextcloud callback.
Sessions have a 600-second (10 minute) TTL to allow time for the user
to complete the browser-based authorization flow.
"""
client_id: str
client_redirect_uri: str
@@ -82,11 +88,7 @@ class ASProxySession:
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
expires_at: float = field(default_factory=lambda: time.time() + 600)
@property
def is_expired(self) -> bool:
@@ -97,6 +99,30 @@ class ASProxySession:
_proxy_codes: dict[str, ProxyCodeEntry] = {}
_as_proxy_sessions: dict[str, ASProxySession] = {}
# OIDC discovery document cache (URL → (expires_at, data))
_discovery_cache: dict[str, tuple[float, dict[str, Any]]] = {}
_DISCOVERY_CACHE_TTL = 300 # 5 minutes
# DCR rate limiting (IP → [timestamps])
_dcr_rate_limit: dict[str, list[float]] = {}
_DCR_RATE_LIMIT_MAX = 10 # max requests
_DCR_RATE_LIMIT_WINDOW = 60 # per 60 seconds
async def _get_cached_discovery(url: str) -> dict[str, Any]:
"""Fetch OIDC discovery document with caching (5-minute TTL)."""
now = time.time()
if url in _discovery_cache:
expires_at, data = _discovery_cache[url]
if now < expires_at:
return data
async with nextcloud_httpx_client() as http_client:
response = await http_client.get(url)
response.raise_for_status()
data = response.json()
_discovery_cache[url] = (now + _DISCOVERY_CACHE_TTL, data)
return data
def _cleanup_expired_proxy_codes() -> None:
"""Remove expired proxy codes and sessions."""
@@ -295,11 +321,8 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
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"]
discovery = await _get_cached_discovery(discovery_url)
authorization_endpoint = discovery["authorization_endpoint"]
# Replace internal Docker hostname with public URL for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
@@ -424,11 +447,8 @@ async def oauth_authorize_nextcloud(
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"]
discovery = await _get_cached_discovery(discovery_url)
authorization_endpoint = discovery["authorization_endpoint"]
# Fix internal hostname for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
@@ -530,11 +550,17 @@ async def oauth_callback_nextcloud(request: Request):
callback_uri = f"{mcp_server_url}/oauth/callback"
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"]
if not discovery_url:
return JSONResponse(
{
"error": "server_error",
"error_description": "OIDC discovery URL not configured",
},
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
# Build token exchange params
token_params = {
@@ -797,16 +823,32 @@ async def _oauth_callback_as_proxy(
mcp_server_client_secret = os.getenv(
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret")
)
if not mcp_server_client_id or not mcp_server_client_secret:
return JSONResponse(
{
"error": "server_error",
"error_description": "MCP server OAuth credentials not configured",
},
status_code=500,
)
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"]
if not discovery_url:
return JSONResponse(
{
"error": "server_error",
"error_description": "OIDC discovery URL not configured",
},
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
# Exchange auth code with Nextcloud (server-side, confidential client, no PKCE)
token_params = {
@@ -942,8 +984,17 @@ async def _token_authorization_code(request: Request, form) -> JSONResponse:
status_code=400,
)
# Validate client_id matches
if client_id and client_id != entry.client_id:
# Validate client_id (required per RFC 6749 Section 4.1.3)
if not client_id:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "client_id is required",
},
status_code=400,
)
if client_id != entry.client_id:
return JSONResponse(
{
"error": "invalid_grant",
@@ -952,8 +1003,17 @@ async def _token_authorization_code(request: Request, form) -> JSONResponse:
status_code=400,
)
# Validate redirect_uri matches
if redirect_uri and redirect_uri != entry.client_redirect_uri:
# Validate redirect_uri (required per RFC 6749 Section 4.1.3)
if not redirect_uri:
return JSONResponse(
{
"error": "invalid_request",
"error_description": "redirect_uri is required",
},
status_code=400,
)
if redirect_uri != entry.client_redirect_uri:
return JSONResponse(
{
"error": "invalid_grant",
@@ -962,26 +1022,29 @@ async def _token_authorization_code(request: Request, form) -> JSONResponse:
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,
)
# Verify PKCE (always required — oauth_authorize mandates code_challenge)
assert entry.code_challenge, (
"code_challenge must be set (enforced by oauth_authorize)"
) # noqa: S101
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,
)
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}"
@@ -1022,15 +1085,31 @@ async def _token_refresh(request: Request, form) -> JSONResponse:
mcp_server_client_secret = os.getenv(
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret")
)
if not mcp_server_client_id or not mcp_server_client_secret:
return JSONResponse(
{
"error": "server_error",
"error_description": "MCP server OAuth credentials not configured",
},
status_code=500,
)
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"]
if not discovery_url:
return JSONResponse(
{
"error": "server_error",
"error_description": "OIDC discovery URL not configured",
},
status_code=500,
)
discovery = await _get_cached_discovery(discovery_url)
token_endpoint = discovery["token_endpoint"]
# Proxy refresh request to Nextcloud
token_params = {
@@ -1095,8 +1174,40 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
oauth_config = oauth_ctx["config"]
nextcloud_host = oauth_config["nextcloud_host"]
# Proxy DCR to Nextcloud
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
# Rate limit DCR requests per client IP
client_ip = request.client.host if request.client else "unknown"
now = time.time()
timestamps = _dcr_rate_limit.get(client_ip, [])
# Remove timestamps outside the window
timestamps = [t for t in timestamps if now - t < _DCR_RATE_LIMIT_WINDOW]
if len(timestamps) >= _DCR_RATE_LIMIT_MAX:
logger.warning(f"DCR rate limit exceeded for {client_ip}")
return JSONResponse(
{
"error": "too_many_requests",
"error_description": "Rate limit exceeded for client registration",
},
status_code=429,
headers={"Retry-After": str(_DCR_RATE_LIMIT_WINDOW)},
)
timestamps.append(now)
_dcr_rate_limit[client_ip] = timestamps
# Discover registration endpoint from OIDC discovery (prefer over hardcoded path)
discovery_url = oauth_config.get("discovery_url")
if discovery_url:
try:
discovery = await _get_cached_discovery(discovery_url)
registration_endpoint = discovery.get(
"registration_endpoint", f"{nextcloud_host}/apps/oidc/register"
)
except Exception:
logger.warning(
"Failed to fetch OIDC discovery for DCR endpoint, using fallback"
)
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
else:
registration_endpoint = f"{nextcloud_host}/apps/oidc/register"
logger.info(f"DCR proxy: Forwarding registration to {registration_endpoint}")
@@ -1,6 +1,7 @@
"""Scope-based authorization for MCP tools."""
import logging
import time
from functools import wraps
from typing import Any, Callable
@@ -141,7 +142,7 @@ def require_scopes(*required_scopes: str):
if get_settings().enable_login_flow and not set(required_scopes).issubset(
IDENTITY_ONLY_SCOPES
):
from nextcloud_mcp_server.server.oauth_tools import ( # noqa: PLC0415
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
extract_user_id_from_token,
)
@@ -476,9 +477,18 @@ def discover_all_scopes(mcp) -> list[str]:
# ── Login Flow v2 helpers ────────────────────────────────────────────────
# Scope cache: user_id → (expires_at, scopes)
_scope_cache: dict[str, tuple[float, list[str] | str | None]] = {}
_SCOPE_CACHE_TTL = 300 # 5 minutes
def invalidate_scope_cache(user_id: str) -> None:
"""Remove cached scopes for a user (call when scopes are updated)."""
_scope_cache.pop(user_id, None)
async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
"""Look up stored app password scopes for a user.
"""Look up stored app password scopes for a user (with TTL cache).
Returns:
- list[str]: Specific scopes granted
@@ -489,11 +499,21 @@ async def _get_stored_scopes(user_id: str) -> list[str] | str | None:
Storage/infrastructure exceptions propagate to the caller
(require_scopes decorator) for proper MCP error responses.
"""
now = time.time()
if user_id in _scope_cache:
expires_at, cached = _scope_cache[user_id]
if now < expires_at:
return cached
storage = await get_shared_storage()
data = await storage.get_app_password_with_scopes(user_id)
if data is None:
return None
if data["scopes"] is None:
return "all"
return data["scopes"]
result = None
elif data["scopes"] is None:
result = "all"
else:
result = data["scopes"]
_scope_cache[user_id] = (now + _SCOPE_CACHE_TTL, result)
return result
+13
View File
@@ -1493,6 +1493,9 @@ class RefreshTokenStorage:
app_password: Nextcloud app password to encrypt and store
scopes: List of granted scopes (None = all scopes allowed)
username: Nextcloud loginName from Login Flow v2 response
Raises:
ValueError: If any scope is not in ALL_SUPPORTED_SCOPES
"""
if not self._initialized:
await self.initialize()
@@ -1503,6 +1506,16 @@ class RefreshTokenStorage:
"Set TOKEN_ENCRYPTION_KEY for app password storage."
)
# Defense-in-depth: validate scopes at storage layer
if scopes is not None:
from nextcloud_mcp_server.models.auth import ( # noqa: PLC0415
ALL_SUPPORTED_SCOPES,
)
invalid = [s for s in scopes if s not in ALL_SUPPORTED_SCOPES]
if invalid:
raise ValueError(f"Invalid scopes: {invalid}")
encrypted_password = self.cipher.encrypt(app_password.encode())
scopes_json = json.dumps(scopes) if scopes is not None else None
now = int(time.time())
+85
View File
@@ -0,0 +1,85 @@
"""Token utility functions for extracting user identity from MCP access tokens.
Extracted from server/oauth_tools.py to break circular import dependencies
between server/ and auth/ layers.
"""
import logging
import os
import jwt
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
async def extract_user_id_from_token(ctx: Context) -> str:
"""Extract user_id from the MCP access token (Flow 1).
Handles both JWT and opaque tokens:
- JWT: Decode and extract 'sub' claim
- Opaque: Call userinfo endpoint to get 'sub'
Args:
ctx: MCP context with access token
Returns:
user_id extracted from token, or "default_user" as fallback
"""
# Use MCP SDK's get_access_token() which uses contextvars
access_token: AccessToken | None = get_access_token()
if not access_token or not access_token.token:
logger.warning(" ✗ No access token found via get_access_token()")
return "default_user"
token = access_token.token
is_jwt = "." in token and token.count(".") >= 2
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
# Try JWT decode first
if is_jwt:
try:
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub", "unknown")
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
return user_id
except Exception as e:
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
# Opaque token - call userinfo endpoint
logger.info(" Opaque token detected, calling userinfo endpoint...")
try:
# Get userinfo endpoint from OIDC discovery
oidc_discovery_uri = os.getenv(
"OIDC_DISCOVERY_URI",
"http://localhost:8080/.well-known/openid-configuration",
)
async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(oidc_discovery_uri)
discovery_response.raise_for_status()
discovery = discovery_response.json()
userinfo_endpoint = discovery.get("userinfo_endpoint")
if userinfo_endpoint:
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
if userinfo:
user_id = userinfo.get("sub", "unknown")
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
return user_id
else:
logger.error(" ✗ Userinfo query failed")
else:
logger.error(" ✗ No userinfo_endpoint available")
except Exception as e:
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
# Fallback
logger.warning(" Using fallback user_id: default_user")
return "default_user"
+25 -25
View File
@@ -5,7 +5,7 @@ import socket
import ssl
from dataclasses import dataclass
from enum import Enum
from typing import Any, Optional
from typing import Any
class DeploymentMode(Enum):
@@ -169,32 +169,32 @@ class Settings:
# Optional: If not set, mode is auto-detected from other settings
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
# oauth_token_exchange, smithery
deployment_mode: Optional[str] = None
deployment_mode: str | None = None
# OAuth/OIDC settings
oidc_discovery_url: Optional[str] = None
oidc_client_id: Optional[str] = None
oidc_client_secret: Optional[str] = None
oidc_issuer: Optional[str] = None
oidc_discovery_url: str | None = None
oidc_client_id: str | None = None
oidc_client_secret: str | None = None
oidc_issuer: str | None = None
# Nextcloud settings
nextcloud_host: Optional[str] = None
nextcloud_username: Optional[str] = None
nextcloud_password: Optional[str] = None
nextcloud_app_password: Optional[str] = None # Preferred over nextcloud_password
nextcloud_host: str | None = None
nextcloud_username: str | None = None
nextcloud_password: str | None = None
nextcloud_app_password: str | None = None # Preferred over nextcloud_password
# Nextcloud SSL/TLS settings
nextcloud_verify_ssl: bool = True
nextcloud_ca_bundle: Optional[str] = None
nextcloud_ca_bundle: str | None = None
# ADR-005: Token Audience Validation (required for OAuth mode)
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience)
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier
nextcloud_mcp_server_url: str | None = None # MCP server URL (used as audience)
nextcloud_resource_uri: str | None = None # Nextcloud resource identifier
# Token verification endpoints
jwks_uri: Optional[str] = None
introspection_uri: Optional[str] = None
userinfo_uri: Optional[str] = None
jwks_uri: str | None = None
introspection_uri: str | None = None
userinfo_uri: str | None = None
# Progressive Consent settings (always enabled - no flag needed)
enable_token_exchange: bool = False
@@ -218,8 +218,8 @@ class Settings:
# TOKEN_STORAGE_DB: Path to SQLite database for persistent storage.
# Used for webhook tracking (all modes) and OAuth token storage.
# Defaults to /tmp/tokens.db
token_encryption_key: Optional[str] = None
token_storage_db: Optional[str] = None
token_encryption_key: str | None = None
token_storage_db: str | None = None
# Vector sync settings (ADR-007)
vector_sync_enabled: bool = False
@@ -229,19 +229,19 @@ class Settings:
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
# Qdrant settings (mutually exclusive modes)
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333
qdrant_location: Optional[str] = None # Local mode: :memory: or /path/to/data
qdrant_api_key: Optional[str] = None
qdrant_url: str | None = None # Network mode: http://qdrant:6333
qdrant_location: str | None = None # Local mode: :memory: or /path/to/data
qdrant_api_key: str | None = None
qdrant_collection: str = "nextcloud_content"
# Ollama settings (for embeddings)
ollama_base_url: Optional[str] = None
ollama_base_url: str | None = None
ollama_embedding_model: str = "nomic-embed-text"
ollama_verify_ssl: bool = True
# OpenAI settings (for embeddings)
openai_api_key: Optional[str] = None
openai_base_url: Optional[str] = None
openai_api_key: str | None = None
openai_base_url: str | None = None
openai_embedding_model: str = "text-embedding-3-small"
# Document chunking settings (for vector embeddings)
@@ -251,7 +251,7 @@ class Settings:
# Observability settings
metrics_enabled: bool = True
metrics_port: int = 9090
otel_exporter_otlp_endpoint: Optional[str] = None
otel_exporter_otlp_endpoint: str | None = None
otel_exporter_verify_ssl: bool = False
otel_service_name: str = "nextcloud-mcp-server"
otel_traces_sampler: str = "always_on"
+1 -1
View File
@@ -272,7 +272,7 @@ async def _get_client_from_login_flow(
Raises:
ProvisioningRequiredError: If no stored app password exists
"""
from nextcloud_mcp_server.server.oauth_tools import ( # noqa: PLC0415
from nextcloud_mcp_server.auth.token_utils import ( # noqa: PLC0415
extract_user_id_from_token,
)
+24 -22
View File
@@ -51,26 +51,28 @@ class UpdateScopesResponse(BaseResponse):
new_scopes: list[str] | None = Field(None, description="Updated scope set")
# All supported application-level scopes
ALL_SUPPORTED_SCOPES = (
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"todo:read",
"todo:write",
"contacts:read",
"contacts:write",
"files:read",
"files:write",
"tables:read",
"tables:write",
"deck:read",
"deck:write",
"cookbook:read",
"cookbook:write",
"sharing:read",
"sharing:write",
"news:read",
"news:write",
# All supported application-level scopes (frozenset for O(1) membership tests)
ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
{
"notes:read",
"notes:write",
"calendar:read",
"calendar:write",
"todo:read",
"todo:write",
"contacts:read",
"contacts:write",
"files:read",
"files:write",
"tables:read",
"tables:write",
"deck:read",
"deck:write",
"cookbook:read",
"cookbook:write",
"sharing:read",
"sharing:write",
"news:read",
"news:write",
}
)
+12 -3
View File
@@ -16,6 +16,7 @@ from nextcloud_mcp_server.auth.elicitation import present_login_url
from nextcloud_mcp_server.auth.login_flow import LoginFlowV2Client
from nextcloud_mcp_server.auth.scope_authorization import require_scopes
from nextcloud_mcp_server.auth.storage import get_shared_storage
from nextcloud_mcp_server.auth.token_utils import extract_user_id_from_token
from nextcloud_mcp_server.config import get_nextcloud_ssl_verify, get_settings
from nextcloud_mcp_server.models.auth import (
ALL_SUPPORTED_SCOPES,
@@ -23,7 +24,6 @@ from nextcloud_mcp_server.models.auth import (
ProvisionStatusResponse,
UpdateScopesResponse,
)
from nextcloud_mcp_server.server.oauth_tools import extract_user_id_from_token
logger = logging.getLogger(__name__)
@@ -92,7 +92,7 @@ def register_auth_tools(mcp: FastMCP) -> None:
return ProvisionAccessResponse(
status="error",
message=f"Invalid scopes: {', '.join(invalid_scopes)}. "
f"Valid scopes: {', '.join(ALL_SUPPORTED_SCOPES)}",
f"Valid scopes: {', '.join(sorted(ALL_SUPPORTED_SCOPES))}",
success=False,
)
@@ -160,6 +160,13 @@ def register_auth_tools(mcp: FastMCP) -> None:
"Login acknowledged. Call nc_auth_check_status to verify "
"and complete provisioning."
)
return ProvisionAccessResponse(
status="pending",
login_url=init_response.login_url,
message=message,
user_id=user_id,
requested_scopes=requested_scopes,
)
return ProvisionAccessResponse(
status="login_required",
@@ -174,10 +181,12 @@ def register_auth_tools(mcp: FastMCP) -> None:
title="Check Nextcloud Access Status",
description=(
"Check if Nextcloud access has been provisioned. "
"If a Login Flow is pending, this will poll for completion."
"If a Login Flow is pending, this will poll for completion. "
"Recommended polling interval: 5 seconds."
),
annotations=ToolAnnotations(
readOnlyHint=True,
idempotentHint=True,
openWorldHint=True,
),
)
+5 -72
View File
@@ -12,9 +12,6 @@ from datetime import datetime, timezone
from typing import Optional
from urllib.parse import urlencode
import jwt
from mcp.server.auth.middleware.auth_context import get_access_token
from mcp.server.auth.provider import AccessToken
from mcp.server.fastmcp import Context
from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field
@@ -23,80 +20,16 @@ from nextcloud_mcp_server.auth import require_scopes
from nextcloud_mcp_server.auth.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService
from nextcloud_mcp_server.auth.userinfo_routes import _query_idp_userinfo
# Re-export for backward compatibility — canonical location is auth.token_utils
from nextcloud_mcp_server.auth.token_utils import (
extract_user_id_from_token as extract_user_id_from_token, # noqa: PLC0414
)
from nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__)
async def extract_user_id_from_token(ctx: Context) -> str:
"""Extract user_id from the MCP access token (Flow 1).
Handles both JWT and opaque tokens:
- JWT: Decode and extract 'sub' claim
- Opaque: Call userinfo endpoint to get 'sub'
Args:
ctx: MCP context with access token
Returns:
user_id extracted from token, or "default_user" as fallback
"""
# Use MCP SDK's get_access_token() which uses contextvars
access_token: AccessToken | None = get_access_token()
if not access_token or not access_token.token:
logger.warning(" ✗ No access token found via get_access_token()")
return "default_user"
token = access_token.token
is_jwt = "." in token and token.count(".") >= 2
logger.info(f" Token type: {'JWT' if is_jwt else 'Opaque'}")
# Try JWT decode first
if is_jwt:
try:
payload = jwt.decode(token, options={"verify_signature": False})
user_id = payload.get("sub", "unknown")
logger.info(f" ✓ JWT decode successful: user_id={user_id}")
return user_id
except Exception as e:
logger.error(f" ✗ JWT decode failed: {type(e).__name__}: {e}")
# Opaque token - call userinfo endpoint
logger.info(" Opaque token detected, calling userinfo endpoint...")
try:
# Get userinfo endpoint from OIDC discovery
oidc_discovery_uri = os.getenv(
"OIDC_DISCOVERY_URI",
"http://localhost:8080/.well-known/openid-configuration",
)
async with nextcloud_httpx_client() as http_client:
discovery_response = await http_client.get(oidc_discovery_uri)
discovery_response.raise_for_status()
discovery = discovery_response.json()
userinfo_endpoint = discovery.get("userinfo_endpoint")
if userinfo_endpoint:
userinfo = await _query_idp_userinfo(token, userinfo_endpoint)
if userinfo:
user_id = userinfo.get("sub", "unknown")
logger.info(f" ✓ Userinfo query successful: user_id={user_id}")
return user_id
else:
logger.error(" ✗ Userinfo query failed")
else:
logger.error(" ✗ No userinfo_endpoint available")
except Exception as e:
logger.error(f" ✗ Userinfo query failed: {type(e).__name__}: {e}")
# Fallback
logger.warning(" Using fallback user_id: default_user")
return "default_user"
class ProvisioningStatus(BaseModel):
"""Status of Nextcloud provisioning for a user."""