From c069d78f80899d91ad21f4f9234ce19c3af72eaa Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Wed, 22 Oct 2025 06:21:16 +0200 Subject: [PATCH] feat: Initialize JWT-scoped tools --- .../00-setup-trusted-domains.sh | 5 + ...ndar-app.sh => 10-install-calendar-app.sh} | 0 ...acts-app.sh => 10-install-contacts-app.sh} | 0 ...book-app.sh => 10-install-cookbook-app.sh} | 0 ...all-deck-app.sh => 10-install-deck-app.sh} | 0 ...l-notes-app.sh => 10-install-notes-app.sh} | 0 .../post-installation/10-install-oidc-app.sh | 14 + ...tables-app.sh => 10-install-tables-app.sh} | 0 ...idc-app.sh => 10-install-user_oidc-app.sh} | 13 +- .../90-create-jwt-oauth-client.sh | 100 ++++ docker-compose.yml | 28 +- nextcloud_mcp_server/app.py | 293 +++++++--- nextcloud_mcp_server/auth/__init__.py | 16 + .../auth/client_registration.py | 3 + .../auth/scope_authorization.py | 254 +++++++++ nextcloud_mcp_server/auth/token_verifier.py | 143 ++++- nextcloud_mcp_server/server/calendar.py | 17 + nextcloud_mcp_server/server/contacts.py | 8 + nextcloud_mcp_server/server/cookbook.py | 14 + nextcloud_mcp_server/server/deck.py | 26 + nextcloud_mcp_server/server/notes.py | 16 +- nextcloud_mcp_server/server/sharing.py | 6 + nextcloud_mcp_server/server/tables.py | 7 + nextcloud_mcp_server/server/webdav.py | 12 + pyproject.toml | 1 + tests/conftest.py | 531 +++++++++++++++++- tests/server/test_jwt_tokens.py | 211 +++++++ tests/server/test_mcp_oauth_jwt.py | 246 ++++++++ tests/server/test_scope_authorization.py | 392 +++++++++++++ uv.lock | 157 ++++++ 30 files changed, 2402 insertions(+), 111 deletions(-) create mode 100755 app-hooks/post-installation/00-setup-trusted-domains.sh rename app-hooks/post-installation/{install-calendar-app.sh => 10-install-calendar-app.sh} (100%) rename app-hooks/post-installation/{install-contacts-app.sh => 10-install-contacts-app.sh} (100%) rename app-hooks/post-installation/{install-cookbook-app.sh => 10-install-cookbook-app.sh} (100%) rename app-hooks/post-installation/{install-deck-app.sh => 10-install-deck-app.sh} (100%) rename app-hooks/post-installation/{install-notes-app.sh => 10-install-notes-app.sh} (100%) create mode 100755 app-hooks/post-installation/10-install-oidc-app.sh rename app-hooks/post-installation/{install-tables-app.sh => 10-install-tables-app.sh} (100%) rename app-hooks/post-installation/{install-oidc-app.sh => 10-install-user_oidc-app.sh} (53%) create mode 100755 app-hooks/post-installation/90-create-jwt-oauth-client.sh create mode 100644 nextcloud_mcp_server/auth/scope_authorization.py create mode 100644 tests/server/test_jwt_tokens.py create mode 100644 tests/server/test_mcp_oauth_jwt.py create mode 100644 tests/server/test_scope_authorization.py diff --git a/app-hooks/post-installation/00-setup-trusted-domains.sh b/app-hooks/post-installation/00-setup-trusted-domains.sh new file mode 100755 index 0000000..be04409 --- /dev/null +++ b/app-hooks/post-installation/00-setup-trusted-domains.sh @@ -0,0 +1,5 @@ +#!/bin/bash + +set -euox pipefail + +php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal diff --git a/app-hooks/post-installation/install-calendar-app.sh b/app-hooks/post-installation/10-install-calendar-app.sh similarity index 100% rename from app-hooks/post-installation/install-calendar-app.sh rename to app-hooks/post-installation/10-install-calendar-app.sh diff --git a/app-hooks/post-installation/install-contacts-app.sh b/app-hooks/post-installation/10-install-contacts-app.sh similarity index 100% rename from app-hooks/post-installation/install-contacts-app.sh rename to app-hooks/post-installation/10-install-contacts-app.sh diff --git a/app-hooks/post-installation/install-cookbook-app.sh b/app-hooks/post-installation/10-install-cookbook-app.sh similarity index 100% rename from app-hooks/post-installation/install-cookbook-app.sh rename to app-hooks/post-installation/10-install-cookbook-app.sh diff --git a/app-hooks/post-installation/install-deck-app.sh b/app-hooks/post-installation/10-install-deck-app.sh similarity index 100% rename from app-hooks/post-installation/install-deck-app.sh rename to app-hooks/post-installation/10-install-deck-app.sh diff --git a/app-hooks/post-installation/install-notes-app.sh b/app-hooks/post-installation/10-install-notes-app.sh similarity index 100% rename from app-hooks/post-installation/install-notes-app.sh rename to app-hooks/post-installation/10-install-notes-app.sh diff --git a/app-hooks/post-installation/10-install-oidc-app.sh b/app-hooks/post-installation/10-install-oidc-app.sh new file mode 100755 index 0000000..9049917 --- /dev/null +++ b/app-hooks/post-installation/10-install-oidc-app.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +set -euox pipefail + +echo "Installing and configuring OIDC app for testing..." + +# Enable the OIDC Identity Provider app +php /var/www/html/occ app:enable oidc + +# Configure OIDC Identity Provider with dynamic client registration enabled +php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true' +php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean + +echo "OIDC app installed and configured successfully" diff --git a/app-hooks/post-installation/install-tables-app.sh b/app-hooks/post-installation/10-install-tables-app.sh similarity index 100% rename from app-hooks/post-installation/install-tables-app.sh rename to app-hooks/post-installation/10-install-tables-app.sh diff --git a/app-hooks/post-installation/install-oidc-app.sh b/app-hooks/post-installation/10-install-user_oidc-app.sh similarity index 53% rename from app-hooks/post-installation/install-oidc-app.sh rename to app-hooks/post-installation/10-install-user_oidc-app.sh index 47053a1..ec6caf2 100755 --- a/app-hooks/post-installation/install-oidc-app.sh +++ b/app-hooks/post-installation/10-install-user_oidc-app.sh @@ -2,21 +2,12 @@ set -euox pipefail -echo "Installing and configuring OIDC apps for testing..." - -# Enable the OIDC Identity Provider app -php /var/www/html/occ app:enable oidc +echo "Installing and configuring user_oidc app for testing..." # Enable the user_oidc app (OIDC client for bearer token validation) php /var/www/html/occ app:enable user_oidc -patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch - -# Configure OIDC Identity Provider with dynamic client registration enabled -php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true' -php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean - # Configure user_oidc to validate bearer tokens from the OIDC Identity Provider php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean -echo "OIDC apps installed and configured successfully" +patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch diff --git a/app-hooks/post-installation/90-create-jwt-oauth-client.sh b/app-hooks/post-installation/90-create-jwt-oauth-client.sh new file mode 100755 index 0000000..6ff5b4c --- /dev/null +++ b/app-hooks/post-installation/90-create-jwt-oauth-client.sh @@ -0,0 +1,100 @@ +#!/bin/bash +set -e + +echo "=== JWT OAuth Client Setup ===" +echo "Installing and configuring OIDC app for JWT tokens..." + +# Wait for Nextcloud to be fully initialized +sleep 5 + +# Install OIDC app if not already installed +if ! php /var/www/html/occ app:list | grep -q "oidc"; then + echo "Installing OIDC app..." + php /var/www/html/occ app:install oidc +else + echo "OIDC app already installed" +fi + +# Enable the app +php /var/www/html/occ app:enable oidc + +# Check if JWT client already exists +# Use a location that www-data user owns +CLIENT_DIR="/var/www/html/.oauth-jwt" +CLIENT_FILE="$CLIENT_DIR/nextcloud_oauth_client.json" + +if [ -f "$CLIENT_FILE" ]; then + echo "JWT OAuth client already exists at $CLIENT_FILE" + exit 0 +fi + +# Create directory owned by www-data +mkdir -p "$CLIENT_DIR" + +# Create JWT OAuth client with proper scopes +echo "Creating JWT OAuth client..." + +# The redirect URI for the MCP server +REDIRECT_URI="http://127.0.0.1:8002/oauth/callback" + +# Create the client with JWT token type +OUTPUT=$(php /var/www/html/occ oidc:create \ + --token_type=jwt \ + --allowed_scopes="openid profile email nc:read nc:write" \ + "Nextcloud MCP Server JWT" \ + "$REDIRECT_URI") + +echo "Client creation output:" +echo "$OUTPUT" + +# Parse the JSON output to extract client_id, client_secret, and issued_at +# Output format is JSON +CLIENT_ID=$(echo "$OUTPUT" | grep '"client_id"' | sed 's/.*"client_id": "\([^"]*\)".*/\1/') +CLIENT_SECRET=$(echo "$OUTPUT" | grep '"client_secret"' | sed 's/.*"client_secret": "\([^"]*\)".*/\1/') +ISSUED_AT=$(echo "$OUTPUT" | grep '"issued_at"' | sed 's/.*"issued_at": \([0-9]*\).*/\1/') + +if [ -z "$CLIENT_ID" ] || [ -z "$CLIENT_SECRET" ]; then + echo "ERROR: Failed to parse client credentials from output" + echo "Output was: $OUTPUT" + exit 1 +fi + +# Use issued_at if available, otherwise use current timestamp +if [ -z "$ISSUED_AT" ]; then + ISSUED_AT=$(date +%s) +fi + +# Set expiration to 10 years in the future (JWT clients don't expire like DCR clients) +EXPIRES_AT=$((ISSUED_AT + 315360000)) + +echo "Successfully created JWT client:" +echo " Client ID: ${CLIENT_ID:0:16}..." +echo " Client Secret: [hidden]" + +# Create the credentials file in the format expected by the MCP server +# This matches the format from ClientInfo.to_dict() in client_registration.py +cat > "$CLIENT_FILE" << EOF +{ + "client_id": "$CLIENT_ID", + "client_secret": "$CLIENT_SECRET", + "client_id_issued_at": $ISSUED_AT, + "client_secret_expires_at": $EXPIRES_AT, + "redirect_uris": ["$REDIRECT_URI"] +} +EOF + +echo "Credentials saved to $CLIENT_FILE" + +# Also save to environment variable format for easy access +cat > "$CLIENT_DIR/client_env.sh" << EOF +export NEXTCLOUD_OIDC_CLIENT_ID="$CLIENT_ID" +export NEXTCLOUD_OIDC_CLIENT_SECRET="$CLIENT_SECRET" +export NEXTCLOUD_OIDC_SCOPES="openid profile email nc:read nc:write" +EOF + +chmod 600 "$CLIENT_DIR/client_env.sh" + +echo "=== JWT OAuth Client Setup Complete ===" +echo "Client credentials are available at:" +echo " JSON: $CLIENT_FILE" +echo " ENV: $CLIENT_DIR/client_env.sh" diff --git a/docker-compose.yml b/docker-compose.yml index 1ef73d4..9d37f85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -73,10 +73,36 @@ services: - NEXTCLOUD_HOST=http://app:80 - NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8001 - NEXTCLOUD_PUBLIC_ISSUER_URL=http://127.0.0.1:8080 - # No USERNAME/PASSWORD - will use OAuth + - NEXTCLOUD_OIDC_CLIENT_STORAGE=/app/.oauth/nextcloud_oauth_client.json + - NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write + # No USERNAME/PASSWORD - will use OAuth with Dynamic Client Registration + # Client credentials will be registered and stored in volume on first startup volumes: - oauth-client-storage:/app/.oauth + mcp-oauth-jwt: + build: . + command: ["--transport", "streamable-http", "--oauth", "--port", "8002"] + restart: always + depends_on: + - app + ports: + - 127.0.0.1:8002:8002 + environment: + #- NEXTCLOUD_HOST=http://app:80 + - NEXTCLOUD_HOST=http://host.docker.internal:8080 + - NEXTCLOUD_MCP_SERVER_URL=http://127.0.0.1:8002 + - NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080 + - NEXTCLOUD_OIDC_CLIENT_STORAGE=/var/www/html/.oauth-jwt/nextcloud_oauth_client.json + - NEXTCLOUD_OIDC_SCOPES=openid profile email nc:read nc:write + # No USERNAME/PASSWORD - will use OAuth with JWT tokens + # Client credentials auto-generated by app container post-installation hook + volumes: + # NOTE: JWT-enabled OIDC client credentials created during nextcloud installation scripts + - nextcloud:/var/www/html:ro + extra_hosts: + - host.docker.internal:host-gateway + volumes: nextcloud: db: diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 21939a4..2997729 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -11,9 +11,15 @@ from mcp.server.auth.settings import AuthSettings from mcp.server.fastmcp import Context, FastMCP from pydantic import AnyHttpUrl from starlette.applications import Starlette -from starlette.routing import Mount +from starlette.responses import JSONResponse +from starlette.routing import Mount, Route -from nextcloud_mcp_server.auth import NextcloudTokenVerifier, load_or_register_client +from nextcloud_mcp_server.auth import ( + InsufficientScopeError, + NextcloudTokenVerifier, + get_access_token_scopes, + has_required_scopes, +) from nextcloud_mcp_server.client import NextcloudClient from nextcloud_mcp_server.config import LOGGING_CONFIG, setup_logging from nextcloud_mcp_server.context import get_client as get_nextcloud_client @@ -135,6 +141,86 @@ def is_oauth_mode() -> bool: return True +async def load_oauth_client_credentials( + nextcloud_host: str, registration_endpoint: str | None +) -> tuple[str, str]: + """ + Load OAuth client credentials from environment, storage file, or dynamic registration. + + This consolidates the client loading logic that was duplicated across multiple functions. + + Args: + nextcloud_host: Nextcloud instance URL + registration_endpoint: Dynamic registration endpoint URL (or None if not available) + + Returns: + Tuple of (client_id, client_secret) + + Raises: + ValueError: If credentials cannot be obtained + """ + # Try environment variables first + client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") + client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET") + + if client_id and client_secret: + logger.info("Using pre-configured OAuth client credentials from environment") + return (client_id, client_secret) + + # Try loading from storage file + storage_path = os.getenv( + "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" + ) + from pathlib import Path + + from nextcloud_mcp_server.auth.client_registration import load_client_from_file + + client_info = load_client_from_file(Path(storage_path)) + + if client_info: + logger.info( + f"Loaded OAuth client from storage: {client_info.client_id[:16]}..." + ) + return (client_info.client_id, client_info.client_secret) + + # Try dynamic registration if available + if registration_endpoint: + logger.info("Dynamic client registration available") + mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") + redirect_uris = [f"{mcp_server_url}/oauth/callback"] + + # Get scopes from environment or use defaults + scopes = os.getenv( + "NEXTCLOUD_OIDC_SCOPES", "openid profile email nc:read nc:write" + ) + logger.info(f"Requesting OAuth scopes: {scopes}") + + # Load or register client + from nextcloud_mcp_server.auth.client_registration import ( + load_or_register_client, + ) + + client_info = await load_or_register_client( + nextcloud_url=nextcloud_host, + registration_endpoint=registration_endpoint, + storage_path=storage_path, + client_name="Nextcloud MCP Server", + redirect_uris=redirect_uris, + scopes=scopes, + ) + + logger.info(f"OAuth client ready: {client_info.client_id[:16]}...") + return (client_info.client_id, client_info.client_secret) + + # No credentials available + raise ValueError( + "OAuth mode requires either:\n" + "1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET environment variables, OR\n" + "2. Pre-existing client credentials file at NEXTCLOUD_OIDC_CLIENT_STORAGE, OR\n" + "3. Dynamic client registration enabled on Nextcloud OIDC app" + ) + + @asynccontextmanager async def app_lifespan_basic(server: FastMCP) -> AsyncIterator[AppContext]: """ @@ -190,39 +276,11 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]: logger.info(f"Userinfo endpoint: {userinfo_uri}") - # Handle client registration - client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") - client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET") - storage_path = os.getenv( - "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" + # Load OAuth client credentials + client_id, client_secret = await load_oauth_client_credentials( + nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint ) - if client_id and client_secret: - logger.info("Using pre-configured OAuth client credentials") - elif registration_endpoint: - logger.info("Dynamic client registration available") - mcp_server_url = os.getenv( - "NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000" - ) - redirect_uris = [f"{mcp_server_url}/oauth/callback"] - - # Load or register client - client_info = await load_or_register_client( - nextcloud_url=nextcloud_host, - registration_endpoint=registration_endpoint, - storage_path=storage_path, - client_name="Nextcloud MCP Server", - redirect_uris=redirect_uris, - ) - - logger.info(f"OAuth client ready: {client_info.client_id[:16]}...") - else: - raise ValueError( - "OAuth mode requires either:\n" - "1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n" - "2. Dynamic client registration enabled on Nextcloud OIDC app" - ) - # Create token verifier token_verifier = NextcloudTokenVerifier( nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri @@ -278,59 +336,54 @@ async def setup_oauth_config(): # Extract endpoints issuer = discovery["issuer"] userinfo_uri = discovery["userinfo_endpoint"] + jwks_uri = discovery.get("jwks_uri") registration_endpoint = discovery.get("registration_endpoint") - # Allow override of public issuer URL for clients - # (useful when MCP server accesses Nextcloud via internal URL - # but needs to advertise a different URL to clients) + logger.info("OIDC endpoints discovered:") + logger.info(f" Issuer: {issuer}") + logger.info(f" Userinfo: {userinfo_uri}") + logger.info(f" JWKS: {jwks_uri}") + + # Allow override of public issuer URL for both client configuration and JWT validation + # When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080), + # the OIDC app issues JWT tokens with that public URL in the 'iss' claim, + # even though the MCP server accesses Nextcloud via an internal URL (e.g., http://app). + # Therefore, we must validate JWT tokens against the public issuer, not the internal one. public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") if public_issuer: public_issuer = public_issuer.rstrip("/") - logger.info(f"Using public issuer URL for clients: {public_issuer}") + logger.info( + f"Using public issuer URL for clients and JWT validation: {public_issuer}" + ) + # Use public issuer for both client configuration AND JWT validation issuer = public_issuer - - # Handle client registration - client_id = os.getenv("NEXTCLOUD_OIDC_CLIENT_ID") - client_secret = os.getenv("NEXTCLOUD_OIDC_CLIENT_SECRET") - - if client_id and client_secret: - logger.info("Using pre-configured OAuth client credentials") - elif registration_endpoint: - logger.info("Dynamic client registration available") - storage_path = os.getenv( - "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" - ) - mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") - redirect_uris = [f"{mcp_server_url}/oauth/callback"] - - # Load or register client - client_info = await load_or_register_client( - nextcloud_url=nextcloud_host, - registration_endpoint=registration_endpoint, - storage_path=storage_path, - client_name="Nextcloud MCP Server", - redirect_uris=redirect_uris, - ) - - logger.info(f"OAuth client ready: {client_info.client_id[:16]}...") + jwt_validation_issuer = public_issuer else: - raise ValueError( - "OAuth mode requires either:\n" - "1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET, OR\n" - "2. Dynamic client registration enabled on Nextcloud OIDC app" - ) + # Use discovered issuer for both + jwt_validation_issuer = issuer - # Create token verifier + # Load OAuth client credentials + client_id, client_secret = await load_oauth_client_credentials( + nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint + ) + + # Create token verifier with JWT support token_verifier = NextcloudTokenVerifier( - nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri + nextcloud_host=nextcloud_host, + userinfo_uri=userinfo_uri, + jwks_uri=jwks_uri, # Enable JWT verification if available + issuer=jwt_validation_issuer, # Use original issuer for JWT validation ) # Create auth settings mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") + + # Note: We don't set required_scopes here anymore. + # Scopes are now advertised via PRM endpoint and enforced per-tool. + # This allows dynamic tool filtering based on user's actual token scopes. auth_settings = AuthSettings( issuer_url=AnyHttpUrl(issuer), resource_server_url=AnyHttpUrl(mcp_server_url), - required_scopes=["openid", "profile"], ) logger.info("OAuth configuration complete") @@ -393,6 +446,45 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): f"Unknown app: {app_name}. Available apps: {list(available_apps.keys())}" ) + # Override list_tools to filter based on user's token scopes (OAuth mode only) + if oauth_enabled: + original_list_tools = mcp._tool_manager.list_tools + + def list_tools_filtered(): + """List tools filtered by user's token scopes.""" + # Get user's scopes from token using MCP SDK's contextvar + # This works for all request types including list_tools + user_scopes = get_access_token_scopes() + logger.info(f"🔍 list_tools called - User scopes: {user_scopes}") + + # Get all tools + all_tools = original_list_tools() + + # If OAuth mode and user has scopes, filter by them + if user_scopes: + allowed_tools = [ + tool + for tool in all_tools + if has_required_scopes(tool.fn, user_scopes) + ] + logger.info( + f"✂️ Filtered tools: {len(allowed_tools)}/{len(all_tools)} tools " + f"available for scopes: {user_scopes}" + ) + else: + # BasicAuth mode or no token - show all tools + allowed_tools = all_tools + logger.info( + f"📋 No scope filtering: showing all {len(all_tools)} tools" + ) + + # Return the Tool objects directly (they're already in the correct format) + return allowed_tools + + # Replace the tool manager's list_tools method + mcp._tool_manager.list_tools = list_tools_filtered + logger.info("Dynamic tool filtering enabled for OAuth mode") + if transport == "sse": mcp_app = mcp.sse_app() lifespan = None @@ -405,7 +497,66 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): await stack.enter_async_context(mcp.session_manager.run()) yield - app = Starlette(routes=[Mount("/", app=mcp_app)], lifespan=lifespan) + # Add Protected Resource Metadata (PRM) endpoint for OAuth mode + routes = [] + if oauth_enabled: + + def oauth_protected_resource_metadata(request): + """RFC 8959 Protected Resource Metadata endpoint.""" + mcp_server_url = os.getenv( + "NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000" + ) + nextcloud_host = os.getenv("NEXTCLOUD_HOST", "") + + return JSONResponse( + { + "resource": mcp_server_url, + "scopes_supported": ["nc:read", "nc:write"], + "authorization_servers": [nextcloud_host], + "bearer_methods_supported": ["header"], + "resource_signing_alg_values_supported": ["RS256"], + } + ) + + routes.append( + Route( + "/.well-known/oauth-protected-resource", + oauth_protected_resource_metadata, + methods=["GET"], + ) + ) + logger.info("Protected Resource Metadata (PRM) endpoint enabled") + + routes.append(Mount("/", app=mcp_app)) + app = Starlette(routes=routes, lifespan=lifespan) + + # Add exception handler for scope challenges (OAuth mode only) + if oauth_enabled: + + @app.exception_handler(InsufficientScopeError) + async def handle_insufficient_scope(request, exc: InsufficientScopeError): + """Return 403 with WWW-Authenticate header for scope challenges.""" + resource_url = os.getenv( + "NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000" + ) + scope_str = " ".join(exc.missing_scopes) + + return JSONResponse( + status_code=403, + headers={ + "WWW-Authenticate": ( + f'Bearer error="insufficient_scope", ' + f'scope="{scope_str}", ' + f'resource_metadata="{resource_url}/.well-known/oauth-protected-resource"' + ) + }, + content={ + "error": "insufficient_scope", + "scopes_required": exc.missing_scopes, + }, + ) + + logger.info("WWW-Authenticate scope challenge handler enabled") return app diff --git a/nextcloud_mcp_server/auth/__init__.py b/nextcloud_mcp_server/auth/__init__.py index 722064b..9756e93 100644 --- a/nextcloud_mcp_server/auth/__init__.py +++ b/nextcloud_mcp_server/auth/__init__.py @@ -3,6 +3,15 @@ from .bearer_auth import BearerAuth from .client_registration import load_or_register_client, register_client from .context_helper import get_client_from_context +from .scope_authorization import ( + InsufficientScopeError, + ScopeAuthorizationError, + check_scopes, + get_access_token_scopes, + get_required_scopes, + has_required_scopes, + require_scopes, +) from .token_verifier import NextcloudTokenVerifier __all__ = [ @@ -11,4 +20,11 @@ __all__ = [ "register_client", "load_or_register_client", "get_client_from_context", + "require_scopes", + "ScopeAuthorizationError", + "InsufficientScopeError", + "check_scopes", + "get_access_token_scopes", + "get_required_scopes", + "has_required_scopes", ] diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py index d99c21d..b8ebcd8 100644 --- a/nextcloud_mcp_server/auth/client_registration.py +++ b/nextcloud_mcp_server/auth/client_registration.py @@ -215,6 +215,7 @@ async def load_or_register_client( storage_path: str | Path, client_name: str = "Nextcloud MCP Server", redirect_uris: list[str] | None = None, + scopes: str = "openid profile email", ) -> ClientInfo: """ Load client from storage or register a new one if not found/expired. @@ -231,6 +232,7 @@ async def load_or_register_client( storage_path: Path to store client credentials client_name: Name of the client application redirect_uris: List of redirect URIs + scopes: Space-separated list of scopes to request (default: "openid profile email") Returns: ClientInfo with valid credentials @@ -253,6 +255,7 @@ async def load_or_register_client( registration_endpoint=registration_endpoint, client_name=client_name, redirect_uris=redirect_uris, + scopes=scopes, ) # Save to storage diff --git a/nextcloud_mcp_server/auth/scope_authorization.py b/nextcloud_mcp_server/auth/scope_authorization.py new file mode 100644 index 0000000..6263ae5 --- /dev/null +++ b/nextcloud_mcp_server/auth/scope_authorization.py @@ -0,0 +1,254 @@ +"""Scope-based authorization for MCP tools.""" + +import logging +from functools import wraps +from typing import Callable + +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.utilities.context_injection import find_context_parameter + +logger = logging.getLogger(__name__) + + +class ScopeAuthorizationError(Exception): + """Raised when a request lacks required scopes.""" + + pass + + +class InsufficientScopeError(ScopeAuthorizationError): + """Raised when request lacks required scopes (enables step-up auth). + + This exception triggers a 403 response with WWW-Authenticate header + containing the missing scopes, allowing clients to perform step-up + authorization to obtain additional permissions. + """ + + def __init__(self, missing_scopes: list[str], message: str | None = None): + self.missing_scopes = missing_scopes + super().__init__( + message or f"Missing required scopes: {', '.join(missing_scopes)}" + ) + + +def require_scopes(*required_scopes: str): + """ + Decorator to require specific OAuth scopes for MCP tool execution. + + This decorator: + 1. Stores scope requirements as function metadata (_required_scopes attribute) + 2. Checks that the access token contains all required scopes before execution + 3. Raises ScopeAuthorizationError if any required scope is missing + + The stored metadata enables dynamic tool filtering - tools can be hidden from + users who lack the necessary scopes. + + Args: + *required_scopes: Variable number of scope strings required (e.g., "nc:read", "nc:write") + + Returns: + Decorated function that checks scopes before execution + + Example: + ```python + @mcp.tool() + @require_scopes("nc:read") + async def nc_notes_get_note(ctx: Context, note_id: int): + # This tool requires the nc:read scope + ... + + @mcp.tool() + @require_scopes("nc:write") + async def nc_notes_create_note(ctx: Context, ...): + # This tool requires the nc:write scope + ... + ``` + + Raises: + ScopeAuthorizationError: If required scopes are not present in the access token + """ + + def decorator(func: Callable): + # Store scope requirements as function metadata for dynamic filtering + func._required_scopes = list(required_scopes) # type: ignore + + # Find which parameter receives the Context (FastMCP injects it by name) + context_param_name = find_context_parameter(func) + + @wraps(func) + async def wrapper(*args, **kwargs): + # Extract context from kwargs (where FastMCP injected it) + ctx: Context | None = ( + kwargs.get(context_param_name) if context_param_name else None + ) + + if ctx is None: + # No context parameter found - likely BasicAuth mode + # In BasicAuth mode, all operations are allowed + logger.debug( + f"No context parameter for {func.__name__} - allowing (BasicAuth mode)" + ) + return await func(*args, **kwargs) + + # Check if we're in OAuth mode (access token available) + access_token: AccessToken | None = getattr( + ctx.request_context, "access_token", None + ) + + if access_token is None: + # Not in OAuth mode (BasicAuth or no auth) + # In BasicAuth mode, all operations are allowed + logger.debug( + f"No access token present for {func.__name__} - allowing (BasicAuth mode)" + ) + return await func(*args, **kwargs) + + # Extract scopes from access token + token_scopes = set(access_token.scopes or []) + required_scopes_set = set(required_scopes) + + # Check if all required scopes are present + missing_scopes = required_scopes_set - token_scopes + if missing_scopes: + error_msg = ( + f"Access denied to {func.__name__}: " + f"Missing required scopes: {', '.join(sorted(missing_scopes))}. " + f"Token has scopes: {', '.join(sorted(token_scopes)) if token_scopes else 'none'}" + ) + logger.warning(error_msg) + raise InsufficientScopeError(list(missing_scopes), error_msg) + + # All required scopes present - allow execution + logger.debug( + f"Scope authorization passed for {func.__name__}: {required_scopes}" + ) + return await func(*args, **kwargs) + + return wrapper + + return decorator + + +def get_access_token_scopes(ctx: Context | None = None) -> set[str]: + """ + Extract scopes from the authenticated user's access token. + + This function uses MCP SDK's contextvar to access the token, which works + across all request types including list_tools. + + Args: + ctx: FastMCP context object (unused, kept for compatibility) + + Returns: + Set of scope strings, empty set if no token or no scopes + """ + # Use MCP SDK's get_access_token() which uses contextvars + # This works for all request types, including list_tools + access_token: AccessToken | None = get_access_token() + + if access_token is None: + logger.debug("No access token found in auth context (likely BasicAuth mode)") + return set() + + scopes = set(access_token.scopes or []) + logger.info(f"✅ Extracted scopes from access token: {scopes}") + return scopes + + +def check_scopes(ctx: Context, *required_scopes: str) -> tuple[bool, set[str]]: + """ + Check if the request context has all required scopes. + + Utility function for manual scope checking without decorator. + + Args: + ctx: FastMCP context object + *required_scopes: Variable number of required scope strings + + Returns: + Tuple of (has_all_scopes: bool, missing_scopes: set[str]) + + Example: + ```python + async def my_tool(ctx: Context): + has_scopes, missing = check_scopes(ctx, "nc:read", "nc:write") + if not has_scopes: + # Handle missing scopes + ... + ``` + """ + token_scopes = get_access_token_scopes(ctx) + + # If no access token, assume BasicAuth mode (all operations allowed) + if not token_scopes and getattr(ctx.request_context, "access_token", None) is None: + return True, set() + + required_scopes_set = set(required_scopes) + missing_scopes = required_scopes_set - token_scopes + + return len(missing_scopes) == 0, missing_scopes + + +def get_required_scopes(func: Callable) -> list[str]: + """ + Extract required scopes from a function decorated with @require_scopes. + + Args: + func: Function to check (may be decorated) + + Returns: + List of required scope strings, empty list if no scopes required + + Example: + ```python + @require_scopes("nc:read", "nc:write") + async def my_tool(): + pass + + scopes = get_required_scopes(my_tool) # ["nc:read", "nc:write"] + ``` + """ + return getattr(func, "_required_scopes", []) + + +def has_required_scopes(func: Callable, user_scopes: set[str]) -> bool: + """ + Check if a user has all scopes required by a function. + + Used for dynamic tool filtering - determines if a tool should be visible + to a user based on their token scopes. + + Args: + func: Function decorated with @require_scopes + user_scopes: Set of scopes the user possesses + + Returns: + True if user has all required scopes (or no scopes required), False otherwise + + Example: + ```python + @require_scopes("nc:write") + async def create_note(): + pass + + user_scopes = {"nc:read", "nc:write"} + can_see = has_required_scopes(create_note, user_scopes) # True + + limited_user_scopes = {"nc:read"} + can_see = has_required_scopes(create_note, limited_user_scopes) # False + ``` + """ + required = get_required_scopes(func) + + # No scopes required → always allow + if not required: + return True + + # Empty user_scopes but scopes required → deny + if not user_scopes: + return False + + # Check if user has all required scopes + return set(required).issubset(user_scopes) diff --git a/nextcloud_mcp_server/auth/token_verifier.py b/nextcloud_mcp_server/auth/token_verifier.py index afa4ac8..ef5deae 100644 --- a/nextcloud_mcp_server/auth/token_verifier.py +++ b/nextcloud_mcp_server/auth/token_verifier.py @@ -5,6 +5,8 @@ import time from typing import Any import httpx +import jwt +from jwt import PyJWKClient from mcp.server.auth.provider import AccessToken, TokenVerifier logger = logging.getLogger(__name__) @@ -12,22 +14,30 @@ logger = logging.getLogger(__name__) class NextcloudTokenVerifier(TokenVerifier): """ - Validates access tokens using Nextcloud OIDC userinfo endpoint. + Validates access tokens using JWT verification with JWKS or userinfo endpoint fallback. - This verifier: - 1. Calls the userinfo endpoint with the bearer token - 2. Caches successful responses to avoid repeated API calls - 3. Extracts username from the 'sub' or 'preferred_username' claim - 4. Optionally supports JWT validation for performance (future enhancement) + This verifier supports both JWT and opaque tokens: + 1. For JWT tokens: Verifies signature with JWKS and extracts scopes from payload + 2. For opaque tokens: Falls back to userinfo endpoint validation + 3. Caches successful responses to avoid repeated API calls/verifications - The userinfo endpoint validates the token and returns user claims if valid, - or returns HTTP 400/401 if the token is invalid or expired. + JWT validation provides: + - Faster validation (no HTTP call needed) + - Direct scope extraction from token payload + - Signature verification using JWKS + + Userinfo fallback provides: + - Support for opaque tokens + - Backward compatibility + - Additional validation layer """ def __init__( self, nextcloud_host: str, userinfo_uri: str, + jwks_uri: str | None = None, + issuer: str | None = None, cache_ttl: int = 3600, ): """ @@ -36,10 +46,14 @@ class NextcloudTokenVerifier(TokenVerifier): Args: nextcloud_host: Base URL of the Nextcloud instance (e.g., https://cloud.example.com) userinfo_uri: Full URL to the userinfo endpoint + jwks_uri: Full URL to the JWKS endpoint (for JWT verification) + issuer: Expected issuer claim value (for JWT verification) cache_ttl: Time-to-live for cached tokens in seconds (default: 3600) """ self.nextcloud_host = nextcloud_host.rstrip("/") self.userinfo_uri = userinfo_uri + self.jwks_uri = jwks_uri + self.issuer = issuer self.cache_ttl = cache_ttl # Cache: token -> (userinfo, expiry_timestamp) @@ -48,14 +62,21 @@ class NextcloudTokenVerifier(TokenVerifier): # HTTP client for userinfo requests self._client = httpx.AsyncClient(timeout=10.0) + # PyJWKClient for JWT verification (lazy initialization) + self._jwks_client: PyJWKClient | None = None + if jwks_uri: + logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}") + self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True) + async def verify_token(self, token: str) -> AccessToken | None: """ - Verify a bearer token by calling the userinfo endpoint. + Verify a bearer token using JWT verification or userinfo endpoint. This method: 1. Checks the cache first for recent validations - 2. Calls the userinfo endpoint if not cached - 3. Returns AccessToken with username stored in metadata + 2. Attempts JWT verification if JWKS is configured and token looks like JWT + 3. Falls back to userinfo endpoint for opaque tokens or JWT verification failures + 4. Returns AccessToken with username and scopes Args: token: The bearer token to verify @@ -69,13 +90,111 @@ class NextcloudTokenVerifier(TokenVerifier): logger.debug("Token found in cache") return cached - # Validate via userinfo endpoint + # Try JWT verification first if enabled and token looks like JWT + if self._jwks_client and self._is_jwt_format(token): + logger.debug("Attempting JWT verification...") + jwt_result = self._verify_jwt(token) + if jwt_result: + logger.info("Token validated via JWT verification") + return jwt_result + + # Fall back to userinfo endpoint validation + logger.debug("Attempting userinfo endpoint validation...") try: return await self._verify_via_userinfo(token) except Exception as e: logger.warning(f"Token verification failed: {e}") return None + def _is_jwt_format(self, token: str) -> bool: + """ + Check if token looks like a JWT (has 3 parts separated by dots). + + Args: + token: The token to check + + Returns: + True if token appears to be JWT format + """ + return "." in token and token.count(".") == 2 + + def _verify_jwt(self, token: str) -> AccessToken | None: + """ + Verify JWT token with signature validation using JWKS. + + Args: + token: The JWT token to verify + + Returns: + AccessToken if valid, None if invalid + """ + try: + # Get signing key from JWKS + signing_key = self._jwks_client.get_signing_key_from_jwt(token) + + # Verify and decode JWT + payload = jwt.decode( + token, + signing_key.key, + algorithms=["RS256"], + issuer=self.issuer, + options={ + "verify_signature": True, + "verify_exp": True, + "verify_iat": True, + "verify_iss": True if self.issuer else False, + "verify_aud": False, # Skip audience validation for Bearer tokens + }, + ) + + logger.debug(f"JWT verified successfully for user: {payload.get('sub')}") + + # Extract username (sub claim) + username = payload.get("sub") + if not username: + logger.error("No 'sub' claim found in JWT payload") + return None + + # Extract scopes from scope claim (space-separated string) + scope_string = payload.get("scope", "") + scopes = scope_string.split() if scope_string else [] + logger.debug(f"Extracted scopes from JWT: {scopes}") + + # Extract expiration + exp = payload.get("exp") + if not exp: + logger.warning("No 'exp' claim in JWT, using default TTL") + exp = int(time.time() + self.cache_ttl) + + # Cache the result + userinfo = { + "sub": username, + "scope": scope_string, + **{k: v for k, v in payload.items() if k not in ["sub", "scope"]}, + } + self._token_cache[token] = (userinfo, exp) + + return AccessToken( + token=token, + client_id=payload.get("client_id", ""), + scopes=scopes, + expires_at=exp, + resource=username, # Store username in resource field (RFC 8707) + ) + + except jwt.ExpiredSignatureError: + logger.info("JWT token has expired") + return None + except jwt.InvalidIssuerError as e: + logger.warning(f"JWT issuer validation failed: {e}") + return None + except jwt.InvalidTokenError as e: + logger.warning(f"JWT validation failed: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error during JWT verification: {e}") + return None + async def _verify_via_userinfo(self, token: str) -> AccessToken | None: """ Validate token by calling the userinfo endpoint. diff --git a/nextcloud_mcp_server/server/calendar.py b/nextcloud_mcp_server/server/calendar.py index 493ede2..ca333e4 100644 --- a/nextcloud_mcp_server/server/calendar.py +++ b/nextcloud_mcp_server/server/calendar.py @@ -4,6 +4,7 @@ from typing import Optional from mcp.server.fastmcp import Context, FastMCP +from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.calendar import ( Calendar, @@ -18,6 +19,7 @@ logger = logging.getLogger(__name__) def configure_calendar_tools(mcp: FastMCP): # Calendar tools @mcp.tool() + @require_scopes("nc:read") async def nc_calendar_list_calendars(ctx: Context) -> ListCalendarsResponse: """List all available calendars for the user""" client = get_client(ctx) @@ -27,6 +29,7 @@ def configure_calendar_tools(mcp: FastMCP): return ListCalendarsResponse(calendars=calendars, total_count=len(calendars)) @mcp.tool() + @require_scopes("nc:write") async def nc_calendar_create_event( calendar_name: str, title: str, @@ -102,6 +105,7 @@ def configure_calendar_tools(mcp: FastMCP): return await client.calendar.create_event(calendar_name, event_data) @mcp.tool() + @require_scopes("nc:read") async def nc_calendar_list_events( calendar_name: str, ctx: Context, @@ -203,6 +207,7 @@ def configure_calendar_tools(mcp: FastMCP): return events @mcp.tool() + @require_scopes("nc:read") async def nc_calendar_get_event( calendar_name: str, event_uid: str, @@ -214,6 +219,7 @@ def configure_calendar_tools(mcp: FastMCP): return event_data @mcp.tool() + @require_scopes("nc:write") async def nc_calendar_update_event( calendar_name: str, event_uid: str, @@ -286,6 +292,7 @@ def configure_calendar_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_calendar_delete_event( calendar_name: str, event_uid: str, @@ -296,6 +303,7 @@ def configure_calendar_tools(mcp: FastMCP): return await client.calendar.delete_event(calendar_name, event_uid) @mcp.tool() + @require_scopes("nc:write") async def nc_calendar_create_meeting( title: str, date: str, @@ -361,6 +369,7 @@ def configure_calendar_tools(mcp: FastMCP): return await client.calendar.create_event(calendar_name, event_data) @mcp.tool() + @require_scopes("nc:read") async def nc_calendar_get_upcoming_events( ctx: Context, calendar_name: str = "", # Empty = all calendars @@ -410,6 +419,7 @@ def configure_calendar_tools(mcp: FastMCP): return all_events[:limit] @mcp.tool() + @require_scopes("nc:read") async def nc_calendar_find_availability( duration_minutes: int, ctx: Context, @@ -489,6 +499,7 @@ def configure_calendar_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_calendar_bulk_operations( operation: str, # "update", "delete", "move" ctx: Context, @@ -737,6 +748,7 @@ def configure_calendar_tools(mcp: FastMCP): } @mcp.tool() + @require_scopes("nc:write") async def nc_calendar_manage_calendar( action: str, # "create", "delete", "update", "list" ctx: Context, @@ -805,6 +817,7 @@ def configure_calendar_tools(mcp: FastMCP): # ============= Todo/Task Tools ============= @mcp.tool() + @require_scopes("nc:read") async def nc_calendar_list_todos( calendar_name: str, ctx: Context, @@ -849,6 +862,7 @@ def configure_calendar_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_calendar_create_todo( calendar_name: str, summary: str, @@ -891,6 +905,7 @@ def configure_calendar_tools(mcp: FastMCP): return await client.calendar.create_todo(calendar_name, todo_data) @mcp.tool() + @require_scopes("nc:write") async def nc_calendar_update_todo( calendar_name: str, todo_uid: str, @@ -950,6 +965,7 @@ def configure_calendar_tools(mcp: FastMCP): return await client.calendar.update_todo(calendar_name, todo_uid, todo_data) @mcp.tool() + @require_scopes("nc:write") async def nc_calendar_delete_todo( calendar_name: str, todo_uid: str, @@ -969,6 +985,7 @@ def configure_calendar_tools(mcp: FastMCP): return await client.calendar.delete_todo(calendar_name, todo_uid) @mcp.tool() + @require_scopes("nc:read") async def nc_calendar_search_todos( ctx: Context, status: Optional[str] = None, diff --git a/nextcloud_mcp_server/server/contacts.py b/nextcloud_mcp_server/server/contacts.py index b6d2871..54dea51 100644 --- a/nextcloud_mcp_server/server/contacts.py +++ b/nextcloud_mcp_server/server/contacts.py @@ -2,6 +2,7 @@ import logging from mcp.server.fastmcp import Context, FastMCP +from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client logger = logging.getLogger(__name__) @@ -10,18 +11,21 @@ logger = logging.getLogger(__name__) def configure_contacts_tools(mcp: FastMCP): # Contacts tools @mcp.tool() + @require_scopes("nc:read") async def nc_contacts_list_addressbooks(ctx: Context): """List all addressbooks for the user.""" client = get_client(ctx) return await client.contacts.list_addressbooks() @mcp.tool() + @require_scopes("nc:read") async def nc_contacts_list_contacts(ctx: Context, *, addressbook: str): """List all contacts in the specified addressbook.""" client = get_client(ctx) return await client.contacts.list_contacts(addressbook=addressbook) @mcp.tool() + @require_scopes("nc:write") async def nc_contacts_create_addressbook( ctx: Context, *, name: str, display_name: str ): @@ -37,12 +41,14 @@ def configure_contacts_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_contacts_delete_addressbook(ctx: Context, *, name: str): """Delete an addressbook.""" client = get_client(ctx) return await client.contacts.delete_addressbook(name=name) @mcp.tool() + @require_scopes("nc:write") async def nc_contacts_create_contact( ctx: Context, *, addressbook: str, uid: str, contact_data: dict ): @@ -59,12 +65,14 @@ def configure_contacts_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_contacts_delete_contact(ctx: Context, *, addressbook: str, uid: str): """Delete a contact.""" client = get_client(ctx) return await client.contacts.delete_contact(addressbook=addressbook, uid=uid) @mcp.tool() + @require_scopes("nc:write") async def nc_contacts_update_contact( ctx: Context, *, addressbook: str, uid: str, contact_data: dict, etag: str = "" ): diff --git a/nextcloud_mcp_server/server/cookbook.py b/nextcloud_mcp_server/server/cookbook.py index fdbcc43..1342233 100644 --- a/nextcloud_mcp_server/server/cookbook.py +++ b/nextcloud_mcp_server/server/cookbook.py @@ -5,6 +5,7 @@ from mcp.server.fastmcp import Context, FastMCP from mcp.shared.exceptions import McpError from mcp.types import ErrorData +from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.cookbook import ( Category, @@ -70,6 +71,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_cookbook_import_recipe(url: str, ctx: Context) -> ImportRecipeResponse: """Import a recipe from a URL using schema.org metadata. @@ -126,6 +128,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_cookbook_list_recipes(ctx: Context) -> ListRecipesResponse: """Get all recipes in the database""" client = get_client(ctx) @@ -150,6 +153,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_cookbook_get_recipe(recipe_id: int, ctx: Context) -> Recipe: """Get a specific recipe by its ID""" client = get_client(ctx) @@ -174,6 +178,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_cookbook_create_recipe( name: str, description: str | None = None, @@ -252,6 +257,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_cookbook_update_recipe( recipe_id: int, name: str | None = None, @@ -340,6 +346,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_cookbook_delete_recipe( recipe_id: int, ctx: Context ) -> DeleteRecipeResponse: @@ -374,6 +381,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_cookbook_search_recipes( query: str, ctx: Context ) -> SearchRecipesResponse: @@ -409,6 +417,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_cookbook_list_categories(ctx: Context) -> ListCategoriesResponse: """Get all known categories. @@ -435,6 +444,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_cookbook_get_recipes_in_category( category: str, ctx: Context ) -> ListRecipesResponse: @@ -470,6 +480,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_cookbook_list_keywords(ctx: Context) -> ListKeywordsResponse: """Get all known keywords/tags""" client = get_client(ctx) @@ -494,6 +505,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_cookbook_get_recipes_with_keywords( keywords: list[str], ctx: Context ) -> ListRecipesResponse: @@ -527,6 +539,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_cookbook_set_config( folder: str | None = None, update_interval: int | None = None, @@ -569,6 +582,7 @@ def configure_cookbook_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_cookbook_reindex(ctx: Context) -> ReindexResponse: """Trigger a rescan of all recipes into the caching database. diff --git a/nextcloud_mcp_server/server/deck.py b/nextcloud_mcp_server/server/deck.py index a79ba4f..2b02578 100644 --- a/nextcloud_mcp_server/server/deck.py +++ b/nextcloud_mcp_server/server/deck.py @@ -3,6 +3,7 @@ from typing import Optional from mcp.server.fastmcp import Context, FastMCP +from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.deck import ( CardOperationResponse, @@ -116,6 +117,7 @@ def configure_deck_tools(mcp: FastMCP): # Read Tools (converted from resources) @mcp.tool() + @require_scopes("nc:read") async def deck_get_boards(ctx: Context) -> list[DeckBoard]: """Get all Nextcloud Deck boards""" client = get_client(ctx) @@ -123,6 +125,7 @@ def configure_deck_tools(mcp: FastMCP): return boards @mcp.tool() + @require_scopes("nc:read") async def deck_get_board(ctx: Context, board_id: int) -> DeckBoard: """Get details of a specific Nextcloud Deck board""" client = get_client(ctx) @@ -130,6 +133,7 @@ def configure_deck_tools(mcp: FastMCP): return board @mcp.tool() + @require_scopes("nc:read") async def deck_get_stacks(ctx: Context, board_id: int) -> list[DeckStack]: """Get all stacks in a Nextcloud Deck board""" client = get_client(ctx) @@ -137,6 +141,7 @@ def configure_deck_tools(mcp: FastMCP): return stacks @mcp.tool() + @require_scopes("nc:read") async def deck_get_stack(ctx: Context, board_id: int, stack_id: int) -> DeckStack: """Get details of a specific Nextcloud Deck stack""" client = get_client(ctx) @@ -144,6 +149,7 @@ def configure_deck_tools(mcp: FastMCP): return stack @mcp.tool() + @require_scopes("nc:read") async def deck_get_cards( ctx: Context, board_id: int, stack_id: int ) -> list[DeckCard]: @@ -155,6 +161,7 @@ def configure_deck_tools(mcp: FastMCP): return [] @mcp.tool() + @require_scopes("nc:read") async def deck_get_card( ctx: Context, board_id: int, stack_id: int, card_id: int ) -> DeckCard: @@ -164,6 +171,7 @@ def configure_deck_tools(mcp: FastMCP): return card @mcp.tool() + @require_scopes("nc:read") async def deck_get_labels(ctx: Context, board_id: int) -> list[DeckLabel]: """Get all labels in a Nextcloud Deck board""" client = get_client(ctx) @@ -171,6 +179,7 @@ def configure_deck_tools(mcp: FastMCP): return board.labels @mcp.tool() + @require_scopes("nc:read") async def deck_get_label(ctx: Context, board_id: int, label_id: int) -> DeckLabel: """Get details of a specific Nextcloud Deck label""" client = get_client(ctx) @@ -180,6 +189,7 @@ def configure_deck_tools(mcp: FastMCP): # Create/Update/Delete Tools @mcp.tool() + @require_scopes("nc:write") async def deck_create_board( ctx: Context, title: str, color: str ) -> CreateBoardResponse: @@ -196,6 +206,7 @@ def configure_deck_tools(mcp: FastMCP): # Stack Tools @mcp.tool() + @require_scopes("nc:write") async def deck_create_stack( ctx: Context, board_id: int, title: str, order: int ) -> CreateStackResponse: @@ -211,6 +222,7 @@ def configure_deck_tools(mcp: FastMCP): return CreateStackResponse(id=stack.id, title=stack.title, order=stack.order) @mcp.tool() + @require_scopes("nc:write") async def deck_update_stack( ctx: Context, board_id: int, @@ -236,6 +248,7 @@ def configure_deck_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def deck_delete_stack( ctx: Context, board_id: int, stack_id: int ) -> StackOperationResponse: @@ -256,6 +269,7 @@ def configure_deck_tools(mcp: FastMCP): # Card Tools @mcp.tool() + @require_scopes("nc:write") async def deck_create_card( ctx: Context, board_id: int, @@ -289,6 +303,7 @@ def configure_deck_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def deck_update_card( ctx: Context, board_id: int, @@ -341,6 +356,7 @@ def configure_deck_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def deck_delete_card( ctx: Context, board_id: int, stack_id: int, card_id: int ) -> CardOperationResponse: @@ -362,6 +378,7 @@ def configure_deck_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def deck_archive_card( ctx: Context, board_id: int, stack_id: int, card_id: int ) -> CardOperationResponse: @@ -383,6 +400,7 @@ def configure_deck_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def deck_unarchive_card( ctx: Context, board_id: int, stack_id: int, card_id: int ) -> CardOperationResponse: @@ -404,6 +422,7 @@ def configure_deck_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def deck_reorder_card( ctx: Context, board_id: int, @@ -435,6 +454,7 @@ def configure_deck_tools(mcp: FastMCP): # Label Tools @mcp.tool() + @require_scopes("nc:write") async def deck_create_label( ctx: Context, board_id: int, title: str, color: str ) -> CreateLabelResponse: @@ -450,6 +470,7 @@ def configure_deck_tools(mcp: FastMCP): return CreateLabelResponse(id=label.id, title=label.title, color=label.color) @mcp.tool() + @require_scopes("nc:write") async def deck_update_label( ctx: Context, board_id: int, @@ -475,6 +496,7 @@ def configure_deck_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def deck_delete_label( ctx: Context, board_id: int, label_id: int ) -> LabelOperationResponse: @@ -495,6 +517,7 @@ def configure_deck_tools(mcp: FastMCP): # Card-Label Assignment Tools @mcp.tool() + @require_scopes("nc:write") async def deck_assign_label_to_card( ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int ) -> CardOperationResponse: @@ -517,6 +540,7 @@ def configure_deck_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def deck_remove_label_from_card( ctx: Context, board_id: int, stack_id: int, card_id: int, label_id: int ) -> CardOperationResponse: @@ -540,6 +564,7 @@ def configure_deck_tools(mcp: FastMCP): # Card-User Assignment Tools @mcp.tool() + @require_scopes("nc:write") async def deck_assign_user_to_card( ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str ) -> CardOperationResponse: @@ -562,6 +587,7 @@ def configure_deck_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def deck_unassign_user_from_card( ctx: Context, board_id: int, stack_id: int, card_id: int, user_id: str ) -> CardOperationResponse: diff --git a/nextcloud_mcp_server/server/notes.py b/nextcloud_mcp_server/server/notes.py index 631ea7b..a29e6ad 100644 --- a/nextcloud_mcp_server/server/notes.py +++ b/nextcloud_mcp_server/server/notes.py @@ -5,6 +5,7 @@ from mcp.server.fastmcp import Context, FastMCP from mcp.shared.exceptions import McpError from mcp.types import ErrorData +from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models.notes import ( AppendContentResponse, @@ -84,10 +85,11 @@ def configure_notes_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_notes_create_note( title: str, content: str, category: str, ctx: Context ) -> CreateNoteResponse: - """Create a new note""" + """Create a new note (requires nc:write scope)""" client = get_client(ctx) try: note_data = await client.notes.create_note( @@ -129,6 +131,7 @@ def configure_notes_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_notes_update_note( note_id: int, etag: str, @@ -137,7 +140,7 @@ def configure_notes_tools(mcp: FastMCP): category: str | None, ctx: Context, ) -> UpdateNoteResponse: - """Update an existing note's title, content, or category. + """Update an existing note's title, content, or category (requires nc:write scope). REQUIRED: etag parameter must be provided to prevent overwriting concurrent changes. Get the current ETag by first retrieving the note using nc_notes_get_note tool. @@ -193,6 +196,7 @@ def configure_notes_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_notes_append_content( note_id: int, content: str, ctx: Context ) -> AppendContentResponse: @@ -242,8 +246,9 @@ def configure_notes_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_notes_search_notes(query: str, ctx: Context) -> SearchNotesResponse: - """Search notes by title or content, returning only id, title, and category.""" + """Search notes by title or content, returning only id, title, and category (requires nc:read scope).""" client = get_client(ctx) try: search_results_raw = await client.notes_search_notes(query=query) @@ -287,8 +292,9 @@ def configure_notes_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_notes_get_note(note_id: int, ctx: Context) -> Note: - """Get a specific note by its ID""" + """Get a specific note by its ID (requires nc:read scope)""" client = get_client(ctx) try: note_data = await client.notes.get_note(note_id) @@ -315,6 +321,7 @@ def configure_notes_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_notes_get_attachment( note_id: int, attachment_filename: str, ctx: Context ) -> dict[str, str]: @@ -360,6 +367,7 @@ def configure_notes_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_notes_delete_note(note_id: int, ctx: Context) -> DeleteNoteResponse: """Delete a note permanently""" logger.info("Deleting note %s", note_id) diff --git a/nextcloud_mcp_server/server/sharing.py b/nextcloud_mcp_server/server/sharing.py index 2c31e9e..c278d80 100644 --- a/nextcloud_mcp_server/server/sharing.py +++ b/nextcloud_mcp_server/server/sharing.py @@ -4,6 +4,7 @@ import json from mcp.server.fastmcp import Context, FastMCP +from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client @@ -15,6 +16,7 @@ def configure_sharing_tools(mcp: FastMCP): """ @mcp.tool() + @require_scopes("nc:write") async def nc_share_create( path: str, share_with: str, @@ -53,6 +55,7 @@ def configure_sharing_tools(mcp: FastMCP): return json.dumps(share_data, indent=2) @mcp.tool() + @require_scopes("nc:write") async def nc_share_delete(share_id: int, ctx: Context) -> str: """Delete a share by its ID. @@ -71,6 +74,7 @@ def configure_sharing_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_share_get(share_id: int, ctx: Context) -> str: """Get information about a specific share. @@ -88,6 +92,7 @@ def configure_sharing_tools(mcp: FastMCP): return json.dumps(share_data, indent=2) @mcp.tool() + @require_scopes("nc:write") async def nc_share_list( ctx: Context, path: str | None = None, shared_with_me: bool = False ) -> str: @@ -108,6 +113,7 @@ def configure_sharing_tools(mcp: FastMCP): return json.dumps(shares, indent=2) @mcp.tool() + @require_scopes("nc:write") async def nc_share_update(share_id: int, permissions: int, ctx: Context) -> str: """Update the permissions of an existing share. diff --git a/nextcloud_mcp_server/server/tables.py b/nextcloud_mcp_server/server/tables.py index 90f985a..f9bb826 100644 --- a/nextcloud_mcp_server/server/tables.py +++ b/nextcloud_mcp_server/server/tables.py @@ -2,6 +2,7 @@ import logging from mcp.server.fastmcp import Context, FastMCP +from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client logger = logging.getLogger(__name__) @@ -10,18 +11,21 @@ logger = logging.getLogger(__name__) def configure_tables_tools(mcp: FastMCP): # Tables tools @mcp.tool() + @require_scopes("nc:read") async def nc_tables_list_tables(ctx: Context): """List all tables available to the user""" client = get_client(ctx) return await client.tables.list_tables() @mcp.tool() + @require_scopes("nc:read") async def nc_tables_get_schema(table_id: int, ctx: Context): """Get the schema/structure of a specific table including columns and views""" client = get_client(ctx) return await client.tables.get_table_schema(table_id) @mcp.tool() + @require_scopes("nc:read") async def nc_tables_read_table( table_id: int, ctx: Context, @@ -33,6 +37,7 @@ def configure_tables_tools(mcp: FastMCP): return await client.tables.get_table_rows(table_id, limit, offset) @mcp.tool() + @require_scopes("nc:write") async def nc_tables_insert_row(table_id: int, data: dict, ctx: Context): """Insert a new row into a table. @@ -42,6 +47,7 @@ def configure_tables_tools(mcp: FastMCP): return await client.tables.create_row(table_id, data) @mcp.tool() + @require_scopes("nc:write") async def nc_tables_update_row(row_id: int, data: dict, ctx: Context): """Update an existing row in a table. @@ -51,6 +57,7 @@ def configure_tables_tools(mcp: FastMCP): return await client.tables.update_row(row_id, data) @mcp.tool() + @require_scopes("nc:write") async def nc_tables_delete_row(row_id: int, ctx: Context): """Delete a row from a table""" client = get_client(ctx) diff --git a/nextcloud_mcp_server/server/webdav.py b/nextcloud_mcp_server/server/webdav.py index 2a2fd08..e045248 100644 --- a/nextcloud_mcp_server/server/webdav.py +++ b/nextcloud_mcp_server/server/webdav.py @@ -2,6 +2,7 @@ import logging from mcp.server.fastmcp import Context, FastMCP +from nextcloud_mcp_server.auth import require_scopes from nextcloud_mcp_server.context import get_client from nextcloud_mcp_server.models import FileInfo, SearchFilesResponse @@ -11,6 +12,7 @@ logger = logging.getLogger(__name__) def configure_webdav_tools(mcp: FastMCP): # WebDAV file system tools @mcp.tool() + @require_scopes("nc:read") async def nc_webdav_list_directory(ctx: Context, path: str = ""): """List files and directories in the specified NextCloud path. @@ -24,6 +26,7 @@ def configure_webdav_tools(mcp: FastMCP): return await client.webdav.list_directory(path) @mcp.tool() + @require_scopes("nc:read") async def nc_webdav_read_file(path: str, ctx: Context): """Read the content of a file from NextCloud. @@ -62,6 +65,7 @@ def configure_webdav_tools(mcp: FastMCP): } @mcp.tool() + @require_scopes("nc:write") async def nc_webdav_write_file( path: str, content: str, ctx: Context, content_type: str | None = None ): @@ -89,6 +93,7 @@ def configure_webdav_tools(mcp: FastMCP): return await client.webdav.write_file(path, content_bytes, content_type) @mcp.tool() + @require_scopes("nc:write") async def nc_webdav_create_directory(path: str, ctx: Context): """Create a directory in NextCloud. @@ -102,6 +107,7 @@ def configure_webdav_tools(mcp: FastMCP): return await client.webdav.create_directory(path) @mcp.tool() + @require_scopes("nc:write") async def nc_webdav_delete_resource(path: str, ctx: Context): """Delete a file or directory in NextCloud. @@ -115,6 +121,7 @@ def configure_webdav_tools(mcp: FastMCP): return await client.webdav.delete_resource(path) @mcp.tool() + @require_scopes("nc:write") async def nc_webdav_move_resource( source_path: str, destination_path: str, ctx: Context, overwrite: bool = False ): @@ -134,6 +141,7 @@ def configure_webdav_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:write") async def nc_webdav_copy_resource( source_path: str, destination_path: str, ctx: Context, overwrite: bool = False ): @@ -153,6 +161,7 @@ def configure_webdav_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_webdav_search_files( ctx: Context, scope: str = "", @@ -268,6 +277,7 @@ def configure_webdav_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_webdav_find_by_name( pattern: str, ctx: Context, scope: str = "", limit: int | None = None ) -> SearchFilesResponse: @@ -294,6 +304,7 @@ def configure_webdav_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_webdav_find_by_type( mime_type: str, ctx: Context, scope: str = "", limit: int | None = None ) -> SearchFilesResponse: @@ -320,6 +331,7 @@ def configure_webdav_tools(mcp: FastMCP): ) @mcp.tool() + @require_scopes("nc:read") async def nc_webdav_list_favorites( ctx: Context, scope: str = "", limit: int | None = None ) -> SearchFilesResponse: diff --git a/pyproject.toml b/pyproject.toml index 6b89ffa..3dfeea1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ dependencies = [ "pydantic>=2.11.4", "click>=8.1.8", "caldav", + "pyjwt[crypto]>=2.8.0", # JWT validation with RSA support ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/tests/conftest.py b/tests/conftest.py index b843d1a..b1835dd 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -184,6 +184,99 @@ async def nc_mcp_oauth_client( yield session +@pytest.fixture(scope="session") +async def nc_mcp_oauth_jwt_client( + anyio_backend, + playwright_oauth_token: str, +) -> AsyncGenerator[ClientSession, Any]: + """ + Fixture to create an MCP client session for JWT OAuth integration tests. + Connects to the JWT OAuth-enabled MCP server on port 8002 with OAuth authentication. + + This server uses JWT tokens (RFC 9068) instead of opaque tokens, enabling: + - Token introspection via JWT signature verification + - Scope information embedded in token claims + - Offline token validation without userinfo endpoint + + Uses headless browser automation suitable for CI/CD. + Uses anyio pytest plugin for proper async fixture handling. + """ + async for session in create_mcp_client_session( + url="http://127.0.0.1:8002/mcp", + token=playwright_oauth_token, + client_name="OAuth JWT MCP (Playwright)", + ): + yield session + + +@pytest.fixture(scope="session") +async def nc_mcp_oauth_client_read_only( + anyio_backend, + playwright_oauth_token_read_only: str, +) -> AsyncGenerator[ClientSession, Any]: + """ + Fixture to create an MCP client session with only nc:read scope. + Connects to the JWT OAuth-enabled MCP server on port 8002. + + This client should only see read tools and should get 403 errors + when attempting to call write tools. + + Uses JWT MCP server because JWT tokens embed scope information in claims, + enabling proper scope-based filtering. + """ + async for session in create_mcp_client_session( + url="http://127.0.0.1:8002/mcp", + token=playwright_oauth_token_read_only, + client_name="OAuth JWT MCP Read-Only (Playwright)", + ): + yield session + + +@pytest.fixture(scope="session") +async def nc_mcp_oauth_client_write_only( + anyio_backend, + playwright_oauth_token_write_only: str, +) -> AsyncGenerator[ClientSession, Any]: + """ + Fixture to create an MCP client session with only nc:write scope. + Connects to the JWT OAuth-enabled MCP server on port 8002. + + This client should only see write tools and should get 403 errors + when attempting to call read tools. + + Uses JWT MCP server because JWT tokens embed scope information in claims, + enabling proper scope-based filtering. + """ + async for session in create_mcp_client_session( + url="http://127.0.0.1:8002/mcp", + token=playwright_oauth_token_write_only, + client_name="OAuth JWT MCP Write-Only (Playwright)", + ): + yield session + + +@pytest.fixture(scope="session") +async def nc_mcp_oauth_client_full_access( + anyio_backend, + playwright_oauth_token_full_access: str, +) -> AsyncGenerator[ClientSession, Any]: + """ + Fixture to create an MCP client session with both nc:read and nc:write scopes. + Connects to the JWT OAuth-enabled MCP server on port 8002. + + This client should see all tools and be able to call all operations. + + Uses JWT MCP server because JWT tokens embed scope information in claims, + enabling proper scope-based filtering. + """ + async for session in create_mcp_client_session( + url="http://127.0.0.1:8002/mcp", + token=playwright_oauth_token_full_access, + client_name="OAuth JWT MCP Full Access (Playwright)", + ): + yield session + + @pytest.fixture async def temporary_note(nc_client: NextcloudClient): """ @@ -821,17 +914,37 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server): registration_endpoint = oidc_config.get("registration_endpoint") authorization_endpoint = oidc_config.get("authorization_endpoint") - if not all([token_endpoint, registration_endpoint, authorization_endpoint]): - raise ValueError("OIDC discovery missing required endpoints") + if not token_endpoint or not authorization_endpoint: + raise ValueError( + "OIDC discovery missing required endpoints (token_endpoint or authorization_endpoint)" + ) - # Register or load shared OAuth client (matches MCP server registration) - client_info = await load_or_register_client( - nextcloud_url=nextcloud_host, - registration_endpoint=registration_endpoint, - storage_path=".nextcloud_oauth_shared_test_client.json", - client_name="Pytest - Shared Test Client", - redirect_uris=[callback_url], - ) + # Try to load existing client first + from pathlib import Path + + from nextcloud_mcp_server.auth.client_registration import load_client_from_file + + storage_path = Path(".nextcloud_oauth_shared_test_client.json") + client_info = load_client_from_file(storage_path) + + if not client_info and not registration_endpoint: + raise ValueError( + "Cannot create OAuth client: registration_endpoint not available and no pre-existing credentials found at .nextcloud_oauth_shared_test_client.json" + ) + + if not client_info: + # Register or load shared OAuth client (matches MCP server registration) + client_info = await load_or_register_client( + nextcloud_url=nextcloud_host, + registration_endpoint=registration_endpoint, + storage_path=".nextcloud_oauth_shared_test_client.json", + client_name="Pytest - Shared Test Client", + redirect_uris=[callback_url], + ) + else: + logger.info( + f"Using existing shared OAuth client: {client_info.client_id[:16]}..." + ) logger.info(f"Shared OAuth client ready: {client_info.client_id[:16]}...") logger.info("This client will be reused for all test user authentications") @@ -845,6 +958,185 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server): ) +async def _create_oauth_client_with_scopes( + callback_url: str, + client_name: str, + allowed_scopes: str, +) -> tuple[str, str]: + """ + Helper function to create an OAuth client with specific allowed_scopes using occ. + + Creates JWT clients (not opaque) so that scope information is embedded in the token. + + Returns: + Tuple of (client_id, client_secret) + """ + import json + import subprocess + + logger.info( + f"Creating JWT OAuth client '{client_name}' with scopes: {allowed_scopes}" + ) + + # Use occ oidc:create to create JWT client with specific allowed_scopes + # JWT tokens are required for scope enforcement (scopes are embedded in token claims) + result = subprocess.run( + [ + "docker-compose", + "exec", + "-T", + "-u", + "www-data", + "app", + "php", + "/var/www/html/occ", + "oidc:create", + "--token_type=jwt", + f"--allowed_scopes={allowed_scopes}", + client_name, + callback_url, + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + raise RuntimeError( + f"Failed to create OAuth client: {result.stderr}\nStdout: {result.stdout}" + ) + + # Parse the JSON output from occ + try: + client_data = json.loads(result.stdout) + client_id = client_data["client_id"] + client_secret = client_data["client_secret"] + logger.info( + f"Created OAuth client: {client_id[:16]}... with scopes: {allowed_scopes}" + ) + return client_id, client_secret + except (json.JSONDecodeError, KeyError) as e: + raise RuntimeError( + f"Failed to parse OAuth client response: {e}\nOutput: {result.stdout}" + ) + + +@pytest.fixture(scope="session") +async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_server): + """ + Fixture for OAuth client with only nc:read scope. + + Returns: + Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint) + """ + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + if not nextcloud_host: + pytest.skip("Read-only OAuth client requires NEXTCLOUD_HOST") + + auth_states, callback_url = oauth_callback_server + + async with httpx.AsyncClient(timeout=30.0) as http_client: + discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" + discovery_response = await http_client.get(discovery_url) + discovery_response.raise_for_status() + oidc_config = discovery_response.json() + + token_endpoint = oidc_config.get("token_endpoint") + authorization_endpoint = oidc_config.get("authorization_endpoint") + + # Create client with READ-ONLY scopes + client_id, client_secret = await _create_oauth_client_with_scopes( + callback_url=callback_url, + client_name="Test Client Read Only", + allowed_scopes="openid profile email nc:read", + ) + + return ( + client_id, + client_secret, + callback_url, + token_endpoint, + authorization_endpoint, + ) + + +@pytest.fixture(scope="session") +async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_server): + """ + Fixture for OAuth client with only nc:write scope. + + Returns: + Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint) + """ + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + if not nextcloud_host: + pytest.skip("Write-only OAuth client requires NEXTCLOUD_HOST") + + auth_states, callback_url = oauth_callback_server + + async with httpx.AsyncClient(timeout=30.0) as http_client: + discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" + discovery_response = await http_client.get(discovery_url) + discovery_response.raise_for_status() + oidc_config = discovery_response.json() + + token_endpoint = oidc_config.get("token_endpoint") + authorization_endpoint = oidc_config.get("authorization_endpoint") + + # Create client with WRITE-ONLY scopes + client_id, client_secret = await _create_oauth_client_with_scopes( + callback_url=callback_url, + client_name="Test Client Write Only", + allowed_scopes="openid profile email nc:write", + ) + + return ( + client_id, + client_secret, + callback_url, + token_endpoint, + authorization_endpoint, + ) + + +@pytest.fixture(scope="session") +async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_server): + """ + Fixture for OAuth client with both nc:read and nc:write scopes. + + Returns: + Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint) + """ + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + if not nextcloud_host: + pytest.skip("Full-access OAuth client requires NEXTCLOUD_HOST") + + auth_states, callback_url = oauth_callback_server + + async with httpx.AsyncClient(timeout=30.0) as http_client: + discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" + discovery_response = await http_client.get(discovery_url) + discovery_response.raise_for_status() + oidc_config = discovery_response.json() + + token_endpoint = oidc_config.get("token_endpoint") + authorization_endpoint = oidc_config.get("authorization_endpoint") + + # Create client with FULL ACCESS (both read and write scopes) + client_id, client_secret = await _create_oauth_client_with_scopes( + callback_url=callback_url, + client_name="Test Client Full Access", + allowed_scopes="openid profile email nc:read nc:write", + ) + + return ( + client_id, + client_secret, + callback_url, + token_endpoint, + authorization_endpoint, + ) + + @pytest.fixture(scope="session") async def playwright_oauth_token( anyio_backend, browser, shared_oauth_client_credentials, oauth_callback_server @@ -906,7 +1198,7 @@ async def playwright_oauth_token( f"client_id={client_id}&" f"redirect_uri={quote(callback_url, safe='')}&" f"state={state}&" - f"scope=openid%20profile%20email" + f"scope=openid%20profile%20email%20nc:read%20nc:write" ) # Async browser automation using pytest-playwright's browser fixture @@ -1009,6 +1301,221 @@ async def playwright_oauth_token( return access_token +async def _get_oauth_token_with_scopes( + browser, + shared_oauth_client_credentials, + oauth_callback_server, + scopes: str, +) -> str: + """ + Helper function to obtain OAuth token with specific scopes. + + Args: + browser: Playwright browser instance + shared_oauth_client_credentials: Tuple of OAuth client credentials + oauth_callback_server: OAuth callback server fixture + scopes: Space-separated list of scopes (e.g., "openid profile email nc:read") + + Returns: + OAuth access token string with requested scopes + """ + import secrets + import time + from urllib.parse import quote + + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + username = os.getenv("NEXTCLOUD_USERNAME") + password = os.getenv("NEXTCLOUD_PASSWORD") + + if not all([nextcloud_host, username, password]): + pytest.skip( + "Scoped OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD" + ) + + # Get auth_states dict from callback server + auth_states, _ = oauth_callback_server + + # Unpack shared client credentials + client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = ( + shared_oauth_client_credentials + ) + + logger.info(f"Starting Playwright-based OAuth flow with scopes: {scopes}") + logger.info(f"Using shared OAuth client: {client_id[:16]}...") + logger.info(f"Using real callback server at: {callback_url}") + + # Generate unique state parameter for this OAuth flow + state = secrets.token_urlsafe(32) + logger.debug(f"Generated state: {state[:16]}...") + + # URL-encode scopes + scopes_encoded = quote(scopes, safe="") + + # Construct authorization URL with state parameter and requested scopes + auth_url = ( + f"{authorization_endpoint}?" + f"response_type=code&" + f"client_id={client_id}&" + f"redirect_uri={quote(callback_url, safe='')}&" + f"state={state}&" + f"scope={scopes_encoded}" + ) + + # Async browser automation using pytest-playwright's browser fixture + context = await browser.new_context(ignore_https_errors=True) + page = await context.new_page() + + try: + # Navigate to authorization URL + logger.debug(f"Navigating to: {auth_url}") + await page.goto(auth_url, wait_until="networkidle", timeout=60000) + + # Check if we need to login first + current_url = page.url + logger.debug(f"Current URL after navigation: {current_url}") + + # If we're on a login page, fill in credentials + if "/login" in current_url or "/index.php/login" in current_url: + logger.info("Login page detected, filling in credentials...") + + # Wait for login form + await page.wait_for_selector('input[name="user"]', timeout=10000) + + # Fill in username and password + await page.fill('input[name="user"]', username) + await page.fill('input[name="password"]', password) + + logger.debug("Credentials filled, submitting login form...") + + # Submit the form + await page.click('button[type="submit"]') + + # Wait for navigation after login + await page.wait_for_load_state("networkidle", timeout=60000) + current_url = page.url + logger.info(f"After login, current URL: {current_url}") + + # Now we should be on the OAuth authorization/consent page or already redirected + # Check if there's an authorize button to click + try: + authorize_button = await page.query_selector( + 'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]' + ) + if authorize_button: + logger.info("Authorization button found, clicking it...") + await authorize_button.click() + await page.wait_for_load_state("networkidle", timeout=30000) + logger.info("Authorization completed") + else: + logger.info( + "No authorization button found, assuming already authorized" + ) + except Exception as e: + logger.debug(f"No authorization button found or already redirected: {e}") + + # Wait for callback server to receive the auth code + logger.info(f"Waiting for auth code with state: {state[:16]}...") + start_time = time.time() + timeout = 30 + + while time.time() - start_time < timeout: + if state in auth_states: + auth_code = auth_states[state] + logger.info("Auth code received from callback server") + break + await asyncio.sleep(0.1) + else: + raise TimeoutError( + f"Auth code not received within {timeout}s. State: {state[:16]}..." + ) + + finally: + await context.close() + + # Exchange authorization code for access token + logger.info("Exchanging authorization code for access token...") + async with httpx.AsyncClient(timeout=30.0) as token_client: + token_response = await token_client.post( + token_endpoint, + data={ + "grant_type": "authorization_code", + "code": auth_code, + "redirect_uri": callback_url, + "client_id": client_id, + "client_secret": client_secret, + }, + ) + + token_response.raise_for_status() + token_data = token_response.json() + access_token = token_data.get("access_token") + + if not access_token: + raise ValueError(f"No access_token in response: {token_data}") + + logger.info(f"Successfully obtained OAuth access token with scopes: {scopes}") + return access_token + + +@pytest.fixture(scope="session") +async def playwright_oauth_token_read_only( + anyio_backend, browser, read_only_oauth_client_credentials, oauth_callback_server +) -> str: + """ + Fixture to obtain an OAuth access token with only nc:read scope. + + This token will only be able to perform read operations and should + have write tools filtered out from the tool list. + + Uses a dedicated OAuth client with allowed_scopes="openid profile email nc:read" + """ + return await _get_oauth_token_with_scopes( + browser, + read_only_oauth_client_credentials, + oauth_callback_server, + scopes="openid profile email nc:read", + ) + + +@pytest.fixture(scope="session") +async def playwright_oauth_token_write_only( + anyio_backend, browser, write_only_oauth_client_credentials, oauth_callback_server +) -> str: + """ + Fixture to obtain an OAuth access token with only nc:write scope. + + This token will only be able to perform write operations and should + have read tools filtered out from the tool list. + + Uses a dedicated OAuth client with allowed_scopes="openid profile email nc:write" + """ + return await _get_oauth_token_with_scopes( + browser, + write_only_oauth_client_credentials, + oauth_callback_server, + scopes="openid profile email nc:write", + ) + + +@pytest.fixture(scope="session") +async def playwright_oauth_token_full_access( + anyio_backend, browser, full_access_oauth_client_credentials, oauth_callback_server +) -> str: + """ + Fixture to obtain an OAuth access token with both nc:read and nc:write scopes. + + This token will be able to perform all operations. + + Uses a dedicated JWT OAuth client with allowed_scopes="openid profile email nc:read nc:write" + """ + return await _get_oauth_token_with_scopes( + browser, + full_access_oauth_client_credentials, + oauth_callback_server, + scopes="openid profile email nc:read nc:write", + ) + + @pytest.fixture(scope="session") async def test_users_setup(anyio_backend, nc_client: NextcloudClient): """ @@ -1169,7 +1676,7 @@ async def _get_oauth_token_for_user( f"client_id={client_id}&" f"redirect_uri={quote(callback_url, safe='')}&" f"state={state}&" - f"scope=openid%20profile%20email" + f"scope=openid%20profile%20email%20nc:read%20nc:write" ) logger.info(f"Performing browser OAuth flow for {username}...") diff --git a/tests/server/test_jwt_tokens.py b/tests/server/test_jwt_tokens.py new file mode 100644 index 0000000..0da9ed9 --- /dev/null +++ b/tests/server/test_jwt_tokens.py @@ -0,0 +1,211 @@ +""" +Test JWT token structure and scope support. + +This test obtains a JWT token via OAuth and examines its structure. +""" + +import base64 +import json + +import pytest + + +def decode_jwt_without_verification(token: str) -> dict: + """ + Decode JWT token without signature verification (for inspection only). + + Returns: + Dict with header and payload + """ + parts = token.split(".") + if len(parts) != 3: + raise ValueError(f"Invalid JWT format: expected 3 parts, got {len(parts)}") + + # Decode header + header = json.loads( + base64.urlsafe_b64decode(parts[0] + "=" * (4 - len(parts[0]) % 4)) + ) + + # Decode payload + payload = json.loads( + base64.urlsafe_b64decode(parts[1] + "=" * (4 - len(parts[1]) % 4)) + ) + + return { + "header": header, + "payload": payload, + } + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_jwt_token_structure_with_custom_client(): + """ + Test that we can create a JWT-enabled OAuth client and examine the token structure. + + This test manually configures a JWT client and obtains a token. + """ + import os + + import httpx + + # This test requires manual setup of a JWT client + # Skip if not configured + client_id = os.getenv("NEXTCLOUD_JWT_CLIENT_ID") + if not client_id: + pytest.skip("NEXTCLOUD_JWT_CLIENT_ID not set - skipping JWT token test") + + _client_secret = os.getenv("NEXTCLOUD_JWT_CLIENT_SECRET") + nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://127.0.0.1:8080") + + # Fetch discovery + async with httpx.AsyncClient() as client: + discovery_response = await client.get( + f"{nextcloud_host}/.well-known/openid-configuration" + ) + discovery_response.raise_for_status() + discovery = discovery_response.json() + + _token_endpoint = discovery["token_endpoint"] + + # For this test, we'll use client credentials grant if supported + # Otherwise, skip this test + pytest.skip( + "JWT token test requires OAuth flow - use manual testing script instead" + ) + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_opaque_token_vs_jwt_comparison(): + """ + Compare opaque tokens vs JWT tokens to understand the differences. + + This is a documentation test that explains the findings. + """ + # This test documents our findings about JWT vs opaque tokens + # Based on manual testing with the test script + + findings = { + "oidc_app_capabilities": { + "supports_jwt_tokens": True, + "supports_opaque_tokens": True, + "configuration_method": "per-client via token_type field", + "jwt_standard": "RFC 9068 (OAuth 2.0 Access Token JWT Profile)", + }, + "dynamic_registration": { + "sets_allowed_scopes": False, + "note": "Dynamic registration does NOT populate allowed_scopes from the scope parameter in registration request", + "workaround": "Must use occ oidc:create with --allowed_scopes flag or manually update via web UI/API", + }, + "jwt_token_structure": { + "header": { + "typ": "at+JWT", # RFC 9068 access token type + "alg": "RS256", # Signature algorithm + }, + "payload_claims": { + "iss": "issuer URL", + "sub": "user ID", + "aud": "client ID", + "exp": "expiration timestamp", + "iat": "issued at timestamp", + "scope": "space-separated scope string (THIS IS THE KEY!)", + "client_id": "client identifier", + "jti": "JWT ID", + # Optional based on scopes: + "roles": "if roles scope present", + "groups": "if groups scope present", + "email": "if email scope present", + "name": "if profile scope present", + }, + "scope_claim": { + "format": "space-separated string", + "example": "openid profile email nc:read nc:write", + "extraction": "payload['scope'].split()", + }, + }, + "scope_validation": { + "oidc_app": { + "validates": True, + "method": "Intersects requested scopes with allowed_scopes per client", + "location": "LoginRedirectorController.php:251-267", + }, + "user_oidc_app": { + "validates_scopes": False, + "validates": ["token expiration", "issuer", "audience (optional)"], + "limitation": "Does NOT extract or validate scopes from JWT", + }, + }, + "token_size": { + "opaque": "72 characters", + "jwt": "~800-1200 characters (depends on claims)", + "overhead": "JWT is 10-15x larger than opaque tokens", + }, + "recommendation": { + "for_mcp_server": "Use JWT tokens with self-validation", + "reasoning": [ + "Can extract scopes directly from token payload", + "No additional API call needed", + "Standard approach (RFC 9068)", + "Works with existing oidc app", + ], + "alternative": "Implement introspection endpoint in oidc app (future work)", + }, + } + + # Print findings for documentation + print("\n" + "=" * 80) + print("JWT Token vs Opaque Token Findings") + print("=" * 80) + print(json.dumps(findings, indent=2)) + print("=" * 80 + "\n") + + # This test always passes - it's for documentation + assert True, "Findings documented" + + +@pytest.mark.integration +@pytest.mark.asyncio +async def test_scope_presence_in_jwt(): + """ + Verify that custom scopes (nc:read, nc:write) are present in JWT tokens. + + NOTE: This test documents the expected behavior based on manual testing. + Actual implementation will be tested in integration tests after JWT validation is implemented. + """ + expected_behavior = { + "client_configuration": { + "allowed_scopes": "openid profile email nc:read nc:write", + "token_type": "jwt", + }, + "authorization_request": { + "scope": "openid profile email nc:read nc:write", + }, + "token_response": { + "access_token": "JWT with scope claim", + }, + "jwt_payload": { + "scope": "openid profile email nc:read nc:write", # All requested scopes present if in allowed_scopes + }, + "scope_filtering": { + "description": "oidc app filters requested scopes against allowed_scopes", + "example": { + "requested": "openid profile nc:read nc:write nc:admin", + "allowed": "openid profile email nc:read nc:write", + "granted": "openid profile nc:read nc:write", # nc:admin filtered out, email not requested + }, + }, + } + + print("\n" + "=" * 80) + print("Expected JWT Scope Behavior") + print("=" * 80) + print(json.dumps(expected_behavior, indent=2)) + print("=" * 80 + "\n") + + assert True, "Expected behavior documented" + + +if __name__ == "__main__": + # Run with: uv run pytest tests/server/test_jwt_tokens.py -v + pytest.main([__file__, "-v", "-s"]) diff --git a/tests/server/test_mcp_oauth_jwt.py b/tests/server/test_mcp_oauth_jwt.py new file mode 100644 index 0000000..6fd24e9 --- /dev/null +++ b/tests/server/test_mcp_oauth_jwt.py @@ -0,0 +1,246 @@ +"""Integration tests for JWT OAuth authentication. + +These tests verify: +1. JWT token authentication works correctly +2. JWT token verification via JWKS +3. Scope information is properly extracted from JWT claims +4. Dynamic tool filtering works with JWT tokens +5. All MCP operations work with JWT authentication +""" + +import json +import logging + +import pytest + +logger = logging.getLogger(__name__) + +pytestmark = [pytest.mark.integration, pytest.mark.oauth] + + +async def test_jwt_mcp_server_connection(nc_mcp_oauth_jwt_client): + """Test connection to JWT OAuth-enabled MCP server.""" + result = await nc_mcp_oauth_jwt_client.list_tools() + assert result is not None + assert len(result.tools) > 0 + + logger.info(f"JWT OAuth MCP server has {len(result.tools)} tools available") + + +async def test_jwt_token_authentication(nc_mcp_oauth_jwt_client): + """Test that JWT token authentication works.""" + # Execute a simple read operation + result = await nc_mcp_oauth_jwt_client.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + response_data = json.loads(result.content[0].text) + + assert "results" in response_data + assert isinstance(response_data["results"], list) + + logger.info( + f"Successfully authenticated with JWT token and executed tool, got {len(response_data['results'])} notes." + ) + + +async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client): + """Test that list_tools works with JWT authentication.""" + result = await nc_mcp_oauth_jwt_client.list_tools() + + # Verify we have tools + assert len(result.tools) > 0 + + # Verify some expected tools exist + tool_names = [tool.name for tool in result.tools] + assert "nc_notes_get_note" in tool_names + assert "nc_notes_create_note" in tool_names + assert "nc_calendar_list_calendars" in tool_names + assert "nc_webdav_list_directory" in tool_names + + logger.info(f"JWT server provides {len(result.tools)} tools") + + +async def test_jwt_read_operation(nc_mcp_oauth_jwt_client): + """Test read operation with JWT authentication.""" + # List calendars (read operation) + result = await nc_mcp_oauth_jwt_client.call_tool( + "nc_calendar_list_calendars", arguments={} + ) + + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + response_data = json.loads(result.content[0].text) + + assert "calendars" in response_data + assert isinstance(response_data["calendars"], list) + + logger.info( + f"Successfully executed read operation with JWT, got {len(response_data['calendars'])} calendars." + ) + + +async def test_jwt_write_operation(nc_mcp_oauth_jwt_client): + """Test write operation with JWT authentication.""" + import uuid + + # Create a note (write operation) + note_title = f"JWT Test Note {uuid.uuid4().hex[:8]}" + note_content = "This note was created during JWT authentication testing" + + result = await nc_mcp_oauth_jwt_client.call_tool( + "nc_notes_create_note", + arguments={ + "title": note_title, + "content": note_content, + "category": "Testing", + }, + ) + + assert result.isError is False, f"Tool execution failed: {result.content}" + assert result.content is not None + response_data = json.loads(result.content[0].text) + + # Verify note was created + assert "id" in response_data + assert response_data["title"] == note_title + + note_id = response_data["id"] + logger.info(f"Successfully created note {note_id} with JWT authentication") + + # Clean up: Delete the note + delete_result = await nc_mcp_oauth_jwt_client.call_tool( + "nc_notes_delete_note", arguments={"note_id": note_id} + ) + + assert delete_result.isError is False, f"Cleanup failed: {delete_result.content}" + logger.info(f"Cleaned up test note {note_id}") + + +async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client): + """Test multiple operations with same JWT token to verify token persistence.""" + # First operation: Search notes + result1 = await nc_mcp_oauth_jwt_client.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + assert result1.isError is False + + # Second operation: List calendars + result2 = await nc_mcp_oauth_jwt_client.call_tool( + "nc_calendar_list_calendars", arguments={} + ) + assert result2.isError is False + + # Third operation: List directory + result3 = await nc_mcp_oauth_jwt_client.call_tool( + "nc_webdav_list_directory", arguments={"path": "/"} + ) + assert result3.isError is False + + logger.info("Successfully executed multiple operations with JWT token") + + +async def test_jwt_vs_opaque_token_compatibility( + nc_mcp_oauth_client, nc_mcp_oauth_jwt_client +): + """Verify that both opaque and JWT tokens provide same functionality.""" + # Execute same operation on both servers + opaque_result = await nc_mcp_oauth_client.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + jwt_result = await nc_mcp_oauth_jwt_client.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + + # Both should succeed + assert opaque_result.isError is False + assert jwt_result.isError is False + + # Both should have results + opaque_data = json.loads(opaque_result.content[0].text) + jwt_data = json.loads(jwt_result.content[0].text) + + assert "results" in opaque_data + assert "results" in jwt_data + + # Results should be the same (same user, same notes) + assert len(opaque_data["results"]) == len(jwt_data["results"]) + + logger.info( + "Verified opaque and JWT tokens provide identical functionality: " + f"{len(opaque_data['results'])} notes accessible from both servers" + ) + + +async def test_jwt_error_handling(nc_mcp_oauth_jwt_client): + """Test error handling with JWT authentication.""" + # Try to get a non-existent note + result = await nc_mcp_oauth_jwt_client.call_tool( + "nc_notes_get_note", arguments={"note_id": 999999} + ) + + # Should get an error (note doesn't exist) + assert result.isError is True + logger.info("JWT server correctly handles errors for invalid operations") + + +async def test_jwt_scope_enforcement(nc_mcp_oauth_jwt_client): + """Test that JWT server properly enforces scopes.""" + # This test assumes the JWT token has both nc:read and nc:write scopes + # Both read and write operations should succeed + + # Read operation + read_result = await nc_mcp_oauth_jwt_client.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + assert read_result.isError is False + + # Write operation + import uuid + + note_title = f"Scope Test {uuid.uuid4().hex[:8]}" + write_result = await nc_mcp_oauth_jwt_client.call_tool( + "nc_notes_create_note", + arguments={ + "title": note_title, + "content": "Testing scope enforcement", + "category": "Testing", + }, + ) + assert write_result.isError is False + + # Clean up + note_id = json.loads(write_result.content[0].text)["id"] + await nc_mcp_oauth_jwt_client.call_tool( + "nc_notes_delete_note", arguments={"note_id": note_id} + ) + + logger.info("JWT server properly allows operations based on token scopes") + + +async def test_jwt_automation_worked(nc_mcp_oauth_jwt_client): + """Test that verifies the automated JWT client creation worked correctly. + + This test confirms that: + 1. JWT client was auto-created during container initialization + 2. MCP server loaded credentials from auto-generated file + 3. JWT authentication flow works end-to-end + 4. Server uses JWT tokens (not opaque tokens) + """ + # If we can connect and execute tools, the automation worked + result = await nc_mcp_oauth_jwt_client.list_tools() + assert result is not None + assert len(result.tools) > 0 + + # Execute a tool to verify full OAuth flow + tool_result = await nc_mcp_oauth_jwt_client.call_tool( + "nc_notes_search_notes", arguments={"query": ""} + ) + assert tool_result.isError is False + + logger.info( + "✅ JWT client automation successful! " + "Auto-generated credentials working correctly." + ) diff --git a/tests/server/test_scope_authorization.py b/tests/server/test_scope_authorization.py new file mode 100644 index 0000000..5047d4a --- /dev/null +++ b/tests/server/test_scope_authorization.py @@ -0,0 +1,392 @@ +"""Integration tests for OAuth scope-based authorization and dynamic tool filtering. + +These tests verify: +1. Dynamic tool filtering based on user's token scopes +2. Scope enforcement (403 responses for insufficient scopes) +3. Protected Resource Metadata (PRM) endpoint +4. WWW-Authenticate challenge headers +5. BasicAuth bypass (all tools visible) +""" + +import pytest + + +@pytest.mark.integration +async def test_prm_endpoint(): + """Test that the Protected Resource Metadata endpoint returns correct data.""" + import httpx + + # Test the PRM endpoint directly + async with httpx.AsyncClient() as client: + response = await client.get( + "http://127.0.0.1:8001/.well-known/oauth-protected-resource" + ) + assert response.status_code == 200 + + prm_data = response.json() + assert prm_data["resource"] == "http://127.0.0.1:8001" + assert "nc:read" in prm_data["scopes_supported"] + assert "nc:write" in prm_data["scopes_supported"] + assert "http://app:80" in prm_data["authorization_servers"] + assert "header" in prm_data["bearer_methods_supported"] + assert "RS256" in prm_data["resource_signing_alg_values_supported"] + + +@pytest.mark.integration +async def test_basicauth_shows_all_tools(nc_mcp_client): + """Test that BasicAuth mode shows all tools (no filtering).""" + # Note: Don't use 'async with' for session-scoped fixtures + # The fixture itself manages the session lifecycle + + # List all tools + tools_response = await nc_mcp_client.list_tools() + + # BasicAuth should see all tools + tool_names = [tool.name for tool in tools_response.tools] + + # Should see both read and write tools + assert "nc_notes_get_note" in tool_names # read tool + assert "nc_notes_create_note" in tool_names # write tool + assert "nc_calendar_list_calendars" in tool_names # read tool + assert "nc_calendar_create_event" in tool_names # write tool + + # Should have all 90+ tools + assert len(tool_names) >= 90 + + +@pytest.mark.integration +async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only): + """Test that a token with only nc:read scope filters out write tools.""" + import logging + + logger = logging.getLogger(__name__) + + # Connect with token that has only "nc:read" scope + result = await nc_mcp_oauth_client_read_only.list_tools() + assert result is not None + assert len(result.tools) > 0 + + tool_names = [tool.name for tool in result.tools] + logger.info(f"Read-only token sees {len(tool_names)} tools") + + # Verify read tools are present + expected_read_tools = [ + "nc_notes_get_note", + "nc_notes_search_notes", + "nc_calendar_list_calendars", + "nc_calendar_get_event", + "nc_webdav_list_directory", + "nc_webdav_read_file", + ] + + for tool in expected_read_tools: + assert tool in tool_names, f"Expected read tool {tool} not found in tool list" + + # Verify write tools are NOT present + write_tools_should_be_filtered = [ + "nc_notes_create_note", + "nc_notes_update_note", + "nc_notes_delete_note", + "nc_calendar_create_event", + "nc_calendar_update_event", + "nc_calendar_delete_event", + "nc_webdav_write_file", + "nc_webdav_create_directory", + ] + + for tool in write_tools_should_be_filtered: + assert tool not in tool_names, ( + f"Write tool {tool} should be filtered out but was found in tool list" + ) + + logger.info( + f"✅ Read-only token properly filters tools: {len(tool_names)} read tools visible, " + f"write tools hidden" + ) + + +@pytest.mark.integration +async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only): + """Test that a token with only nc:write scope filters out read tools.""" + import logging + + logger = logging.getLogger(__name__) + + # Connect with token that has only "nc:write" scope + result = await nc_mcp_oauth_client_write_only.list_tools() + assert result is not None + assert len(result.tools) > 0 + + tool_names = [tool.name for tool in result.tools] + logger.info(f"Write-only token sees {len(tool_names)} tools") + + # Verify write tools are present + expected_write_tools = [ + "nc_notes_create_note", + "nc_notes_update_note", + "nc_notes_delete_note", + "nc_calendar_create_event", + "nc_calendar_update_event", + "nc_calendar_delete_event", + "nc_webdav_write_file", + "nc_webdav_create_directory", + ] + + for tool in expected_write_tools: + assert tool in tool_names, f"Expected write tool {tool} not found in tool list" + + # Verify read tools are NOT present (write-only scope) + read_tools_should_be_filtered = [ + "nc_notes_get_note", + "nc_notes_search_notes", + "nc_calendar_list_calendars", + "nc_calendar_get_event", + "nc_webdav_list_directory", + "nc_webdav_read_file", + ] + + for tool in read_tools_should_be_filtered: + assert tool not in tool_names, ( + f"Read tool {tool} should be filtered out but was found in tool list" + ) + + logger.info( + f"✅ Write-only token properly filters tools: {len(tool_names)} write tools visible, " + f"read tools hidden" + ) + + +@pytest.mark.integration +async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access): + """Test that a token with both nc:read and nc:write scopes can see all tools.""" + import logging + + logger = logging.getLogger(__name__) + + # Connect with token that has both "nc:read" and "nc:write" scopes + result = await nc_mcp_oauth_client_full_access.list_tools() + assert result is not None + assert len(result.tools) > 0 + + tool_names = [tool.name for tool in result.tools] + logger.info(f"Full access token sees {len(tool_names)} tools") + + # Verify both read and write tools are present + expected_read_tools = [ + "nc_notes_get_note", + "nc_notes_search_notes", + "nc_calendar_list_calendars", + "nc_webdav_read_file", + ] + + expected_write_tools = [ + "nc_notes_create_note", + "nc_calendar_create_event", + "nc_webdav_write_file", + ] + + for tool in expected_read_tools: + assert tool in tool_names, f"Expected read tool {tool} not found" + + for tool in expected_write_tools: + assert tool in tool_names, f"Expected write tool {tool} not found" + + # Should have all 90+ tools (both read and write) + assert len(tool_names) >= 90 + + logger.info( + f"✅ Full access token sees all tools: {len(tool_names)} total (read + write)" + ) + + +@pytest.mark.integration +async def test_scope_helper_functions(): + """Test the scope authorization helper functions.""" + from nextcloud_mcp_server.auth import get_required_scopes, has_required_scopes + + # Create a mock function with scope requirements + async def mock_read_tool(): + pass + + async def mock_write_tool(): + pass + + async def mock_no_scope_tool(): + pass + + # Add scope metadata + mock_read_tool._required_scopes = ["nc:read"] # type: ignore + mock_write_tool._required_scopes = ["nc:write"] # type: ignore + + # Test get_required_scopes + assert get_required_scopes(mock_read_tool) == ["nc:read"] + assert get_required_scopes(mock_write_tool) == ["nc:write"] + assert get_required_scopes(mock_no_scope_tool) == [] + + # Test has_required_scopes + read_only_scopes = {"nc:read"} + full_scopes = {"nc:read", "nc:write"} + no_scopes = set() + + # User with only read scope + assert has_required_scopes(mock_read_tool, read_only_scopes) is True + assert has_required_scopes(mock_write_tool, read_only_scopes) is False + assert has_required_scopes(mock_no_scope_tool, read_only_scopes) is True + + # User with full scopes + assert has_required_scopes(mock_read_tool, full_scopes) is True + assert has_required_scopes(mock_write_tool, full_scopes) is True + assert has_required_scopes(mock_no_scope_tool, full_scopes) is True + + # User with no scopes + assert has_required_scopes(mock_read_tool, no_scopes) is False + assert has_required_scopes(mock_write_tool, no_scopes) is False + assert has_required_scopes(mock_no_scope_tool, no_scopes) is True + + +@pytest.mark.integration +async def test_scope_decorator_stores_metadata(): + """Test that @require_scopes decorator properly stores metadata.""" + from nextcloud_mcp_server.auth import require_scopes + + @require_scopes("nc:read", "nc:write") + async def test_function(): + pass + + # Check that metadata was stored + assert hasattr(test_function, "_required_scopes") + assert test_function._required_scopes == ["nc:read", "nc:write"] + + +@pytest.mark.integration +async def test_tools_have_scope_decorators(nc_mcp_client): + """Test that MCP tools have scope requirements defined.""" + # Note: Don't use 'async with' for session-scoped fixtures + # The fixture itself manages the session lifecycle + + # We can at least verify that some expected tools exist + tools_response = await nc_mcp_client.list_tools() + tool_names = [tool.name for tool in tools_response.tools] + + # Verify expected read tools exist + expected_read_tools = [ + "nc_notes_get_note", + "nc_notes_search_notes", + "nc_calendar_list_calendars", + "nc_calendar_get_event", + "nc_contacts_list_contacts", + "nc_webdav_list_directory", + "nc_webdav_read_file", + ] + + for tool in expected_read_tools: + assert tool in tool_names, f"Expected read tool {tool} not found" + + # Verify expected write tools exist + expected_write_tools = [ + "nc_notes_create_note", + "nc_notes_update_note", + "nc_notes_delete_note", + "nc_calendar_create_event", + "nc_calendar_update_event", + "nc_calendar_delete_event", + "nc_contacts_create_contact", + "nc_webdav_write_file", + "nc_webdav_create_directory", + ] + + for tool in expected_write_tools: + assert tool in tool_names, f"Expected write tool {tool} not found" + + +@pytest.mark.integration +async def test_scope_classification(): + """Test that our scope classification correctly identifies read vs write operations.""" + from scripts.add_scope_decorators_simple import classify_function + + # Test read operations + assert classify_function("nc_notes_get_note") == "nc:read" + assert classify_function("nc_notes_search_notes") == "nc:read" + assert classify_function("nc_calendar_list_events") == "nc:read" + assert classify_function("nc_webdav_read_file") == "nc:read" + assert classify_function("nc_calendar_find_availability") == "nc:read" + assert classify_function("nc_calendar_get_upcoming_events") == "nc:read" + + # Test write operations + assert classify_function("nc_notes_create_note") == "nc:write" + assert classify_function("nc_notes_update_note") == "nc:write" + assert classify_function("nc_notes_delete_note") == "nc:write" + assert classify_function("nc_notes_append_content") == "nc:write" + assert classify_function("nc_calendar_create_event") == "nc:write" + assert classify_function("nc_calendar_update_event") == "nc:write" + assert classify_function("nc_calendar_manage_calendar") == "nc:write" + assert classify_function("nc_webdav_write_file") == "nc:write" + assert classify_function("nc_webdav_move_resource") == "nc:write" + assert classify_function("nc_contacts_create_contact") == "nc:write" + assert classify_function("nc_cookbook_import_recipe") == "nc:write" + assert classify_function("nc_tables_insert_row") == "nc:write" + assert classify_function("deck_archive_card") == "nc:write" + assert classify_function("deck_assign_label_to_card") == "nc:write" + + +@pytest.mark.integration +async def test_all_tools_classified(): + """Verify that all tools can be properly classified as read or write.""" + from scripts.add_scope_decorators_simple import classify_function + + # List of all tool names (extracted from our implementation) + all_tools = [ + # Calendar tools + "nc_calendar_list_calendars", + "nc_calendar_create_event", + "nc_calendar_list_events", + "nc_calendar_get_event", + "nc_calendar_update_event", + "nc_calendar_delete_event", + "nc_calendar_create_meeting", + "nc_calendar_get_upcoming_events", + "nc_calendar_find_availability", + "nc_calendar_bulk_operations", + "nc_calendar_manage_calendar", + "nc_calendar_list_todos", + "nc_calendar_create_todo", + "nc_calendar_update_todo", + "nc_calendar_delete_todo", + "nc_calendar_search_todos", + # Notes tools + "nc_notes_get_note", + "nc_notes_search_notes", + "nc_notes_create_note", + "nc_notes_update_note", + "nc_notes_append_content", + "nc_notes_delete_note", + "nc_notes_get_attachment", + # Add more as needed... + ] + + unclassified = [] + for tool_name in all_tools: + scope = classify_function(tool_name) + if scope is None: + unclassified.append(tool_name) + + # All tools should be classifiable + assert len(unclassified) == 0, f"Unclassified tools: {unclassified}" + + +@pytest.mark.integration +async def test_scope_metadata_coverage(nc_mcp_client): + """Test that all tools have scope metadata defined (no undecorated tools).""" + # This test would require access to the actual tool functions to check metadata + # For now, we verify that the expected number of tools exists + # Note: Don't use 'async with' for session-scoped fixtures + + tools_response = await nc_mcp_client.list_tools() + + # We applied decorators to 90 tools + # In BasicAuth mode, all should be visible + assert len(tools_response.tools) >= 90 + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/uv.lock b/uv.lock index fd4f97a..c0167a8 100644 --- a/uv.lock +++ b/uv.lock @@ -72,6 +72,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.4" @@ -281,6 +351,68 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "46.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9f/33/c00162f49c0e2fe8064a62cb92b93e50c74a72bc370ab92f86112b33ff62/cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1", size = 749258, upload-time = "2025-10-15T23:18:31.74Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1d/42/9c391dd801d6cf0d561b5890549d4b27bafcc53b39c31a817e69d87c625b/cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a", size = 7225004, upload-time = "2025-10-15T23:16:52.239Z" }, + { url = "https://files.pythonhosted.org/packages/1c/67/38769ca6b65f07461eb200e85fc1639b438bdc667be02cf7f2cd6a64601c/cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc", size = 4296667, upload-time = "2025-10-15T23:16:54.369Z" }, + { url = "https://files.pythonhosted.org/packages/5c/49/498c86566a1d80e978b42f0d702795f69887005548c041636df6ae1ca64c/cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d", size = 4450807, upload-time = "2025-10-15T23:16:56.414Z" }, + { url = "https://files.pythonhosted.org/packages/4b/0a/863a3604112174c8624a2ac3c038662d9e59970c7f926acdcfaed8d61142/cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb", size = 4299615, upload-time = "2025-10-15T23:16:58.442Z" }, + { url = "https://files.pythonhosted.org/packages/64/02/b73a533f6b64a69f3cd3872acb6ebc12aef924d8d103133bb3ea750dc703/cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849", size = 4016800, upload-time = "2025-10-15T23:17:00.378Z" }, + { url = "https://files.pythonhosted.org/packages/25/d5/16e41afbfa450cde85a3b7ec599bebefaef16b5c6ba4ec49a3532336ed72/cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8", size = 4984707, upload-time = "2025-10-15T23:17:01.98Z" }, + { url = "https://files.pythonhosted.org/packages/c9/56/e7e69b427c3878352c2fb9b450bd0e19ed552753491d39d7d0a2f5226d41/cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec", size = 4482541, upload-time = "2025-10-15T23:17:04.078Z" }, + { url = "https://files.pythonhosted.org/packages/78/f6/50736d40d97e8483172f1bb6e698895b92a223dba513b0ca6f06b2365339/cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91", size = 4299464, upload-time = "2025-10-15T23:17:05.483Z" }, + { url = "https://files.pythonhosted.org/packages/00/de/d8e26b1a855f19d9994a19c702fa2e93b0456beccbcfe437eda00e0701f2/cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e", size = 4950838, upload-time = "2025-10-15T23:17:07.425Z" }, + { url = "https://files.pythonhosted.org/packages/8f/29/798fc4ec461a1c9e9f735f2fc58741b0daae30688f41b2497dcbc9ed1355/cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926", size = 4481596, upload-time = "2025-10-15T23:17:09.343Z" }, + { url = "https://files.pythonhosted.org/packages/15/8d/03cd48b20a573adfff7652b76271078e3045b9f49387920e7f1f631d125e/cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71", size = 4426782, upload-time = "2025-10-15T23:17:11.22Z" }, + { url = "https://files.pythonhosted.org/packages/fa/b1/ebacbfe53317d55cf33165bda24c86523497a6881f339f9aae5c2e13e57b/cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac", size = 4698381, upload-time = "2025-10-15T23:17:12.829Z" }, + { url = "https://files.pythonhosted.org/packages/96/92/8a6a9525893325fc057a01f654d7efc2c64b9de90413adcf605a85744ff4/cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018", size = 3055988, upload-time = "2025-10-15T23:17:14.65Z" }, + { url = "https://files.pythonhosted.org/packages/7e/bf/80fbf45253ea585a1e492a6a17efcb93467701fa79e71550a430c5e60df0/cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb", size = 3514451, upload-time = "2025-10-15T23:17:16.142Z" }, + { url = "https://files.pythonhosted.org/packages/2e/af/9b302da4c87b0beb9db4e756386a7c6c5b8003cd0e742277888d352ae91d/cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c", size = 2928007, upload-time = "2025-10-15T23:17:18.04Z" }, + { url = "https://files.pythonhosted.org/packages/f5/e2/a510aa736755bffa9d2f75029c229111a1d02f8ecd5de03078f4c18d91a3/cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217", size = 7158012, upload-time = "2025-10-15T23:17:19.982Z" }, + { url = "https://files.pythonhosted.org/packages/73/dc/9aa866fbdbb95b02e7f9d086f1fccfeebf8953509b87e3f28fff927ff8a0/cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5", size = 4288728, upload-time = "2025-10-15T23:17:21.527Z" }, + { url = "https://files.pythonhosted.org/packages/c5/fd/bc1daf8230eaa075184cbbf5f8cd00ba9db4fd32d63fb83da4671b72ed8a/cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715", size = 4435078, upload-time = "2025-10-15T23:17:23.042Z" }, + { url = "https://files.pythonhosted.org/packages/82/98/d3bd5407ce4c60017f8ff9e63ffee4200ab3e23fe05b765cab805a7db008/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54", size = 4293460, upload-time = "2025-10-15T23:17:24.885Z" }, + { url = "https://files.pythonhosted.org/packages/26/e9/e23e7900983c2b8af7a08098db406cf989d7f09caea7897e347598d4cd5b/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459", size = 3995237, upload-time = "2025-10-15T23:17:26.449Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/af68c509d4a138cfe299d0d7ddb14afba15233223ebd933b4bbdbc7155d3/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422", size = 4967344, upload-time = "2025-10-15T23:17:28.06Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e3/8643d077c53868b681af077edf6b3cb58288b5423610f21c62aadcbe99f4/cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7", size = 4466564, upload-time = "2025-10-15T23:17:29.665Z" }, + { url = "https://files.pythonhosted.org/packages/0e/43/c1e8726fa59c236ff477ff2b5dc071e54b21e5a1e51aa2cee1676f1c986f/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044", size = 4292415, upload-time = "2025-10-15T23:17:31.686Z" }, + { url = "https://files.pythonhosted.org/packages/42/f9/2f8fefdb1aee8a8e3256a0568cffc4e6d517b256a2fe97a029b3f1b9fe7e/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665", size = 4931457, upload-time = "2025-10-15T23:17:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/79/30/9b54127a9a778ccd6d27c3da7563e9f2d341826075ceab89ae3b41bf5be2/cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3", size = 4466074, upload-time = "2025-10-15T23:17:35.158Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/b4f4a10928e26c941b1b6a179143af9f4d27d88fe84a6a3c53592d2e76bf/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20", size = 4420569, upload-time = "2025-10-15T23:17:37.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/49/3746dab4c0d1979888f125226357d3262a6dd40e114ac29e3d2abdf1ec55/cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de", size = 4681941, upload-time = "2025-10-15T23:17:39.236Z" }, + { url = "https://files.pythonhosted.org/packages/fd/30/27654c1dbaf7e4a3531fa1fc77986d04aefa4d6d78259a62c9dc13d7ad36/cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914", size = 3022339, upload-time = "2025-10-15T23:17:40.888Z" }, + { url = "https://files.pythonhosted.org/packages/f6/30/640f34ccd4d2a1bc88367b54b926b781b5a018d65f404d409aba76a84b1c/cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db", size = 3494315, upload-time = "2025-10-15T23:17:42.769Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8b/88cc7e3bd0a8e7b861f26981f7b820e1f46aa9d26cc482d0feba0ecb4919/cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21", size = 2919331, upload-time = "2025-10-15T23:17:44.468Z" }, + { url = "https://files.pythonhosted.org/packages/fd/23/45fe7f376a7df8daf6da3556603b36f53475a99ce4faacb6ba2cf3d82021/cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936", size = 7218248, upload-time = "2025-10-15T23:17:46.294Z" }, + { url = "https://files.pythonhosted.org/packages/27/32/b68d27471372737054cbd34c84981f9edbc24fe67ca225d389799614e27f/cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683", size = 4294089, upload-time = "2025-10-15T23:17:48.269Z" }, + { url = "https://files.pythonhosted.org/packages/26/42/fa8389d4478368743e24e61eea78846a0006caffaf72ea24a15159215a14/cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d", size = 4440029, upload-time = "2025-10-15T23:17:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/f483db0ec5ac040824f269e93dd2bd8a21ecd1027e77ad7bdf6914f2fd80/cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0", size = 4297222, upload-time = "2025-10-15T23:17:51.357Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cf/da9502c4e1912cb1da3807ea3618a6829bee8207456fbbeebc361ec38ba3/cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc", size = 4012280, upload-time = "2025-10-15T23:17:52.964Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8f/9adb86b93330e0df8b3dcf03eae67c33ba89958fc2e03862ef1ac2b42465/cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3", size = 4978958, upload-time = "2025-10-15T23:17:54.965Z" }, + { url = "https://files.pythonhosted.org/packages/d1/a0/5fa77988289c34bdb9f913f5606ecc9ada1adb5ae870bd0d1054a7021cc4/cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971", size = 4473714, upload-time = "2025-10-15T23:17:56.754Z" }, + { url = "https://files.pythonhosted.org/packages/14/e5/fc82d72a58d41c393697aa18c9abe5ae1214ff6f2a5c18ac470f92777895/cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac", size = 4296970, upload-time = "2025-10-15T23:17:58.588Z" }, + { url = "https://files.pythonhosted.org/packages/78/06/5663ed35438d0b09056973994f1aec467492b33bd31da36e468b01ec1097/cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04", size = 4940236, upload-time = "2025-10-15T23:18:00.897Z" }, + { url = "https://files.pythonhosted.org/packages/fc/59/873633f3f2dcd8a053b8dd1d38f783043b5fce589c0f6988bf55ef57e43e/cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506", size = 4472642, upload-time = "2025-10-15T23:18:02.749Z" }, + { url = "https://files.pythonhosted.org/packages/3d/39/8e71f3930e40f6877737d6f69248cf74d4e34b886a3967d32f919cc50d3b/cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963", size = 4423126, upload-time = "2025-10-15T23:18:04.85Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c7/f65027c2810e14c3e7268353b1681932b87e5a48e65505d8cc17c99e36ae/cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4", size = 4686573, upload-time = "2025-10-15T23:18:06.908Z" }, + { url = "https://files.pythonhosted.org/packages/0a/6e/1c8331ddf91ca4730ab3086a0f1be19c65510a33b5a441cb334e7a2d2560/cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df", size = 3036695, upload-time = "2025-10-15T23:18:08.672Z" }, + { url = "https://files.pythonhosted.org/packages/90/45/b0d691df20633eff80955a0fc7695ff9051ffce8b69741444bd9ed7bd0db/cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f", size = 3501720, upload-time = "2025-10-15T23:18:10.632Z" }, + { url = "https://files.pythonhosted.org/packages/e8/cb/2da4cc83f5edb9c3257d09e1e7ab7b23f049c7962cae8d842bbef0a9cec9/cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372", size = 2918740, upload-time = "2025-10-15T23:18:12.277Z" }, + { url = "https://files.pythonhosted.org/packages/06/8a/e60e46adab4362a682cf142c7dcb5bf79b782ab2199b0dcb81f55970807f/cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea", size = 3698132, upload-time = "2025-10-15T23:18:17.056Z" }, + { url = "https://files.pythonhosted.org/packages/da/38/f59940ec4ee91e93d3311f7532671a5cef5570eb04a144bf203b58552d11/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b", size = 4243992, upload-time = "2025-10-15T23:18:18.695Z" }, + { url = "https://files.pythonhosted.org/packages/b0/0c/35b3d92ddebfdfda76bb485738306545817253d0a3ded0bfe80ef8e67aa5/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb", size = 4409944, upload-time = "2025-10-15T23:18:20.597Z" }, + { url = "https://files.pythonhosted.org/packages/99/55/181022996c4063fc0e7666a47049a1ca705abb9c8a13830f074edb347495/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717", size = 4242957, upload-time = "2025-10-15T23:18:22.18Z" }, + { url = "https://files.pythonhosted.org/packages/ba/af/72cd6ef29f9c5f731251acadaeb821559fe25f10852f44a63374c9ca08c1/cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9", size = 4409447, upload-time = "2025-10-15T23:18:24.209Z" }, + { url = "https://files.pythonhosted.org/packages/0d/c3/e90f4a4feae6410f914f8ebac129b9ae7a8c92eb60a638012dde42030a9d/cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c", size = 3438528, upload-time = "2025-10-15T23:18:26.227Z" }, +] + [[package]] name = "decli" version = "0.6.3" @@ -809,6 +941,7 @@ dependencies = [ { name = "mcp", extra = ["cli"] }, { name = "pillow" }, { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pythonvcard4" }, ] @@ -833,6 +966,7 @@ requires-dist = [ { name = "mcp", extras = ["cli"], specifier = ">=1.18,<1.19" }, { name = "pillow", specifier = ">=12.0.0,<12.1.0" }, { name = "pydantic", specifier = ">=2.11.4" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.8.0" }, { name = "pythonvcard4", specifier = ">=0.2.0" }, ] @@ -1023,6 +1157,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842, upload-time = "2024-07-21T12:58:20.04Z" }, ] +[[package]] +name = "pycparser" +version = "2.23" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/cf/d2d3b9f5699fb1e4615c8e32ff220203e43b248e1dfcc6736ad9057731ca/pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2", size = 173734, upload-time = "2025-09-09T13:23:47.91Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/e3/59cd50310fc9b59512193629e1984c1f95e5c8ae6e5d8c69532ccc65a7fe/pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934", size = 118140, upload-time = "2025-09-09T13:23:46.651Z" }, +] + [[package]] name = "pydantic" version = "2.12.3" @@ -1166,6 +1309,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.10.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "8.4.2"