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:
co-authored by
Claude Opus 4.6
parent
0a53aa5fcd
commit
f43343356e
@@ -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 .
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user