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
co-authored by Claude Opus 4.6
parent 0a53aa5fcd
commit f43343356e
17 changed files with 727 additions and 247 deletions
+1 -1
View File
@@ -177,7 +177,7 @@ jobs:
echo "MCP service is ready on port ${{ matrix.wait-port }}." echo "MCP service is ready on port ${{ matrix.wait-port }}."
- name: Verify OIDC configuration - name: Verify OIDC configuration
if: matrix.needs-playwright if: matrix.mode == 'oauth' || matrix.mode == 'login-flow'
run: | run: |
echo "=== OIDC Discovery ===" echo "=== OIDC Discovery ==="
curl -s http://localhost:8080/.well-known/openid-configuration | jq . curl -s http://localhost:8080/.well-known/openid-configuration | jq .
+11 -2
View File
@@ -14,6 +14,7 @@ from nextcloud_mcp_server.api.passwords import (
_extract_basic_auth, _extract_basic_auth,
_get_app_password_storage, _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 from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
logger = logging.getLogger(__name__) 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. This only updates the stored scopes, not the app password itself.
The app password remains valid; scope enforcement is application-level. 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") path_user_id = request.path_params.get("user_id")
if not path_user_id: if not path_user_id:
@@ -113,7 +119,7 @@ async def update_user_scopes(request: Request) -> JSONResponse:
{ {
"success": False, "success": False,
"error": f"Invalid scopes: {', '.join(invalid)}", "error": f"Invalid scopes: {', '.join(invalid)}",
"valid_scopes": ALL_SUPPORTED_SCOPES, "valid_scopes": sorted(ALL_SUPPORTED_SCOPES),
}, },
status_code=400, status_code=400,
) )
@@ -137,6 +143,9 @@ async def update_user_scopes(request: Request) -> JSONResponse:
scopes=scopes, scopes=scopes,
) )
# Invalidate scope cache so subsequent tool calls see updated scopes
invalidate_scope_cache(username)
return JSONResponse( return JSONResponse(
{ {
"success": True, "success": True,
@@ -159,6 +168,6 @@ async def list_supported_scopes(_: Request) -> JSONResponse:
return JSONResponse( return JSONResponse(
{ {
"success": True, "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() mcp_app = mcp.streamable_http_app()
async def _login_flow_cleanup_loop() -> None: 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: while True:
try: try:
storage = await get_shared_storage() storage = await get_shared_storage()
count = await storage.delete_expired_login_flow_sessions() count = await storage.delete_expired_login_flow_sessions()
if count: if count:
logger.info(f"Cleaned up {count} expired login flow sessions") 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: except Exception as e:
logger.warning(f"Login flow cleanup error: {e}") logger.warning(f"Login flow cleanup error: {e}")
await anyio.sleep(3600) # Every hour await anyio.sleep(3600) # Every hour
@@ -83,6 +83,7 @@ async def register_client(
scopes: str = "openid profile email", scopes: str = "openid profile email",
token_type: str | None = "Bearer", token_type: str | None = "Bearer",
resource_url: str | None = None, resource_url: str | None = None,
max_retries: int = 3,
) -> ClientInfo: ) -> ClientInfo:
""" """
Register a new OAuth client using RFC 7591 Dynamic Client Registration. 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). 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). 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 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: Returns:
ClientInfo with registration details ClientInfo with registration details
@@ -135,57 +137,91 @@ async def register_client(
logger.debug(f"Registration endpoint: {registration_endpoint}") logger.debug(f"Registration endpoint: {registration_endpoint}")
async with nextcloud_httpx_client(timeout=30.0) as client: async with nextcloud_httpx_client(timeout=30.0) as client:
try: for attempt in range(max_retries):
response = await client.post( try:
registration_endpoint, response = await client.post(
json=client_metadata, registration_endpoint,
headers={"Content-Type": "application/json"}, 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"
) )
else:
logger.warning("RFC 7592 fields missing - client deletion may not work")
return ClientInfo( if response.status_code == 429:
client_id=client_info["client_id"], # Rate limited - retry with exponential backoff
client_secret=client_info["client_secret"], if attempt < max_retries - 1:
client_id_issued_at=client_info.get( retry_after = int(response.headers.get("Retry-After", 2))
"client_id_issued_at", int(time.time()) wait_time = min(retry_after, 2**attempt)
), logger.warning(
client_secret_expires_at=client_info.get( f"Rate limited (429) registering client, "
"client_secret_expires_at", int(time.time()) + 3600 f"retrying in {wait_time}s (attempt {attempt + 1}/{max_retries})"
), )
redirect_uris=client_info.get("redirect_uris", redirect_uris), await anyio.sleep(wait_time)
registration_access_token=client_info.get("registration_access_token"), continue
registration_client_uri=client_info.get("registration_client_uri"), else:
) logger.error(
f"Failed to register client after {max_retries} attempts: Rate limited (429)"
)
response.raise_for_status()
except httpx.HTTPStatusError as e: response.raise_for_status()
logger.error(f"Failed to register client: HTTP {e.response.status_code}")
logger.error(f"Response: {e.response.text}") client_info = response.json()
raise logger.info(
except KeyError as e: f"Successfully registered client: {client_info.get('client_id')}"
logger.error(f"Invalid response from registration endpoint: missing {e}") )
raise ValueError(f"Invalid registration response: missing {e}") 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( async def delete_client(
+7 -3
View File
@@ -76,9 +76,13 @@ async def present_login_url(
logger.info("User cancelled login flow") logger.info("User cancelled login flow")
return "cancelled" return "cancelled"
except NotImplementedError as e: except NotImplementedError:
# Elicitation not supported by this client/SDK - fall back to message # Elicitation not supported by this client/SDK - fall back to message
logger.debug( logger.debug("Elicitation not available, returning URL in message")
f"Elicitation not available ({type(e).__name__}: {e}), 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" return "message_only"
+174 -63
View File
@@ -26,6 +26,7 @@ import secrets
import time import time
from base64 import urlsafe_b64encode from base64 import urlsafe_b64encode
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any
from urllib.parse import urlencode from urllib.parse import urlencode
from urllib.parse import urlparse as parse_url from urllib.parse import urlparse as parse_url
@@ -50,20 +51,21 @@ logger = logging.getLogger(__name__)
@dataclass @dataclass
class ProxyCodeEntry: 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_id: str
client_redirect_uri: str client_redirect_uri: str
client_state: str client_state: str
code_challenge: str code_challenge: str
code_challenge_method: 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) created_at: float = field(default_factory=time.time)
expires_at: float = 0.0 expires_at: float = field(default_factory=lambda: time.time() + 60)
def __post_init__(self):
if self.expires_at == 0.0:
self.expires_at = self.created_at + 60 # 60 second TTL
@property @property
def is_expired(self) -> bool: def is_expired(self) -> bool:
@@ -73,7 +75,11 @@ class ProxyCodeEntry:
# Server-side state for AS proxy authorize → callback mapping # Server-side state for AS proxy authorize → callback mapping
@dataclass @dataclass
class ASProxySession: 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_id: str
client_redirect_uri: str client_redirect_uri: str
@@ -82,11 +88,7 @@ class ASProxySession:
code_challenge_method: str code_challenge_method: str
requested_scopes: str requested_scopes: str
created_at: float = field(default_factory=time.time) created_at: float = field(default_factory=time.time)
expires_at: float = 0.0 expires_at: float = field(default_factory=lambda: time.time() + 600)
def __post_init__(self):
if self.expires_at == 0.0:
self.expires_at = self.created_at + 600 # 10 minute TTL
@property @property
def is_expired(self) -> bool: def is_expired(self) -> bool:
@@ -97,6 +99,30 @@ class ASProxySession:
_proxy_codes: dict[str, ProxyCodeEntry] = {} _proxy_codes: dict[str, ProxyCodeEntry] = {}
_as_proxy_sessions: dict[str, ASProxySession] = {} _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: def _cleanup_expired_proxy_codes() -> None:
"""Remove expired proxy codes and sessions.""" """Remove expired proxy codes and sessions."""
@@ -295,11 +321,8 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
status_code=500, status_code=500,
) )
async with nextcloud_httpx_client() as http_client: discovery = await _get_cached_discovery(discovery_url)
response = await http_client.get(discovery_url) authorization_endpoint = discovery["authorization_endpoint"]
response.raise_for_status()
discovery = response.json()
authorization_endpoint = discovery["authorization_endpoint"]
# Replace internal Docker hostname with public URL for browser access # Replace internal Docker hostname with public URL for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
@@ -424,11 +447,8 @@ async def oauth_authorize_nextcloud(
status_code=500, status_code=500,
) )
async with nextcloud_httpx_client() as http_client: discovery = await _get_cached_discovery(discovery_url)
response = await http_client.get(discovery_url) authorization_endpoint = discovery["authorization_endpoint"]
response.raise_for_status()
discovery = response.json()
authorization_endpoint = discovery["authorization_endpoint"]
# Fix internal hostname for browser access # Fix internal hostname for browser access
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") 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" callback_uri = f"{mcp_server_url}/oauth/callback"
discovery_url = oauth_config.get("discovery_url") discovery_url = oauth_config.get("discovery_url")
async with nextcloud_httpx_client() as http_client: if not discovery_url:
response = await http_client.get(discovery_url) return JSONResponse(
response.raise_for_status() {
discovery = response.json() "error": "server_error",
token_endpoint = discovery["token_endpoint"] "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 # Build token exchange params
token_params = { token_params = {
@@ -797,16 +823,32 @@ async def _oauth_callback_as_proxy(
mcp_server_client_secret = os.getenv( mcp_server_client_secret = os.getenv(
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret") "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"] mcp_server_url = oauth_config["mcp_server_url"]
callback_uri = f"{mcp_server_url}/oauth/callback" callback_uri = f"{mcp_server_url}/oauth/callback"
# Discover token endpoint # Discover token endpoint
discovery_url = oauth_config.get("discovery_url") discovery_url = oauth_config.get("discovery_url")
async with nextcloud_httpx_client() as http_client: if not discovery_url:
response = await http_client.get(discovery_url) return JSONResponse(
response.raise_for_status() {
discovery = response.json() "error": "server_error",
token_endpoint = discovery["token_endpoint"] "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) # Exchange auth code with Nextcloud (server-side, confidential client, no PKCE)
token_params = { token_params = {
@@ -942,8 +984,17 @@ async def _token_authorization_code(request: Request, form) -> JSONResponse:
status_code=400, status_code=400,
) )
# Validate client_id matches # Validate client_id (required per RFC 6749 Section 4.1.3)
if client_id and client_id != entry.client_id: 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( return JSONResponse(
{ {
"error": "invalid_grant", "error": "invalid_grant",
@@ -952,8 +1003,17 @@ async def _token_authorization_code(request: Request, form) -> JSONResponse:
status_code=400, status_code=400,
) )
# Validate redirect_uri matches # Validate redirect_uri (required per RFC 6749 Section 4.1.3)
if redirect_uri and redirect_uri != entry.client_redirect_uri: 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( return JSONResponse(
{ {
"error": "invalid_grant", "error": "invalid_grant",
@@ -962,26 +1022,29 @@ async def _token_authorization_code(request: Request, form) -> JSONResponse:
status_code=400, status_code=400,
) )
# Verify PKCE # Verify PKCE (always required — oauth_authorize mandates code_challenge)
if entry.code_challenge: assert entry.code_challenge, (
if not code_verifier: "code_challenge must be set (enforced by oauth_authorize)"
return JSONResponse( ) # noqa: S101
{
"error": "invalid_grant",
"error_description": "code_verifier is required (PKCE)",
},
status_code=400,
)
if not _verify_pkce_s256(code_verifier, entry.code_challenge): if not code_verifier:
logger.warning(f"PKCE verification failed for client {entry.client_id}") return JSONResponse(
return JSONResponse( {
{ "error": "invalid_grant",
"error": "invalid_grant", "error_description": "code_verifier is required (PKCE)",
"error_description": "PKCE verification failed", },
}, status_code=400,
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( logger.info(
f"AS proxy token: Returning Nextcloud token for client {entry.client_id}" 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 = os.getenv(
"MCP_SERVER_CLIENT_SECRET", oauth_config.get("client_secret") "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"] mcp_server_url = oauth_config["mcp_server_url"]
# Discover token endpoint # Discover token endpoint
discovery_url = oauth_config.get("discovery_url") discovery_url = oauth_config.get("discovery_url")
async with nextcloud_httpx_client() as http_client: if not discovery_url:
response = await http_client.get(discovery_url) return JSONResponse(
response.raise_for_status() {
discovery = response.json() "error": "server_error",
token_endpoint = discovery["token_endpoint"] "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 # Proxy refresh request to Nextcloud
token_params = { token_params = {
@@ -1095,8 +1174,40 @@ async def oauth_register_proxy(request: Request) -> JSONResponse:
oauth_config = oauth_ctx["config"] oauth_config = oauth_ctx["config"]
nextcloud_host = oauth_config["nextcloud_host"] nextcloud_host = oauth_config["nextcloud_host"]
# Proxy DCR to Nextcloud # Rate limit DCR requests per client IP
registration_endpoint = f"{nextcloud_host}/apps/oidc/register" 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}") logger.info(f"DCR proxy: Forwarding registration to {registration_endpoint}")
@@ -1,6 +1,7 @@
"""Scope-based authorization for MCP tools.""" """Scope-based authorization for MCP tools."""
import logging import logging
import time
from functools import wraps from functools import wraps
from typing import Any, Callable 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( if get_settings().enable_login_flow and not set(required_scopes).issubset(
IDENTITY_ONLY_SCOPES 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, extract_user_id_from_token,
) )
@@ -476,9 +477,18 @@ def discover_all_scopes(mcp) -> list[str]:
# ── Login Flow v2 helpers ──────────────────────────────────────────────── # ── 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: 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: Returns:
- list[str]: Specific scopes granted - 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 Storage/infrastructure exceptions propagate to the caller
(require_scopes decorator) for proper MCP error responses. (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() storage = await get_shared_storage()
data = await storage.get_app_password_with_scopes(user_id) data = await storage.get_app_password_with_scopes(user_id)
if data is None: if data is None:
return None result = None
if data["scopes"] is None: elif data["scopes"] is None:
return "all" result = "all"
return data["scopes"] 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 app_password: Nextcloud app password to encrypt and store
scopes: List of granted scopes (None = all scopes allowed) scopes: List of granted scopes (None = all scopes allowed)
username: Nextcloud loginName from Login Flow v2 response username: Nextcloud loginName from Login Flow v2 response
Raises:
ValueError: If any scope is not in ALL_SUPPORTED_SCOPES
""" """
if not self._initialized: if not self._initialized:
await self.initialize() await self.initialize()
@@ -1503,6 +1506,16 @@ class RefreshTokenStorage:
"Set TOKEN_ENCRYPTION_KEY for app password storage." "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()) encrypted_password = self.cipher.encrypt(app_password.encode())
scopes_json = json.dumps(scopes) if scopes is not None else None scopes_json = json.dumps(scopes) if scopes is not None else None
now = int(time.time()) 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 import ssl
from dataclasses import dataclass from dataclasses import dataclass
from enum import Enum from enum import Enum
from typing import Any, Optional from typing import Any
class DeploymentMode(Enum): class DeploymentMode(Enum):
@@ -169,32 +169,32 @@ class Settings:
# Optional: If not set, mode is auto-detected from other settings # Optional: If not set, mode is auto-detected from other settings
# Valid values: single_user_basic, multi_user_basic, oauth_single_audience, # Valid values: single_user_basic, multi_user_basic, oauth_single_audience,
# oauth_token_exchange, smithery # oauth_token_exchange, smithery
deployment_mode: Optional[str] = None deployment_mode: str | None = None
# OAuth/OIDC settings # OAuth/OIDC settings
oidc_discovery_url: Optional[str] = None oidc_discovery_url: str | None = None
oidc_client_id: Optional[str] = None oidc_client_id: str | None = None
oidc_client_secret: Optional[str] = None oidc_client_secret: str | None = None
oidc_issuer: Optional[str] = None oidc_issuer: str | None = None
# Nextcloud settings # Nextcloud settings
nextcloud_host: Optional[str] = None nextcloud_host: str | None = None
nextcloud_username: Optional[str] = None nextcloud_username: str | None = None
nextcloud_password: Optional[str] = None nextcloud_password: str | None = None
nextcloud_app_password: Optional[str] = None # Preferred over nextcloud_password nextcloud_app_password: str | None = None # Preferred over nextcloud_password
# Nextcloud SSL/TLS settings # Nextcloud SSL/TLS settings
nextcloud_verify_ssl: bool = True 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) # ADR-005: Token Audience Validation (required for OAuth mode)
nextcloud_mcp_server_url: Optional[str] = None # MCP server URL (used as audience) nextcloud_mcp_server_url: str | None = None # MCP server URL (used as audience)
nextcloud_resource_uri: Optional[str] = None # Nextcloud resource identifier nextcloud_resource_uri: str | None = None # Nextcloud resource identifier
# Token verification endpoints # Token verification endpoints
jwks_uri: Optional[str] = None jwks_uri: str | None = None
introspection_uri: Optional[str] = None introspection_uri: str | None = None
userinfo_uri: Optional[str] = None userinfo_uri: str | None = None
# Progressive Consent settings (always enabled - no flag needed) # Progressive Consent settings (always enabled - no flag needed)
enable_token_exchange: bool = False enable_token_exchange: bool = False
@@ -218,8 +218,8 @@ class Settings:
# TOKEN_STORAGE_DB: Path to SQLite database for persistent storage. # TOKEN_STORAGE_DB: Path to SQLite database for persistent storage.
# Used for webhook tracking (all modes) and OAuth token storage. # Used for webhook tracking (all modes) and OAuth token storage.
# Defaults to /tmp/tokens.db # Defaults to /tmp/tokens.db
token_encryption_key: Optional[str] = None token_encryption_key: str | None = None
token_storage_db: Optional[str] = None token_storage_db: str | None = None
# Vector sync settings (ADR-007) # Vector sync settings (ADR-007)
vector_sync_enabled: bool = False vector_sync_enabled: bool = False
@@ -229,19 +229,19 @@ class Settings:
vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery vector_sync_user_poll_interval: int = 60 # seconds - OAuth mode user discovery
# Qdrant settings (mutually exclusive modes) # Qdrant settings (mutually exclusive modes)
qdrant_url: Optional[str] = None # Network mode: http://qdrant:6333 qdrant_url: str | None = None # Network mode: http://qdrant:6333
qdrant_location: Optional[str] = None # Local mode: :memory: or /path/to/data qdrant_location: str | None = None # Local mode: :memory: or /path/to/data
qdrant_api_key: Optional[str] = None qdrant_api_key: str | None = None
qdrant_collection: str = "nextcloud_content" qdrant_collection: str = "nextcloud_content"
# Ollama settings (for embeddings) # Ollama settings (for embeddings)
ollama_base_url: Optional[str] = None ollama_base_url: str | None = None
ollama_embedding_model: str = "nomic-embed-text" ollama_embedding_model: str = "nomic-embed-text"
ollama_verify_ssl: bool = True ollama_verify_ssl: bool = True
# OpenAI settings (for embeddings) # OpenAI settings (for embeddings)
openai_api_key: Optional[str] = None openai_api_key: str | None = None
openai_base_url: Optional[str] = None openai_base_url: str | None = None
openai_embedding_model: str = "text-embedding-3-small" openai_embedding_model: str = "text-embedding-3-small"
# Document chunking settings (for vector embeddings) # Document chunking settings (for vector embeddings)
@@ -251,7 +251,7 @@ class Settings:
# Observability settings # Observability settings
metrics_enabled: bool = True metrics_enabled: bool = True
metrics_port: int = 9090 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_exporter_verify_ssl: bool = False
otel_service_name: str = "nextcloud-mcp-server" otel_service_name: str = "nextcloud-mcp-server"
otel_traces_sampler: str = "always_on" otel_traces_sampler: str = "always_on"
+1 -1
View File
@@ -272,7 +272,7 @@ async def _get_client_from_login_flow(
Raises: Raises:
ProvisioningRequiredError: If no stored app password exists 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, 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") new_scopes: list[str] | None = Field(None, description="Updated scope set")
# All supported application-level scopes # All supported application-level scopes (frozenset for O(1) membership tests)
ALL_SUPPORTED_SCOPES = ( ALL_SUPPORTED_SCOPES: frozenset[str] = frozenset(
"notes:read", {
"notes:write", "notes:read",
"calendar:read", "notes:write",
"calendar:write", "calendar:read",
"todo:read", "calendar:write",
"todo:write", "todo:read",
"contacts:read", "todo:write",
"contacts:write", "contacts:read",
"files:read", "contacts:write",
"files:write", "files:read",
"tables:read", "files:write",
"tables:write", "tables:read",
"deck:read", "tables:write",
"deck:write", "deck:read",
"cookbook:read", "deck:write",
"cookbook:write", "cookbook:read",
"sharing:read", "cookbook:write",
"sharing:write", "sharing:read",
"news:read", "sharing:write",
"news: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.login_flow import LoginFlowV2Client
from nextcloud_mcp_server.auth.scope_authorization import require_scopes 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.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.config import get_nextcloud_ssl_verify, get_settings
from nextcloud_mcp_server.models.auth import ( from nextcloud_mcp_server.models.auth import (
ALL_SUPPORTED_SCOPES, ALL_SUPPORTED_SCOPES,
@@ -23,7 +24,6 @@ from nextcloud_mcp_server.models.auth import (
ProvisionStatusResponse, ProvisionStatusResponse,
UpdateScopesResponse, UpdateScopesResponse,
) )
from nextcloud_mcp_server.server.oauth_tools import extract_user_id_from_token
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -92,7 +92,7 @@ def register_auth_tools(mcp: FastMCP) -> None:
return ProvisionAccessResponse( return ProvisionAccessResponse(
status="error", status="error",
message=f"Invalid scopes: {', '.join(invalid_scopes)}. " message=f"Invalid scopes: {', '.join(invalid_scopes)}. "
f"Valid scopes: {', '.join(ALL_SUPPORTED_SCOPES)}", f"Valid scopes: {', '.join(sorted(ALL_SUPPORTED_SCOPES))}",
success=False, success=False,
) )
@@ -160,6 +160,13 @@ def register_auth_tools(mcp: FastMCP) -> None:
"Login acknowledged. Call nc_auth_check_status to verify " "Login acknowledged. Call nc_auth_check_status to verify "
"and complete provisioning." "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( return ProvisionAccessResponse(
status="login_required", status="login_required",
@@ -174,10 +181,12 @@ def register_auth_tools(mcp: FastMCP) -> None:
title="Check Nextcloud Access Status", title="Check Nextcloud Access Status",
description=( description=(
"Check if Nextcloud access has been provisioned. " "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( annotations=ToolAnnotations(
readOnlyHint=True, readOnlyHint=True,
idempotentHint=True,
openWorldHint=True, openWorldHint=True,
), ),
) )
+5 -72
View File
@@ -12,9 +12,6 @@ from datetime import datetime, timezone
from typing import Optional from typing import Optional
from urllib.parse import urlencode 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.server.fastmcp import Context
from mcp.types import ToolAnnotations from mcp.types import ToolAnnotations
from pydantic import BaseModel, Field 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.astrolabe_client import AstrolabeClient
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.auth.token_broker import TokenBrokerService 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 nextcloud_mcp_server.config import get_settings
from ..http import nextcloud_httpx_client
logger = logging.getLogger(__name__) 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): class ProvisioningStatus(BaseModel):
"""Status of Nextcloud provisioning for a user.""" """Status of Nextcloud provisioning for a user."""
View File
+243
View File
@@ -0,0 +1,243 @@
"""Unit tests for access.py REST API endpoints.
Tests the REST API endpoints for user access and scope management:
- GET /api/v1/users/{user_id}/access - Get user's provisioned access and scopes
- PATCH /api/v1/users/{user_id}/scopes - Update user's application-level scopes
- GET /api/v1/scopes - List all supported scopes
"""
import base64
import tempfile
from pathlib import Path
import pytest
from cryptography.fernet import Fernet
from starlette.applications import Starlette
from starlette.routing import Route
from starlette.testclient import TestClient
from nextcloud_mcp_server.api.access import (
get_user_access,
list_supported_scopes,
update_user_scopes,
)
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
from nextcloud_mcp_server.models.auth import ALL_SUPPORTED_SCOPES
pytestmark = pytest.mark.unit
@pytest.fixture
def encryption_key():
"""Generate a test encryption key."""
return Fernet.generate_key().decode()
@pytest.fixture
async def temp_storage(encryption_key):
"""Create temporary storage instance with encryption for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
db_path = Path(tmpdir) / "test_access.db"
storage = RefreshTokenStorage(
db_path=str(db_path), encryption_key=encryption_key
)
await storage.initialize()
yield storage
def create_basic_auth_header(username: str, password: str) -> str:
"""Create BasicAuth header value."""
credentials = f"{username}:{password}"
encoded = base64.b64encode(credentials.encode()).decode()
return f"Basic {encoded}"
def create_test_app(storage):
"""Create a test Starlette app with the access endpoints."""
app = Starlette(
routes=[
Route(
"/api/v1/users/{user_id}/access",
get_user_access,
methods=["GET"],
),
Route(
"/api/v1/users/{user_id}/scopes",
update_user_scopes,
methods=["PATCH"],
),
Route(
"/api/v1/scopes",
list_supported_scopes,
methods=["GET"],
),
],
)
app.state.storage = storage
return app
class TestGetUserAccess:
"""Tests for GET /api/v1/users/{user_id}/access."""
async def test_not_provisioned(self, temp_storage):
"""Returns provisioned=False when no app password stored."""
app = create_test_app(temp_storage)
client = TestClient(app)
resp = client.get(
"/api/v1/users/alice/access",
headers={"Authorization": create_basic_auth_header("alice", "pw")},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["provisioned"] is False
assert data["scopes"] is None
async def test_provisioned_with_scopes(self, temp_storage):
"""Returns provisioned=True with scopes when app password exists."""
await temp_storage.store_app_password_with_scopes(
user_id="alice",
app_password="test-app-pw",
scopes=["notes:read", "calendar:write"],
username="alice_nc",
)
app = create_test_app(temp_storage)
client = TestClient(app)
resp = client.get(
"/api/v1/users/alice/access",
headers={"Authorization": create_basic_auth_header("alice", "pw")},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert data["provisioned"] is True
assert set(data["scopes"]) == {"notes:read", "calendar:write"}
assert data["username"] == "alice_nc"
async def test_missing_auth_header(self, temp_storage):
"""Returns 401 when no Authorization header."""
app = create_test_app(temp_storage)
client = TestClient(app)
resp = client.get("/api/v1/users/alice/access")
assert resp.status_code == 401
async def test_user_id_mismatch(self, temp_storage):
"""Returns 403 when path user_id doesn't match auth credentials."""
app = create_test_app(temp_storage)
client = TestClient(app)
resp = client.get(
"/api/v1/users/alice/access",
headers={"Authorization": create_basic_auth_header("bob", "pw")},
)
assert resp.status_code == 403
class TestUpdateUserScopes:
"""Tests for PATCH /api/v1/users/{user_id}/scopes."""
async def test_update_valid_scopes(self, temp_storage):
"""Successfully updates scopes for a provisioned user."""
await temp_storage.store_app_password_with_scopes(
user_id="alice",
app_password="test-app-pw",
scopes=["notes:read"],
username="alice_nc",
)
app = create_test_app(temp_storage)
client = TestClient(app)
resp = client.patch(
"/api/v1/users/alice/scopes",
headers={"Authorization": create_basic_auth_header("alice", "pw")},
json={"scopes": ["notes:read", "notes:write", "calendar:read"]},
)
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert set(data["scopes"]) == {"notes:read", "notes:write", "calendar:read"}
async def test_invalid_scopes(self, temp_storage):
"""Returns 400 for invalid scope names."""
await temp_storage.store_app_password_with_scopes(
user_id="alice",
app_password="test-app-pw",
scopes=["notes:read"],
)
app = create_test_app(temp_storage)
client = TestClient(app)
resp = client.patch(
"/api/v1/users/alice/scopes",
headers={"Authorization": create_basic_auth_header("alice", "pw")},
json={"scopes": ["notes:read", "invalid:scope"]},
)
assert resp.status_code == 400
data = resp.json()
assert data["success"] is False
assert "invalid:scope" in data["error"]
async def test_user_not_provisioned(self, temp_storage):
"""Returns 404 when user has no app password."""
app = create_test_app(temp_storage)
client = TestClient(app)
resp = client.patch(
"/api/v1/users/alice/scopes",
headers={"Authorization": create_basic_auth_header("alice", "pw")},
json={"scopes": ["notes:read"]},
)
assert resp.status_code == 404
data = resp.json()
assert data["success"] is False
async def test_missing_scopes_field(self, temp_storage):
"""Returns 400 when scopes field is missing from body."""
app = create_test_app(temp_storage)
client = TestClient(app)
resp = client.patch(
"/api/v1/users/alice/scopes",
headers={"Authorization": create_basic_auth_header("alice", "pw")},
json={"something_else": True},
)
assert resp.status_code == 400
async def test_invalid_json_body(self, temp_storage):
"""Returns 400 for invalid JSON body."""
app = create_test_app(temp_storage)
client = TestClient(app)
resp = client.patch(
"/api/v1/users/alice/scopes",
headers={
"Authorization": create_basic_auth_header("alice", "pw"),
"Content-Type": "application/json",
},
content=b"not json",
)
assert resp.status_code == 400
class TestListSupportedScopes:
"""Tests for GET /api/v1/scopes."""
async def test_returns_all_scopes(self, temp_storage):
"""Returns all supported scopes sorted."""
app = create_test_app(temp_storage)
client = TestClient(app)
resp = client.get("/api/v1/scopes")
assert resp.status_code == 200
data = resp.json()
assert data["success"] is True
assert set(data["scopes"]) == ALL_SUPPORTED_SCOPES
# Verify it's sorted
assert data["scopes"] == sorted(data["scopes"])
@@ -10,11 +10,20 @@ import pytest
from nextcloud_mcp_server.auth.scope_authorization import ( from nextcloud_mcp_server.auth.scope_authorization import (
_get_stored_scopes, _get_stored_scopes,
_scope_cache,
) )
pytestmark = pytest.mark.unit pytestmark = pytest.mark.unit
@pytest.fixture(autouse=True)
def clear_scope_cache():
"""Clear scope cache before each test."""
_scope_cache.clear()
yield
_scope_cache.clear()
async def test_get_stored_scopes_with_scopes(): async def test_get_stored_scopes_with_scopes():
"""Test getting specific scopes from storage.""" """Test getting specific scopes from storage."""
mock_storage = AsyncMock() mock_storage = AsyncMock()