diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22d6ca6e..98f03490 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -42,7 +42,6 @@ jobs: mode: - "single-user" - "multi-user-basic" - - "oauth" - "login-flow" include: # Version-specific image pins — Renovate updates these via customManagers in renovate.json @@ -58,7 +57,7 @@ jobs: # Mode-specific properties - mode: single-user profile: single-user - markers: "(smoke and not oauth and not keycloak and not login_flow and not multi_user_basic) or (integration and not oauth and not keycloak and not login_flow and not multi_user_basic)" + markers: "(smoke and not keycloak and not login_flow and not multi_user_basic) or (integration and not keycloak and not login_flow and not multi_user_basic)" wait-port: 8000 mcp-internal-url: "http://mcp:8000" needs-playwright: false @@ -74,14 +73,6 @@ jobs: needs-playwright: true extra-args: "" - - mode: oauth - profile: oauth - markers: "oauth and not keycloak" - wait-port: 8001 - mcp-internal-url: "http://mcp-oauth:8001" - needs-playwright: true - extra-args: "" - - mode: login-flow profile: login-flow markers: "login_flow" @@ -184,7 +175,7 @@ jobs: echo "MCP service is ready on port ${{ matrix.wait-port }}." - name: Verify OIDC configuration - if: matrix.mode == 'oauth' || matrix.mode == 'login-flow' + if: matrix.mode == 'login-flow' run: | echo "=== OIDC Discovery ===" curl -s http://localhost:8080/.well-known/openid-configuration | jq . diff --git a/CLAUDE.md b/CLAUDE.md index 343678d8..6b4811c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,9 +52,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **Decorators**: `@require_scopes`, `@require_provisioning` for access control - **Context pattern**: `await get_client(ctx)` to access authenticated NextcloudClient (async!) - **FastMCP decorators**: `@mcp.tool()`, `@mcp.resource()` -- **Token acquisition**: `get_client()` handles both pass-through and token exchange modes - - Pass-through (default): Simple, stateless (ENABLE_TOKEN_EXCHANGE=false) - - Token exchange (opt-in): RFC 8693 delegation (ENABLE_TOKEN_EXCHANGE=true) +- **Token acquisition**: `get_client()` resolves credentials per deployment mode (see Deployment Modes below) ### MCP Tool Annotations (ADR-017) @@ -199,20 +197,20 @@ uv run pytest tests/client/notes/test_notes_api.py -v ``` **Important**: After code changes, rebuild the correct container: -- Single-user tests: `docker-compose up --build -d mcp` -- OAuth tests: `docker-compose up --build -d mcp-oauth` -- Keycloak tests: `docker-compose up --build -d mcp-keycloak` +- Single-user tests: `docker compose up --build -d mcp` +- Login Flow tests: `docker compose up --build -d mcp-login-flow` +- Keycloak tests: `docker compose up --build -d mcp-keycloak` ### Running the Server ```bash # Local development export $(grep -v '^#' .env | xargs) -mcp run --transport sse nextcloud_mcp_server.app:mcp +uv run mcp run --transport sse nextcloud_mcp_server.app:mcp # Docker development (rebuilds after code changes) -docker-compose up --build -d mcp # Single-user (port 8000) -docker-compose up --build -d mcp-oauth # Nextcloud OAuth (port 8001) -docker-compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002) +docker compose up --build -d mcp # Single-user (port 8000) +docker compose up --build -d mcp-login-flow # Login Flow v2 (port 8004) +docker compose up --build -d mcp-keycloak # Keycloak OAuth (port 8002) ``` ### Environment Setup @@ -239,9 +237,11 @@ uv run python -m tests.load.benchmark --output results.json --verbose **Credentials**: root/password, nextcloud/password, database: `nextcloud` -### Quick Query Script (Recommended for Agents) +**Do NOT use `docker compose exec db mariadb` or `docker compose exec sqlite3` directly.** Use the wrapper scripts below instead -- they handle credentials, output formatting, and avoid repeated docker exec approvals. -Use `scripts/dbquery.py` for single SQL statements without requiring approval for each `docker compose exec`: +### MariaDB (Nextcloud) + +Use `scripts/dbquery.py` for all MariaDB queries: ```bash # Basic query @@ -254,27 +254,6 @@ Use `scripts/dbquery.py` for single SQL statements without requiring approval fo ./scripts/dbquery.py -u nextcloud -p nextcloud "SHOW TABLES" ``` -### Direct Docker Access - -For interactive sessions or complex operations: - -```bash -# Connect to database -docker compose exec db mariadb -u root -ppassword nextcloud - -# Check OAuth clients -docker compose exec db mariadb -u root -ppassword nextcloud -e \ - "SELECT id, name, token_type FROM oc_oidc_clients ORDER BY id DESC LIMIT 10;" - -# Check OAuth client scopes -docker compose exec db mariadb -u root -ppassword nextcloud -e \ - "SELECT c.id, c.name, s.scope FROM oc_oidc_clients c LEFT JOIN oc_oidc_client_scopes s ON c.id = s.client_id WHERE c.name LIKE '%MCP%';" - -# Check OAuth access tokens -docker compose exec db mariadb -u root -ppassword nextcloud -e \ - "SELECT id, client_id, user_id, created_at FROM oc_oidc_access_tokens ORDER BY created_at DESC LIMIT 10;" -``` - **Important Tables**: - `oc_oidc_clients` - OAuth client registrations (DCR) - `oc_oidc_client_scopes` - Client allowed scopes @@ -283,9 +262,9 @@ docker compose exec db mariadb -u root -ppassword nextcloud -e \ - `oc_oidc_registration_tokens` - RFC 7592 registration tokens - `oc_oidc_redirect_uris` - Redirect URIs -### SQLite Databases (MCP Services) +### SQLite (MCP Services) -Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers: +Use `scripts/sqlitequery.py` for all SQLite queries: ```bash # List tables @@ -338,48 +317,29 @@ Use `scripts/sqlitequery.py` to query SQLite databases in MCP service containers 3. MCP tools use context pattern: `get_client(ctx)` → `NextcloudClient` 4. All operations are async using httpx -### Progressive Consent Architecture (ADR-004) +### Deployment Modes -**Important**: Progressive consent is a *mechanism* for granting access, not a feature flag. The architecture is always present in OAuth mode. Whether provisioning tools are available is controlled by `ENABLE_OFFLINE_ACCESS`. +The server supports three deployment modes, controlled by environment variables and docker compose profiles: -**What is Progressive Consent?** -- Dual OAuth flow architecture that separates client authentication (Flow 1) from resource provisioning (Flow 2) -- Flow 1: MCP client authenticates directly to IdP with resource scopes (notes:*, calendar:*, etc.) - - Token audience: "mcp-server" - - Client receives resource-scoped token for MCP session -- Flow 2: Server explicitly provisions Nextcloud access via separate login (only when `ENABLE_OFFLINE_ACCESS=true`) - - Server requests: openid, profile, email, offline_access - - Token audience: "nextcloud" - - Server receives refresh token for offline access - - Client never sees this token -- Provides clear separation between session tokens and offline access tokens +**1. Single-User** (profile: `single-user`) +- Set `NEXTCLOUD_USERNAME` + `NEXTCLOUD_PASSWORD` (app password) +- One shared Nextcloud identity for all MCP requests +- Stateless, no persistent storage needed +- Best for: personal instances, local development -**Modes:** -- **Pass-through mode** (`ENABLE_OFFLINE_ACCESS=false`, default): - - No Flow 2 provisioning - - Server uses client's token to access Nextcloud (pass-through) - - No provisioning tools available - - Suitable for stateless, client-driven operations -- **Offline access mode** (`ENABLE_OFFLINE_ACCESS=true`): - - Flow 2 provisioning available - - Server stores refresh tokens for background operations - - Provisioning tools available: `provision_nextcloud_access`, `check_logged_in` - - Suitable for background jobs and server-initiated operations +**2. Multi-User BasicAuth** (profile: `multi-user-basic`) +- Set `ENABLE_MULTI_USER_BASIC_AUTH=true` +- Each MCP client provides credentials via HTTP Authorization header +- Per-request client creation from extracted credentials +- Best for: internal deployments where users manage their own Nextcloud credentials -**When to use OAuth mode:** -- Multi-user deployments -- Background jobs requiring offline access (with `ENABLE_OFFLINE_ACCESS=true`) -- Enhanced security with separate authorization contexts -- Explicit user control over resource access - -**When to use BasicAuth instead:** -- Simple single-user deployments -- Local development and testing - -**Key features:** -- No scope escalation - client gets exactly what it requests -- User explicitly authorizes via `provision_nextcloud_access` tool -- Clear security boundaries between MCP session and Nextcloud access +**3. Login Flow v2** (profile: `login-flow`) +- Browser-based app password acquisition via Nextcloud's native Login Flow v2 API +- Per-user app passwords stored encrypted in SQLite +- Application-level scope enforcement (defense-in-depth) +- Works with any Nextcloud 16+ instance (no special apps required) +- Best for: production multi-user deployments, OAuth MCP integration +- See `docs/ADR-022-login-flow-v2.md` for architecture details ## MCP Response Patterns (CRITICAL) @@ -483,7 +443,7 @@ async def nc_notes_semantic_search_answer( ### Use Existing Fixtures See `tests/conftest.py` for 2888 lines of test infrastructure: - `nc_mcp_client` - MCP client for tool/resource testing (uses `mcp` container) -- `nc_mcp_oauth_client` - MCP client for OAuth testing (uses `mcp-oauth` container) +- `nc_mcp_oauth_client` - MCP client for OAuth testing (uses `mcp-login-flow` container) - `nc_client` - Direct NextcloudClient for setup/cleanup - `temporary_note`, `temporary_addressbook`, `temporary_contact` - Auto-cleanup @@ -518,7 +478,7 @@ async def test_notes_api_get_note(mocker): OAuth tests use **Playwright browser automation** to complete flows programmatically. **Test Environment**: -- Three MCP containers: `mcp` (single-user), `mcp-oauth` (Nextcloud OIDC), `mcp-keycloak` (external IdP) +- Three MCP containers: `mcp` (single-user), `mcp-login-flow` (Login Flow v2), `mcp-keycloak` (external IdP) - OAuth tests require `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables - Playwright configuration: `--browser firefox --headed` for debugging - Install browsers: `uv run playwright install firefox` @@ -541,7 +501,7 @@ uv run pytest tests/server/oauth/test_oauth_core.py --browser firefox --headed - **Setup**: ```bash -docker-compose up -d keycloak app mcp-keycloak +docker compose up -d keycloak app mcp-keycloak curl http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration docker compose exec app php occ user_oidc:provider keycloak ``` @@ -557,7 +517,8 @@ docker compose exec app php occ user_oidc:provider keycloak ## Integration Testing with Docker **Nextcloud**: `docker compose exec app php occ ...` for occ commands -**MariaDB**: `docker compose exec db mariadb -u [user] -p [password] [database]` for queries +**MariaDB**: Use `./scripts/dbquery.py` for queries (see Database Inspection above) +**SQLite**: Use `./scripts/sqlitequery.py` for MCP service databases ### Querying Nextcloud Application Logs diff --git a/docker-compose.yml b/docker-compose.yml index 7c4a1b1f..3108404b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -169,56 +169,6 @@ services: profiles: - multi-user-basic - mcp-oauth: - build: . - command: ["--transport", "streamable-http", "--oauth", "--port", "8001", "--oauth-token-type", "jwt"] - restart: always - depends_on: - app: - condition: service_healthy - ports: - - 127.0.0.1:8001:8001 - environment: - # Generic OIDC configuration (integrated mode - Nextcloud OIDC app) - # OIDC_DISCOVERY_URL not set - defaults to NEXTCLOUD_HOST/.well-known/openid-configuration - # OIDC_CLIENT_ID not set - uses Dynamic Client Registration (DCR) - - NEXTCLOUD_HOST=http://app:80 - - NEXTCLOUD_MCP_SERVER_URL=http://localhost:8001 - - NEXTCLOUD_RESOURCE_URI=http://localhost:8080 # ADR-005: Nextcloud resource identifier for audience validation - - NEXTCLOUD_PUBLIC_ISSUER_URL=http://localhost:8080 - - NEXTCLOUD_OIDC_SCOPES=openid profile email notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write - - # Refresh token storage (ADR-002 Tier 1) - - ENABLE_BACKGROUND_OPERATIONS=true - - TOKEN_ENCRYPTION_KEY=Qh60VwZQsM7CLtSMunzC0gIGPBT948S6VSawUkODtvU= - - TOKEN_STORAGE_DB=/app/data/tokens.db - - # ADR-005: Multi-audience mode (default - ENABLE_TOKEN_EXCHANGE=false) - # Tokens must contain BOTH MCP and Nextcloud audiences - # No token exchange needed - tokens work for both MCP auth and Nextcloud APIs - - # Semantic search configuration (ADR-007, ADR-021) - - ENABLE_SEMANTIC_SEARCH=true - - VECTOR_SYNC_SCAN_INTERVAL=60 - - VECTOR_SYNC_PROCESSOR_WORKERS=1 - - # Qdrant configuration - persistent local storage - - QDRANT_LOCATION=/app/data/qdrant - - # Embedding provider for vector sync (use Simple provider as fallback) - # Ollama not available in CI/test environments - # - OLLAMA_BASE_URL=http://ollama:11434 - # - OLLAMA_EMBEDDING_MODEL=nomic-embed-text - - # NO admin credentials - using OAuth with Dynamic Client Registration (DCR) - # Client credentials registered via RFC 7591 and stored in volume - # JWT token type is used for testing (faster validation, scopes embedded in token) - volumes: - - oauth-client-storage:/app/.oauth - - oauth-tokens:/app/data - profiles: - - oauth - keycloak: image: quay.io/keycloak/keycloak:26.5.4@sha256:ae8efb0d218d8921334b03a2dbee7069a0b868240691c50a3ffc9f42fabba8b4 command: @@ -381,8 +331,6 @@ services: volumes: nextcloud: db: - oauth-client-storage: - oauth-tokens: keycloak-tokens: keycloak-oauth-storage: login-flow-data: diff --git a/nextcloud_mcp_server/api/management.py b/nextcloud_mcp_server/api/management.py index 9fe022fa..0f10d6b9 100644 --- a/nextcloud_mcp_server/api/management.py +++ b/nextcloud_mcp_server/api/management.py @@ -212,7 +212,7 @@ async def get_server_status(request: Request) -> JSONResponse: # Map deployment mode to auth_mode for API response # This helps clients (like Astrolabe) determine which auth flow to use - if mode == AuthMode.OAUTH_SINGLE_AUDIENCE or mode == AuthMode.OAUTH_TOKEN_EXCHANGE: + if mode == AuthMode.OAUTH_SINGLE_AUDIENCE: auth_mode = "oauth" elif mode == AuthMode.MULTI_USER_BASIC: auth_mode = "multi_user_basic" diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index d3835f1f..b58b2849 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -63,7 +63,6 @@ from nextcloud_mcp_server.auth.browser_oauth_routes import ( oauth_logout, ) from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client -from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient from nextcloud_mcp_server.auth.oauth_routes import ( oauth_as_metadata, oauth_authorize, @@ -353,7 +352,7 @@ class OAuthAppContext: nextcloud_host: str token_verifier: object # UnifiedTokenVerifier (ADR-005 compliant) refresh_token_storage: Optional["RefreshTokenStorage"] = None - oauth_client: Optional[object] = None # NextcloudOAuthClient or KeycloakOAuthClient + oauth_client: Optional[object] = None oauth_provider: str = "nextcloud" # "nextcloud" or "keycloak" server_client_id: Optional[str] = ( None # MCP server's OAuth client ID (static or DCR) @@ -772,21 +771,11 @@ async def setup_oauth_config(): token_verifier = UnifiedTokenVerifier(settings) # Log the mode - enable_token_exchange = ( - os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true" + logger.info( + "✓ Multi-audience mode enabled (ADR-005) - tokens must contain both MCP and Nextcloud audiences" ) - if enable_token_exchange: - logger.info( - "✓ Token Exchange mode enabled (ADR-005) - exchanging MCP tokens for Nextcloud tokens via RFC 8693" - ) - logger.info(f" MCP audience: {client_id} or {mcp_server_url}") - logger.info(f" Nextcloud audience: {nextcloud_resource_uri}") - else: - logger.info( - "✓ Multi-audience mode enabled (ADR-005) - tokens must contain both MCP and Nextcloud audiences" - ) - logger.info(f" Required MCP audience: {client_id} or {mcp_server_url}") - logger.info(f" Required Nextcloud audience: {nextcloud_resource_uri}") + logger.info(f" Required MCP audience: {client_id} or {mcp_server_url}") + logger.info(f" Required Nextcloud audience: {nextcloud_resource_uri}") if introspection_uri: logger.info("✓ Opaque token introspection enabled (RFC 7662)") @@ -803,45 +792,7 @@ async def setup_oauth_config(): # that are separate from the real-time token exchange flow logger.debug("Token broker available for future offline access features") - # Create OAuth client for server-initiated flows (e.g., token exchange, background workers) oauth_client = None - if enable_offline_access and refresh_token_storage and is_external_idp: - # For external IdP mode, create generic OIDC client for token operations - mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") - # Note: This redirect_uri is for OAuth client initialization, not used for actual redirects - # since this client is used for backend token operations (exchange, refresh) - redirect_uri = f"{mcp_server_url}/oauth/callback" - - # Extract base URL and realm from discovery URL - # Format: http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration - # → base_url: http://keycloak:8080, realm: nextcloud-mcp - if "/realms/" in discovery_url: - base_url = discovery_url.split("/realms/")[0] - realm = discovery_url.split("/realms/")[1].split("/")[0] - else: - # Fallback: use issuer to extract base URL - base_url = ( - issuer.rsplit("/realms/", 1)[0] if "/realms/" in issuer else issuer - ) - realm = issuer.split("/realms/")[1] if "/realms/" in issuer else "" - - oauth_client = KeycloakOAuthClient( - keycloak_url=base_url, - realm=realm, - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - ) - await oauth_client.discover() - logger.info( - "✓ OIDC client initialized for token operations (token exchange, refresh)" - ) - elif enable_offline_access and refresh_token_storage: - # For integrated mode, OAuth client could be added later - # For now, token refresh can use httpx directly with discovered endpoints - logger.info( - "OAuth client for token refresh not yet implemented for integrated mode" - ) # Create auth settings mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") @@ -1049,10 +1000,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = logger.debug(f"Mode details:\n{get_mode_summary(mode)}") # Derive helper variables for backward compatibility with existing code - oauth_enabled = mode in ( - AuthMode.OAUTH_SINGLE_AUDIENCE, - AuthMode.OAUTH_TOKEN_EXCHANGE, - ) + oauth_enabled = mode == AuthMode.OAUTH_SINGLE_AUDIENCE # Log hybrid authentication status for multi-user BasicAuth with offline access if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access: logger.info( @@ -1202,7 +1150,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = raise # Create MCP server based on detected mode - if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE): + if mode == AuthMode.OAUTH_SINGLE_AUDIENCE: logger.info("Configuring MCP server for OAuth mode") # Asynchronously get the OAuth configuration @@ -1320,18 +1268,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = logger.info("Skipping semantic search tools (VECTOR_SYNC_ENABLED not set)") # Register OAuth provisioning tools (only when offline access is enabled) - # With token exchange enabled (external IdP), provisioning is not needed for MCP operations - enable_token_exchange = ( - os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true" - ) - # Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new) - # and ENABLE_OFFLINE_ACCESS (deprecated) environment variables enable_offline_access_for_tools = settings.enable_offline_access - if oauth_enabled and enable_offline_access_for_tools and not enable_token_exchange: + if oauth_enabled and enable_offline_access_for_tools: logger.info("Registering OAuth provisioning tools for offline access") register_oauth_tools(mcp) - elif oauth_enabled and enable_token_exchange: - logger.info("Skipping provisioning tools registration (token exchange enabled)") elif oauth_enabled and not enable_offline_access_for_tools: logger.info( "Skipping provisioning tools registration (offline access not enabled)" @@ -1965,10 +1905,7 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = # Check authentication configuration # Report the deployment mode, not just whether OAuth is enabled # This helps clients (like Astrolabe) determine which auth flow to use - if ( - mode == AuthMode.OAUTH_SINGLE_AUDIENCE - or mode == AuthMode.OAUTH_TOKEN_EXCHANGE - ): + if mode == AuthMode.OAUTH_SINGLE_AUDIENCE: checks["auth_mode"] = "oauth" checks["auth_configured"] = "ok" elif mode == AuthMode.MULTI_USER_BASIC: diff --git a/nextcloud_mcp_server/auth/context_helper.py b/nextcloud_mcp_server/auth/context_helper.py index 83a22d08..480be8ba 100644 --- a/nextcloud_mcp_server/auth/context_helper.py +++ b/nextcloud_mcp_server/auth/context_helper.py @@ -1,28 +1,17 @@ """Helper functions for extracting OAuth context from MCP requests. -ADR-005 compliant implementation with token exchange caching. +ADR-005 compliant implementation for multi-audience token mode. """ -import hashlib import logging -import time from mcp.server.auth.provider import AccessToken from mcp.server.fastmcp import Context from ..client import NextcloudClient -from ..config import get_settings -from ..observability.metrics import ( - oauth_token_cache_hits_total, - oauth_token_exchange_total, -) -from .token_exchange import exchange_token_for_audience logger = logging.getLogger(__name__) -# Token exchange cache: token_hash -> (exchanged_token, expiry_timestamp) -_exchange_cache: dict[str, tuple[str, float]] = {} - def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: """ @@ -79,131 +68,3 @@ def get_client_from_context(ctx: Context, base_url: str) -> NextcloudClient: logger.error(f"Failed to extract OAuth context: {e}") logger.error("This may indicate the server is not running in OAuth mode") raise - - -async def get_session_client_from_context( - ctx: Context, base_url: str -) -> NextcloudClient: - """ - Create NextcloudClient using RFC 8693 token exchange with caching. - - ADR-005 Mode 2: Exchange MCP token for Nextcloud token via RFC 8693. - - This implements the token exchange pattern where: - 1. Extract MCP token from context (validated by UnifiedTokenVerifier) - 2. Check cache for existing exchanged token - 3. If not cached or expired, exchange via RFC 8693 - 4. Cache the exchanged token to minimize exchange frequency - 5. Create client with exchanged token - - CRITICAL: This is where token exchange happens, NOT in the verifier. - The verifier already validated the MCP audience; now we exchange for Nextcloud. - - Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced - by the MCP server via @require_scopes decorator, not by the IdP. Therefore, - we don't pass scopes to the token exchange - the MCP server already validated - permissions before calling this function. - - Args: - ctx: MCP request context containing session info - base_url: Nextcloud base URL - - Returns: - NextcloudClient configured with ephemeral exchanged token - - Raises: - AttributeError: If context doesn't contain expected OAuth session data - RuntimeError: If token exchange fails - """ - settings = get_settings() - - try: - # Extract MCP token from context - if hasattr(ctx.request_context.request, "user") and hasattr( - ctx.request_context.request.user, "access_token" - ): - access_token: AccessToken = ctx.request_context.request.user.access_token - mcp_token = access_token.token - username = access_token.resource # Username from UnifiedTokenVerifier - logger.debug(f"Retrieved MCP token for user: {username}") - else: - logger.error("No MCP token found in request context") - raise AttributeError("No access token found in OAuth request context") - - if not username: - logger.error("No username found in access token resource field") - raise ValueError("Username not available in OAuth token context") - - # Check cache for existing exchanged token - cache_key = hashlib.sha256(mcp_token.encode()).hexdigest() - if cache_key in _exchange_cache: - cached_token, expiry = _exchange_cache[cache_key] - if time.time() < expiry: - logger.debug( - f"Using cached exchanged token (expires in {expiry - time.time():.1f}s)" - ) - oauth_token_cache_hits_total.labels(hit="true").inc() - return NextcloudClient.from_token( - base_url=base_url, token=cached_token, username=username - ) - else: - logger.debug("Cached token expired, removing from cache") - del _exchange_cache[cache_key] - - oauth_token_cache_hits_total.labels(hit="false").inc() - - # Perform RFC 8693 token exchange - logger.info(f"Exchanging MCP token for Nextcloud API token (user: {username})") - - try: - # Exchange for Nextcloud resource URI audience - exchanged_token, expires_in = await exchange_token_for_audience( - subject_token=mcp_token, - requested_audience=settings.nextcloud_resource_uri or "nextcloud", - requested_scopes=None, # Nextcloud doesn't support scopes - ) - oauth_token_exchange_total.labels(status="success").inc() - - logger.info(f"Token exchange successful. Token expires in {expires_in}s") - except Exception: - oauth_token_exchange_total.labels(status="error").inc() - raise - - # Cache the exchanged token - # Use the minimum of exchange TTL and configured cache TTL - cache_ttl = min(expires_in, settings.token_exchange_cache_ttl) - _exchange_cache[cache_key] = (exchanged_token, time.time() + cache_ttl) - logger.debug(f"Cached exchanged token for {cache_ttl}s") - - # Clean up expired cache entries - _cleanup_exchange_cache() - - # Create client with exchanged token - return NextcloudClient.from_token( - base_url=base_url, token=exchanged_token, username=username - ) - - except AttributeError as e: - logger.error(f"Failed to extract OAuth context: {e}") - raise - except Exception as e: - logger.error(f"Token exchange failed: {e}") - raise RuntimeError(f"Token exchange required but failed: {e}") from e - - -def _cleanup_exchange_cache(): - """Remove expired entries from the token exchange cache.""" - global _exchange_cache - now = time.time() - expired_keys = [k for k, (_, expiry) in _exchange_cache.items() if expiry <= now] - for key in expired_keys: - del _exchange_cache[key] - if expired_keys: - logger.debug(f"Cleaned up {len(expired_keys)} expired cache entries") - - -def clear_exchange_cache(): - """Clear the entire token exchange cache. Useful for testing.""" - global _exchange_cache - _exchange_cache.clear() - logger.debug("Token exchange cache cleared") diff --git a/nextcloud_mcp_server/auth/keycloak_oauth.py b/nextcloud_mcp_server/auth/keycloak_oauth.py deleted file mode 100644 index 5e840d8f..00000000 --- a/nextcloud_mcp_server/auth/keycloak_oauth.py +++ /dev/null @@ -1,585 +0,0 @@ -""" -Keycloak OAuth 2.0 / OIDC Client - -Handles OAuth flows with Keycloak as the identity provider, including: -- OIDC Discovery -- Authorization Code Flow with PKCE -- Token refresh using refresh tokens (ADR-002 Tier 1) -- Integration with RefreshTokenStorage -""" - -import base64 -import hashlib -import logging -import os -import secrets -from typing import Optional -from urllib.parse import urlencode, urlparse - -import httpx - -from ..http import nextcloud_httpx_client - -logger = logging.getLogger(__name__) - - -class KeycloakOAuthClient: - """OAuth 2.0 client for Keycloak integration""" - - def __init__( - self, - keycloak_url: str, - realm: str, - client_id: str, - client_secret: str, - redirect_uri: str, - scopes: Optional[list[str]] = None, - ): - """ - Initialize Keycloak OAuth client. - - Args: - keycloak_url: Base URL of Keycloak (e.g., http://keycloak:8080) - realm: Keycloak realm name - client_id: OAuth client ID - client_secret: OAuth client secret - redirect_uri: OAuth redirect URI - scopes: List of scopes to request (default: openid, profile, email, offline_access) - """ - self.keycloak_url = keycloak_url.rstrip("/") - self.realm = realm - self.client_id = client_id - self.client_secret = client_secret - self.redirect_uri = redirect_uri - self.scopes = scopes or ["openid", "profile", "email", "offline_access"] - - # Discovered endpoints (populated by discover()) - self.authorization_endpoint: Optional[str] = None - self.token_endpoint: Optional[str] = None - self.userinfo_endpoint: Optional[str] = None - self.jwks_uri: Optional[str] = None - self.end_session_endpoint: Optional[str] = None - - self._http_client: Optional[httpx.AsyncClient] = None - - @classmethod - def from_env(cls) -> "KeycloakOAuthClient": - """ - Create client from environment variables. - - Environment variables: - KEYCLOAK_URL: Keycloak base URL - KEYCLOAK_REALM: Realm name - KEYCLOAK_CLIENT_ID: Client ID - KEYCLOAK_CLIENT_SECRET: Client secret - NEXTCLOUD_MCP_SERVER_URL: MCP server URL (for redirect URI) - - Returns: - KeycloakOAuthClient instance - - Raises: - ValueError: If required environment variables are missing - """ - keycloak_url = os.getenv("KEYCLOAK_URL") - realm = os.getenv("KEYCLOAK_REALM") - client_id = os.getenv("KEYCLOAK_CLIENT_ID") - client_secret = os.getenv("KEYCLOAK_CLIENT_SECRET") - server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000") - - if not all([keycloak_url, realm, client_id, client_secret]): - raise ValueError( - "Missing required environment variables: " - "KEYCLOAK_URL, KEYCLOAK_REALM, KEYCLOAK_CLIENT_ID, KEYCLOAK_CLIENT_SECRET" - ) - - # Parse server URL to construct redirect URI - # Note: This is for OAuth client initialization, not used for actual redirects - # since this client is used for backend token operations (exchange, refresh) - parsed_url = urlparse(server_url) - redirect_uri = f"{parsed_url.scheme}://{parsed_url.netloc}/oauth/callback" - - return cls( - keycloak_url=keycloak_url, - realm=realm, - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - ) - - async def _get_http_client(self) -> httpx.AsyncClient: - """Get or create HTTP client""" - if self._http_client is None: - self._http_client = nextcloud_httpx_client(timeout=30.0) - return self._http_client - - async def close(self) -> None: - """Close HTTP client""" - if self._http_client: - await self._http_client.aclose() - self._http_client = None - - async def discover(self) -> None: - """ - Perform OIDC discovery to get endpoint URLs. - - Raises: - httpx.HTTPError: If discovery fails - """ - discovery_url = ( - f"{self.keycloak_url}/realms/{self.realm}/.well-known/openid-configuration" - ) - - logger.info(f"Discovering Keycloak endpoints at {discovery_url}") - - client = await self._get_http_client() - response = await client.get(discovery_url) - response.raise_for_status() - - discovery_data = response.json() - - self.authorization_endpoint = discovery_data["authorization_endpoint"] - self.token_endpoint = discovery_data["token_endpoint"] - self.userinfo_endpoint = discovery_data["userinfo_endpoint"] - self.jwks_uri = discovery_data.get("jwks_uri") - self.end_session_endpoint = discovery_data.get("end_session_endpoint") - - logger.info( - f"✓ Discovered Keycloak endpoints:\n" - f" Authorization: {self.authorization_endpoint}\n" - f" Token: {self.token_endpoint}\n" - f" Userinfo: {self.userinfo_endpoint}\n" - f" JWKS: {self.jwks_uri}" - ) - - def generate_pkce_challenge(self) -> tuple[str, str]: - """ - Generate PKCE code verifier and challenge. - - Returns: - Tuple of (code_verifier, code_challenge) - """ - - # Generate code verifier (43-128 characters) - code_verifier = secrets.token_urlsafe(32) - - # Generate code challenge using S256 method (base64url-encoded SHA256) - digest = hashlib.sha256(code_verifier.encode()).digest() - code_challenge = base64.urlsafe_b64encode(digest).decode().rstrip("=") - - return code_verifier, code_challenge - - async def get_authorization_url( - self, - state: str, - code_challenge: str, - extra_params: Optional[dict[str, str]] = None, - ) -> str: - """ - Build authorization URL for OAuth flow. - - Args: - state: CSRF protection state parameter - code_challenge: PKCE code challenge - extra_params: Additional query parameters - - Returns: - Authorization URL - - Raises: - RuntimeError: If discover() hasn't been called - """ - if not self.authorization_endpoint: - await self.discover() - - if not self.authorization_endpoint: - raise RuntimeError("Authorization endpoint not discovered") - - params = { - "client_id": self.client_id, - "response_type": "code", - "redirect_uri": self.redirect_uri, - "scope": " ".join(self.scopes), - "state": state, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - } - - if extra_params: - params.update(extra_params) - - return f"{self.authorization_endpoint}?{urlencode(params)}" - - async def exchange_authorization_code( - self, - code: str, - code_verifier: str, - ) -> dict: - """ - Exchange authorization code for tokens. - - Args: - code: Authorization code from OAuth callback - code_verifier: PKCE code verifier - - Returns: - Token response dictionary with keys: - - access_token: Access token - - refresh_token: Refresh token (if offline_access scope requested) - - id_token: ID token (JWT) - - expires_in: Access token lifetime in seconds - - refresh_expires_in: Refresh token lifetime in seconds (optional) - - token_type: Token type (Bearer) - - Raises: - httpx.HTTPError: If token exchange fails - """ - if not self.token_endpoint: - await self.discover() - - if not self.token_endpoint: - raise RuntimeError("Token endpoint not discovered") - - logger.debug( - f"Exchanging authorization code for tokens at {self.token_endpoint}" - ) - - client = await self._get_http_client() - response = await client.post( - self.token_endpoint, - data={ - "grant_type": "authorization_code", - "code": code, - "redirect_uri": self.redirect_uri, - "code_verifier": code_verifier, - }, - auth=(self.client_id, self.client_secret), - ) - - response.raise_for_status() - token_data = response.json() - - logger.info("✓ Successfully exchanged authorization code for tokens") - - if "refresh_token" in token_data: - logger.info(" Received refresh token (offline_access granted)") - - return token_data - - async def refresh_access_token(self, refresh_token: str) -> dict: - """ - Refresh access token using refresh token. - - Args: - refresh_token: Refresh token - - Returns: - Token response dictionary (same format as exchange_authorization_code) - - Raises: - httpx.HTTPError: If token refresh fails - """ - if not self.token_endpoint: - await self.discover() - - if not self.token_endpoint: - raise RuntimeError("Token endpoint not discovered") - - logger.debug("Refreshing access token") - - client = await self._get_http_client() - response = await client.post( - self.token_endpoint, - data={ - "grant_type": "refresh_token", - "refresh_token": refresh_token, - }, - auth=(self.client_id, self.client_secret), - ) - - response.raise_for_status() - token_data = response.json() - - logger.debug("✓ Successfully refreshed access token") - - return token_data - - async def get_userinfo(self, access_token: str) -> dict: - """ - Get user information using access token. - - Args: - access_token: Access token - - Returns: - Userinfo response dictionary with claims like: - - sub: Subject (user ID) - - name: Full name - - preferred_username: Username - - email: Email address - - email_verified: Email verification status - - Raises: - httpx.HTTPError: If userinfo request fails - """ - if not self.userinfo_endpoint: - await self.discover() - - if not self.userinfo_endpoint: - raise RuntimeError("Userinfo endpoint not discovered") - - logger.debug("Fetching user info") - - client = await self._get_http_client() - response = await client.get( - self.userinfo_endpoint, - headers={"Authorization": f"Bearer {access_token}"}, - ) - - response.raise_for_status() - userinfo = response.json() - - logger.debug(f"✓ Retrieved user info for subject: {userinfo.get('sub')}") - - return userinfo - - async def get_service_account_token(self, scopes: list[str] | None = None) -> dict: - """ - Get a service account token using client_credentials grant. - - ⚠️ **WARNING: DO NOT USE FOR DIRECT API ACCESS IN OAUTH MODE** ⚠️ - - This method creates a service account user in Nextcloud which VIOLATES - OAuth "act on-behalf-of" principles. Using this token directly for API - access will: - - Create a Nextcloud user: `service-account-{client_id}` - - Attribute all actions to service account instead of real user - - Break audit trail and user attribution - - Create stateful server identity in Nextcloud - - Violate OAuth security model - - **Valid Use Case**: ONLY as subject_token for RFC 8693 token exchange - (ADR-002 Tier 2) where it's immediately exchanged for a user token. - - **Invalid Use Case**: Direct API access with this token (ADR-002 rejected - this as "Tier 1" - see docs/ADR-002-vector-sync-authentication.md). - - **Alternative**: Use token exchange (impersonation/delegation) for - background operations, or use BasicAuth mode if truly need service account. - - This requires the client to have serviceAccountsEnabled=true in provider. - - Args: - scopes: Optional list of scopes to request (default: openid profile email) - - Returns: - Token response dictionary with: - - access_token: Service account access token - - token_type: Bearer - - expires_in: Token lifetime in seconds - - scope: Granted scopes - - Raises: - httpx.HTTPError: If token request fails - - See Also: - - ADR-002 "Will Not Implement" section for detailed critique - - exchange_token_for_user() for proper token exchange usage - """ - if not self.token_endpoint: - await self.discover() - - if not self.token_endpoint: - raise RuntimeError("Token endpoint not discovered") - - # Default scopes - if scopes is None: - scopes = ["openid", "profile", "email"] - - scope_str = " ".join(scopes) - - logger.info(f"Requesting service account token with scopes: {scope_str}") - - client = await self._get_http_client() - response = await client.post( - self.token_endpoint, - data={ - "grant_type": "client_credentials", - "scope": scope_str, - }, - auth=(self.client_id, self.client_secret), - ) - - response.raise_for_status() - token_data = response.json() - - logger.info("✓ Service account token acquired") - - return token_data - - async def exchange_token_for_user( - self, - subject_token: str, - target_user_id: str | None = None, - audience: str | None = None, - scopes: list[str] | None = None, - ) -> dict: - """ - Exchange a token for a user-scoped token using RFC 8693 Token Exchange. - - This allows the MCP server (with a service account token) to obtain - user-scoped access tokens for background operations without needing - refresh tokens. - - Args: - subject_token: The token being exchanged (service account or user token) - target_user_id: Optional user ID to impersonate/exchange for - audience: Optional target audience (client ID) - scopes: Optional list of scopes for the new token - - Returns: - Token response dictionary with: - - access_token: User-scoped access token - - issued_token_type: urn:ietf:params:oauth:token-type:access_token - - token_type: Bearer - - expires_in: Token lifetime in seconds - - Raises: - httpx.HTTPError: If token exchange fails (403 if not authorized) - - Example: - # Get service account token - service_token = await client.get_service_account_token() - - # Exchange for user-scoped token - user_token = await client.exchange_token_for_user( - subject_token=service_token["access_token"], - target_user_id="admin", # Username or sub claim - audience="nextcloud", - scopes=["notes:read", "files:read"] - ) - - Note: - This implements BOTH ADR-002 tiers: - - **Tier 2 (Delegation - Recommended)**: When target_user_id is None - - Uses Keycloak Standard V2 (production-ready) - - Service account maintains its identity (sub claim unchanged) - - No special permissions required - - **Tier 1 (Impersonation - Advanced)**: When target_user_id is provided - - Requires Keycloak Legacy V1 (--features=preview) - - Subject claim changes to target user - - Requires impersonation role granted via Keycloak CLI: - ``` - kcadm.sh add-roles -r \ - --uusername service-account- \ - --cclientid realm-management \ - --rolename impersonation - ``` - - Both tiers require: - - Client has token.exchange.grant.enabled=true - - Client has serviceAccountsEnabled=true - """ - if not self.token_endpoint: - await self.discover() - - if not self.token_endpoint: - raise RuntimeError("Token endpoint not discovered") - - # Build token exchange request - data = { - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "subject_token": subject_token, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - } - - # Add optional parameters - if audience: - data["audience"] = audience - - if scopes: - data["scope"] = " ".join(scopes) - - if target_user_id: - # Tier 1: Impersonation (Legacy V1) - # Use requested_subject for user impersonation - data["requested_subject"] = target_user_id - logger.info( - f"Exchanging token with impersonation (Tier 1): target_user={target_user_id}" - ) - else: - # Tier 2: Delegation (Standard V2) - logger.info( - "Exchanging token with delegation (Tier 2): service account identity preserved" - ) - - client = await self._get_http_client() - response = await client.post( - self.token_endpoint, - data=data, - auth=(self.client_id, self.client_secret), - ) - - if response.status_code != 200: - error_data = ( - response.json() - if response.headers.get("content-type", "").startswith( - "application/json" - ) - else {"error": "unknown"} - ) - logger.error(f"Token exchange failed: {response.status_code}") - logger.error(f"Error response: {error_data}") - - response.raise_for_status() - token_data = response.json() - - logger.info( - f"✓ Token exchange successful, issued_token_type: {token_data.get('issued_token_type')}" - ) - - return token_data - - async def check_token_exchange_support(self) -> bool: - """ - Check if Keycloak supports RFC 8693 token exchange. - - Returns: - True if token exchange is supported - - Note: - This is ADR-002 Tier 2. Most Keycloak installations don't - have token exchange enabled by default. - """ - if not self.token_endpoint: - await self.discover() - - # Try to get discovery document and check for token exchange grant - discovery_url = ( - f"{self.keycloak_url}/realms/{self.realm}/.well-known/openid-configuration" - ) - - try: - client = await self._get_http_client() - response = await client.get(discovery_url) - response.raise_for_status() - discovery_data = response.json() - - grant_types = discovery_data.get("grant_types_supported", []) - supported = "urn:ietf:params:oauth:grant-type:token-exchange" in grant_types - - if supported: - logger.info("✓ Token exchange (RFC 8693) is supported") - else: - logger.info("Token exchange (RFC 8693) is not supported") - - return supported - - except Exception as e: - logger.warning(f"Failed to check token exchange support: {e}") - return False - - -__all__ = ["KeycloakOAuthClient"] diff --git a/nextcloud_mcp_server/auth/provisioning_decorator.py b/nextcloud_mcp_server/auth/provisioning_decorator.py index d2d98b66..585b6ff2 100644 --- a/nextcloud_mcp_server/auth/provisioning_decorator.py +++ b/nextcloud_mcp_server/auth/provisioning_decorator.py @@ -15,7 +15,6 @@ from mcp.shared.exceptions import McpError from mcp.types import ErrorData from nextcloud_mcp_server.auth.storage import RefreshTokenStorage -from nextcloud_mcp_server.config import get_settings logger = logging.getLogger(__name__) @@ -65,14 +64,6 @@ def require_provisioning(func: Callable) -> Callable: logger.debug("BasicAuth mode detected - skipping provisioning check") return await func(*args, **kwargs) - # Check if we're in token exchange mode - if so, skip provisioning check - # In token exchange mode, tokens are exchanged per-request (no stored refresh tokens) - settings = get_settings() - if hasattr(lifespan_ctx, "nextcloud_host") and settings.enable_token_exchange: - # Token exchange mode - per-request exchange, no provisioning needed - logger.debug("Token exchange mode detected - skipping provisioning check") - return await func(*args, **kwargs) - # Offline access mode - check if user has completed Flow 2 provisioning # Get user_id from authorization token user_id = None diff --git a/nextcloud_mcp_server/auth/token_broker.py b/nextcloud_mcp_server/auth/token_broker.py index b14478bc..7e11c198 100644 --- a/nextcloud_mcp_server/auth/token_broker.py +++ b/nextcloud_mcp_server/auth/token_broker.py @@ -11,7 +11,7 @@ The Token Broker provides: - Short-lived token caching (5-minute TTL) - Master refresh token rotation - Audience-specific token validation -- Session vs background token separation (RFC 8693) +- Background token management """ import logging @@ -23,7 +23,6 @@ import httpx import jwt from nextcloud_mcp_server.auth.storage import RefreshTokenStorage -from nextcloud_mcp_server.auth.token_exchange import exchange_token_for_delegation from ..http import nextcloud_httpx_client @@ -219,55 +218,6 @@ class TokenBrokerService: await self.cache.invalidate(user_id) return None - async def get_session_token( - self, - flow1_token: str, - required_scopes: list[str], - requested_audience: str = "nextcloud", - ) -> Optional[str]: - """ - Get ephemeral token for MCP session operations (on-demand). - - This implements the correct Progressive Consent pattern where: - 1. Client provides Flow 1 token (aud: "mcp-server") - 2. Server exchanges it for ephemeral Nextcloud token - 3. Token is NOT stored, only used for current operation - - Key properties: - - On-demand generation during tool execution - - Ephemeral (not stored, discarded after use) - - Limited scopes (only what tool needs) - - Short-lived (5 minutes) - - Args: - flow1_token: The MCP session token (aud: "mcp-server") - required_scopes: Minimal scopes needed for this operation - requested_audience: Target audience (usually "nextcloud") - - Returns: - Ephemeral Nextcloud access token or None if exchange fails - """ - try: - # Perform RFC 8693 token exchange - delegated_token, expires_in = await exchange_token_for_delegation( - flow1_token=flow1_token, - requested_scopes=required_scopes, - requested_audience=requested_audience, - ) - - # NOTE: We intentionally do NOT cache session tokens - # They are ephemeral and should be discarded after use - logger.info( - f"Generated ephemeral session token with scopes: {required_scopes}, " - f"expires in {expires_in}s" - ) - - return delegated_token - - except Exception as e: - logger.error(f"Failed to get session token: {e}") - return None - async def get_background_token( self, user_id: str, required_scopes: list[str] ) -> Optional[str]: diff --git a/nextcloud_mcp_server/auth/token_exchange.py b/nextcloud_mcp_server/auth/token_exchange.py deleted file mode 100644 index 52be1ab7..00000000 --- a/nextcloud_mcp_server/auth/token_exchange.py +++ /dev/null @@ -1,596 +0,0 @@ -"""RFC 8693 Token Exchange implementation for ADR-004 Progressive Consent. - -This module implements the token exchange pattern to convert Flow 1 MCP tokens -(aud: "mcp-server") into ephemeral delegated Nextcloud tokens (aud: "nextcloud") -for session operations. - -Key Properties: -- On-demand generation during tool execution -- Ephemeral tokens (NOT stored, discarded after use) -- Limited scopes (only what tool needs) -- Short-lived (5 minutes default) -""" - -import logging -import time -from typing import Any, Dict, Optional, Tuple -from urllib.parse import urljoin - -import httpx -import jwt - -from ..config import get_settings -from ..http import nextcloud_httpx_client -from .storage import RefreshTokenStorage - -logger = logging.getLogger(__name__) - - -class TokenExchangeService: - """Implements RFC 8693 OAuth 2.0 Token Exchange.""" - - # RFC 8693 Grant Type - TOKEN_EXCHANGE_GRANT = "urn:ietf:params:oauth:grant-type:token-exchange" - - # RFC 8693 Token Type Identifiers - TOKEN_TYPE_ACCESS_TOKEN = "urn:ietf:params:oauth:token-type:access_token" - TOKEN_TYPE_JWT = "urn:ietf:params:oauth:token-type:jwt" - TOKEN_TYPE_ID_TOKEN = "urn:ietf:params:oauth:token-type:id_token" - - def __init__( - self, - oidc_discovery_url: Optional[str] = None, - client_id: Optional[str] = None, - client_secret: Optional[str] = None, - nextcloud_host: Optional[str] = None, - ): - """Initialize token exchange service. - - Args: - oidc_discovery_url: OIDC discovery endpoint URL - client_id: OAuth client ID for token exchange - client_secret: OAuth client secret - nextcloud_host: Nextcloud instance URL - """ - settings = get_settings() - self.oidc_discovery_url = oidc_discovery_url or settings.oidc_discovery_url - self.client_id = client_id or settings.oidc_client_id - self.client_secret = client_secret or settings.oidc_client_secret - self.nextcloud_host = nextcloud_host or settings.nextcloud_host - - self._token_endpoint: Optional[str] = None - self._jwks_uri: Optional[str] = None - self._discovery_cache: Optional[Dict[str, Any]] = None - self._discovery_cache_time: float = 0 - self._discovery_cache_ttl: float = 3600 # 1 hour - - # Storage for Progressive Consent (refresh tokens) - only needed for delegation - # NOT needed for pure RFC 8693 exchange (MCP tools) - self.storage: Optional[RefreshTokenStorage] = None - - # Create HTTP client - self.http_client = nextcloud_httpx_client( - timeout=30.0, - follow_redirects=True, - ) - - async def __aenter__(self): - """Async context manager entry.""" - if self.storage: - await self.storage.initialize() - return self - - async def __aexit__(self, exc_type, exc_val, exc_tb): - """Async context manager exit.""" - await self.close() - - async def close(self): - """Close HTTP client and storage.""" - await self.http_client.aclose() - # RefreshTokenStorage doesn't have a close method - - async def _ensure_storage(self): - """Lazily initialize storage for Progressive Consent operations. - - Only needed for delegation operations that use refresh tokens. - NOT needed for pure RFC 8693 exchange (MCP tools). - """ - if self.storage is None: - self.storage = RefreshTokenStorage.from_env() - await self.storage.initialize() - - async def _discover_endpoints(self) -> Dict[str, Any]: - """Discover OIDC endpoints from discovery URL. - - Returns: - Discovery document containing endpoint URLs - """ - # Check cache - if ( - self._discovery_cache - and (time.time() - self._discovery_cache_time) < self._discovery_cache_ttl - ): - return self._discovery_cache - - if not self.oidc_discovery_url: - # Fallback to Nextcloud OIDC if no discovery URL - self.oidc_discovery_url = urljoin( - self.nextcloud_host, # type: ignore[arg-type] - "/.well-known/openid-configuration", - ) - - try: - response = await self.http_client.get(self.oidc_discovery_url) - response.raise_for_status() - - self._discovery_cache = response.json() - self._discovery_cache_time = time.time() - - # Cache frequently used endpoints - self._token_endpoint = self._discovery_cache.get("token_endpoint") - self._jwks_uri = self._discovery_cache.get("jwks_uri") - - return self._discovery_cache - - except Exception as e: - logger.error(f"Failed to discover OIDC endpoints: {e}") - raise - - async def exchange_token_for_delegation( - self, - flow1_token: str, - requested_scopes: list[str], - requested_audience: str = "nextcloud", - ) -> Tuple[str, int]: - """Exchange Flow 1 MCP token for delegated Nextcloud token. - - This implements RFC 8693 Token Exchange for on-behalf-of delegation. - - Args: - flow1_token: The MCP session token (aud: "mcp-server") - requested_scopes: Scopes needed for this operation - requested_audience: Target audience (usually "nextcloud") - - Returns: - Tuple of (delegated_token, expires_in) - - Raises: - ValueError: If token validation fails - RuntimeError: If provisioning not completed or exchange fails - """ - # 1. Validate Flow 1 token audience - await self._validate_flow1_token(flow1_token) - - # 2. Extract user ID from token - user_id = self._extract_user_id(flow1_token) - - # 3. Check user has provisioned Nextcloud access (Flow 2) - if not await self._check_provisioning(user_id): - raise RuntimeError( - "Nextcloud access not provisioned. " - "User must complete Flow 2 provisioning first." - ) - - # 4. Get stored refresh token for user (from Flow 2) - refresh_token = await self._get_user_refresh_token(user_id) - if not refresh_token: - raise RuntimeError( - "No refresh token found. User must complete provisioning." - ) - - # 5. Perform token exchange with IdP - delegated_token, expires_in = await self._perform_token_exchange( - subject_token=flow1_token, - refresh_token=refresh_token, - requested_scopes=requested_scopes, - requested_audience=requested_audience, - ) - - # 6. Log the exchange for audit trail - logger.info( - f"Token exchange completed for user {user_id}: " - f"scopes={requested_scopes}, audience={requested_audience}, " - f"expires_in={expires_in}s" - ) - - return delegated_token, expires_in - - async def exchange_token_for_audience( - self, - subject_token: str, - requested_audience: str = "nextcloud", - requested_scopes: list[str] | None = None, - ) -> Tuple[str, int]: - """ - Pure RFC 8693 token exchange (no refresh tokens required). - - This implements stateless per-request token exchange where: - 1. Client token has aud: (e.g., "nextcloud-mcp-server") - 2. Exchange for token with aud: "nextcloud" (for API access) - 3. NO refresh tokens or provisioning required - - Use case: All MCP tool calls (request-time operations). - NOT for background jobs (which use refresh tokens separately). - - Args: - subject_token: Token being exchanged (from MCP client) - requested_audience: Target audience (usually "nextcloud") - requested_scopes: Optional scopes (may not be supported by all IdPs) - - Returns: - Tuple of (access_token, expires_in) - - Raises: - ValueError: If token validation fails - RuntimeError: If exchange fails - """ - # 1. Validate subject token (accepts both "mcp-server" and client_id) - await self._validate_flow1_token(subject_token) - - # 2. Extract user ID for logging - user_id = self._extract_user_id(subject_token) - - # 3. Discover token endpoint - discovery = await self._discover_endpoints() - token_endpoint = discovery.get("token_endpoint") - - if not token_endpoint: - raise RuntimeError("No token endpoint found in discovery") - - # 4. Build pure RFC 8693 exchange request (subject_token ONLY) - data = { - "grant_type": self.TOKEN_EXCHANGE_GRANT, - "subject_token": subject_token, - "subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN, - "requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN, - "audience": requested_audience, - } - - # Add scopes if provided (may not be supported by all providers) - if requested_scopes: - data["scope"] = " ".join(requested_scopes) - - # Add client credentials - if self.client_id and self.client_secret: - data["client_id"] = self.client_id - data["client_secret"] = self.client_secret - - try: - # Perform exchange - logger.debug(f"Exchanging token for audience={requested_audience}") - response = await self.http_client.post( - token_endpoint, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - response.raise_for_status() - result = response.json() - - access_token = result.get("access_token") - expires_in = result.get("expires_in", 300) - - if not access_token: - raise RuntimeError("No access token in exchange response") - - logger.info( - f"Pure RFC 8693 token exchange successful for user {user_id}: " - f"audience={requested_audience}, expires_in={expires_in}s" - ) - - return access_token, expires_in - - except httpx.HTTPStatusError as e: - logger.error(f"Token exchange failed: {e.response.text}") - raise RuntimeError(f"Token exchange failed: {e}") - except Exception as e: - logger.error(f"Token exchange error: {e}") - raise - - async def _validate_flow1_token(self, token: str): - """Validate that token has correct audience for MCP server. - - Accepts either: - - "mcp-server" (Progressive Consent legacy) - - self.client_id (external IdP, e.g., "nextcloud-mcp-server") - - Args: - token: JWT token to validate - - Raises: - ValueError: If token is invalid or has wrong audience - """ - try: - # Decode without verification first to check audience - # In production, should verify signature against JWKS - payload = jwt.decode(token, options={"verify_signature": False}) - - # Check audience - audience = payload.get("aud", []) - if isinstance(audience, str): - audience = [audience] - - # Accept either "mcp-server" (Progressive Consent) or client_id (external IdP) - valid_audiences = ["mcp-server"] - if self.client_id: - valid_audiences.append(self.client_id) - - if not any(aud in audience for aud in valid_audiences): - raise ValueError( - f"Invalid token audience. Expected one of {valid_audiences}, got {audience}" - ) - - # Check expiration - exp = payload.get("exp", 0) - if exp < time.time(): - raise ValueError("Token has expired") - - except jwt.DecodeError as e: - raise ValueError(f"Invalid JWT token: {e}") - - def _extract_user_id(self, token: str) -> str: - """Extract user ID from JWT token. - - Args: - token: JWT token - - Returns: - User ID from token - """ - try: - payload = jwt.decode(token, options={"verify_signature": False}) - - # Try standard claims in order of preference - user_id = ( - payload.get("sub") - or payload.get("preferred_username") - or payload.get("email") - or payload.get("name") - ) - - if not user_id: - raise ValueError("No user identifier in token") - - return user_id - - except jwt.DecodeError as e: - raise ValueError(f"Failed to extract user ID: {e}") - - async def _check_provisioning(self, user_id: str) -> bool: - """Check if user has completed Flow 2 provisioning. - - Args: - user_id: User identifier - - Returns: - True if provisioned, False otherwise - """ - await self._ensure_storage() - assert self.storage is not None # _ensure_storage() ensures this - token_data = await self.storage.get_refresh_token(user_id) - return token_data is not None - - async def _get_user_refresh_token(self, user_id: str) -> Optional[str]: - """Get stored refresh token for user from Flow 2 provisioning. - - Args: - user_id: User identifier - - Returns: - Refresh token if found, None otherwise - """ - await self._ensure_storage() - assert self.storage is not None # _ensure_storage() ensures this - token_data = await self.storage.get_refresh_token(user_id) - if token_data: - return token_data.get("refresh_token") - return None - - async def _perform_token_exchange( - self, - subject_token: str, - refresh_token: str, - requested_scopes: list[str], - requested_audience: str, - ) -> Tuple[str, int]: - """Perform RFC 8693 token exchange with IdP. - - Args: - subject_token: The token being exchanged (Flow 1 token) - refresh_token: User's stored refresh token for delegation - requested_scopes: Minimal scopes for this operation - requested_audience: Target audience - - Returns: - Tuple of (access_token, expires_in) - """ - # Discover token endpoint - discovery = await self._discover_endpoints() - token_endpoint = discovery.get("token_endpoint") - - if not token_endpoint: - raise RuntimeError("No token endpoint found in discovery") - - # Build token exchange request per RFC 8693 - data = { - # Token exchange grant type - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - # The token we're exchanging (Flow 1 MCP token) - "subject_token": subject_token, - "subject_token_type": self.TOKEN_TYPE_ACCESS_TOKEN, - # Use refresh token as actor token (proves we have delegation rights) - "actor_token": refresh_token, - "actor_token_type": self.TOKEN_TYPE_ACCESS_TOKEN, - # Requested token properties - "requested_token_type": self.TOKEN_TYPE_ACCESS_TOKEN, - "audience": requested_audience, - "scope": " ".join(requested_scopes), - } - - # Add client credentials if configured - if self.client_id and self.client_secret: - data["client_id"] = self.client_id - data["client_secret"] = self.client_secret - - try: - # Attempt RFC 8693 token exchange - response = await self.http_client.post( - token_endpoint, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - - if response.status_code == 400: - # Token exchange might not be supported, fall back to refresh grant - logger.info( - "Token exchange not supported, falling back to refresh grant" - ) - return await self._fallback_refresh_grant( - refresh_token=refresh_token, - requested_scopes=requested_scopes, - token_endpoint=token_endpoint, - ) - - response.raise_for_status() - result = response.json() - - access_token = result.get("access_token") - expires_in = result.get("expires_in", 300) # Default 5 minutes - - if not access_token: - raise RuntimeError("No access token in exchange response") - - return access_token, expires_in - - except httpx.HTTPStatusError as e: - logger.error(f"Token exchange failed: {e.response.text}") - raise RuntimeError(f"Token exchange failed: {e}") - except Exception as e: - logger.error(f"Token exchange error: {e}") - raise - - async def _fallback_refresh_grant( - self, refresh_token: str, requested_scopes: list[str], token_endpoint: str - ) -> Tuple[str, int]: - """Fallback to standard refresh token grant if token exchange not supported. - - This is less secure than token exchange but provides compatibility. - - Args: - refresh_token: User's stored refresh token - requested_scopes: Minimal scopes for this operation - token_endpoint: Token endpoint URL - - Returns: - Tuple of (access_token, expires_in) - """ - data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "scope": " ".join(requested_scopes), # Request minimal scopes - } - - # Add client credentials if configured - if self.client_id and self.client_secret: - data["client_id"] = self.client_id - data["client_secret"] = self.client_secret - - try: - response = await self.http_client.post( - token_endpoint, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - response.raise_for_status() - - result = response.json() - - access_token = result.get("access_token") - expires_in = result.get("expires_in", 300) # Default 5 minutes - - if not access_token: - raise RuntimeError("No access token in refresh response") - - # Log that we're using fallback - logger.warning( - f"Using refresh grant fallback for token exchange. " - f"Scopes: {requested_scopes}" - ) - - return access_token, expires_in - - except httpx.HTTPStatusError as e: - logger.error(f"Refresh grant failed: {e.response.text}") - raise RuntimeError(f"Refresh grant failed: {e}") - except Exception as e: - logger.error(f"Refresh grant error: {e}") - raise - - -# Singleton instance -_token_exchange_service: Optional[TokenExchangeService] = None - - -async def get_token_exchange_service() -> TokenExchangeService: - """Get or create the singleton token exchange service. - - Note: Storage is initialized lazily only when needed for delegation operations. - Pure RFC 8693 exchange (MCP tools) doesn't require storage. - - Returns: - TokenExchangeService instance - """ - global _token_exchange_service - - if _token_exchange_service is None: - _token_exchange_service = TokenExchangeService() - # Storage is initialized lazily via _ensure_storage() when needed - - return _token_exchange_service - - -async def exchange_token_for_delegation( - flow1_token: str, requested_scopes: list[str], requested_audience: str = "nextcloud" -) -> Tuple[str, int]: - """Convenience function to exchange tokens (Progressive Consent with refresh tokens). - - NOTE: This is for background jobs only. For MCP tool calls, use exchange_token_for_audience(). - - Args: - flow1_token: The MCP session token (aud: "mcp-server") - requested_scopes: Scopes needed for this operation - requested_audience: Target audience (usually "nextcloud") - - Returns: - Tuple of (delegated_token, expires_in) - """ - service = await get_token_exchange_service() - return await service.exchange_token_for_delegation( - flow1_token=flow1_token, - requested_scopes=requested_scopes, - requested_audience=requested_audience, - ) - - -async def exchange_token_for_audience( - subject_token: str, - requested_audience: str = "nextcloud", - requested_scopes: list[str] | None = None, -) -> Tuple[str, int]: - """Convenience function for pure RFC 8693 token exchange (no refresh tokens). - - Use this for ALL MCP tool calls (request-time operations). - - Args: - subject_token: Token being exchanged (from MCP client) - requested_audience: Target audience (usually "nextcloud") - requested_scopes: Optional scopes (may not be supported by all IdPs) - - Returns: - Tuple of (access_token, expires_in) - """ - service = await get_token_exchange_service() - return await service.exchange_token_for_audience( - subject_token=subject_token, - requested_audience=requested_audience, - requested_scopes=requested_scopes, - ) diff --git a/nextcloud_mcp_server/auth/unified_verifier.py b/nextcloud_mcp_server/auth/unified_verifier.py index 41147f7a..cf4b814e 100644 --- a/nextcloud_mcp_server/auth/unified_verifier.py +++ b/nextcloud_mcp_server/auth/unified_verifier.py @@ -60,7 +60,7 @@ class UnifiedTokenVerifier(TokenVerifier): settings: Application settings containing OAuth configuration """ self.settings = settings - self.mode = "exchange" if settings.enable_token_exchange else "multi-audience" + self.mode = "multi-audience" # Common components for all modes self.http_client = nextcloud_httpx_client(timeout=10.0) diff --git a/nextcloud_mcp_server/config.py b/nextcloud_mcp_server/config.py index 6405c938..cc4aa50c 100644 --- a/nextcloud_mcp_server/config.py +++ b/nextcloud_mcp_server/config.py @@ -138,8 +138,7 @@ class Settings: # Deployment mode (ADR-021: explicit mode selection) # Optional: If not set, mode is auto-detected from other settings - # Valid values: single_user_basic, multi_user_basic, oauth_single_audience, - # oauth_token_exchange + # Valid values: single_user_basic, multi_user_basic, oauth_single_audience deployment_mode: str | None = None # OAuth/OIDC settings @@ -168,7 +167,6 @@ class Settings: userinfo_uri: str | None = None # Progressive Consent settings (always enabled - no flag needed) - enable_token_exchange: bool = False enable_offline_access: bool = False # Multi-user BasicAuth pass-through mode (ADR-019 interim solution) @@ -179,9 +177,6 @@ class Settings: # Login Flow v2 settings (ADR-022) enable_login_flow: bool = False - # Token exchange cache settings - token_exchange_cache_ttl: int = 300 # seconds (5 minutes default) - # Token and webhook storage settings # TOKEN_ENCRYPTION_KEY: Optional - Only required for OAuth token storage operations. # Webhook tracking works without encryption key. @@ -507,9 +502,6 @@ def get_settings() -> Settings: introspection_uri=os.getenv("INTROSPECTION_URI"), userinfo_uri=os.getenv("USERINFO_URI"), # Progressive Consent settings (always enabled) - enable_token_exchange=( - os.getenv("ENABLE_TOKEN_EXCHANGE", "false").lower() == "true" - ), enable_offline_access=enable_background_operations, # Smart dependency resolution # Multi-user BasicAuth pass-through mode enable_multi_user_basic_auth=( @@ -517,8 +509,6 @@ def get_settings() -> Settings: ), # Login Flow v2 settings (ADR-022) enable_login_flow=(os.getenv("ENABLE_LOGIN_FLOW", "false").lower() == "true"), - # Token exchange cache settings - token_exchange_cache_ttl=int(os.getenv("TOKEN_EXCHANGE_CACHE_TTL", "300")), # Token and webhook storage settings (encryption key optional for webhook-only usage) token_encryption_key=os.getenv("TOKEN_ENCRYPTION_KEY"), token_storage_db=os.getenv("TOKEN_STORAGE_DB", "/tmp/tokens.db"), diff --git a/nextcloud_mcp_server/config_validators.py b/nextcloud_mcp_server/config_validators.py index d425367f..d2e4ff8a 100644 --- a/nextcloud_mcp_server/config_validators.py +++ b/nextcloud_mcp_server/config_validators.py @@ -26,7 +26,6 @@ class AuthMode(Enum): SINGLE_USER_BASIC = "single_user_basic" MULTI_USER_BASIC = "multi_user_basic" OAUTH_SINGLE_AUDIENCE = "oauth_single" - OAUTH_TOKEN_EXCHANGE = "oauth_exchange" @dataclass @@ -66,7 +65,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { ], forbidden=[ "enable_multi_user_basic_auth", - "enable_token_exchange", "oidc_client_id", "oidc_client_secret", ], @@ -100,7 +98,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { forbidden=[ "nextcloud_username", "nextcloud_password", - "enable_token_exchange", ], conditional={ "enable_offline_access": [ @@ -141,7 +138,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { forbidden=[ "nextcloud_username", "nextcloud_password", - "enable_token_exchange", "enable_multi_user_basic_auth", ], conditional={ @@ -157,46 +153,6 @@ MODE_REQUIREMENTS: dict[AuthMode, ModeRequirements] = { "Tokens work for both MCP server and Nextcloud APIs (pass-through). " "Uses Dynamic Client Registration if credentials not provided.", ), - AuthMode.OAUTH_TOKEN_EXCHANGE: ModeRequirements( - required=["nextcloud_host", "enable_token_exchange"], - optional=[ - # OAuth credentials - "oidc_client_id", - "oidc_client_secret", - "oidc_discovery_url", - # Token exchange settings - "token_exchange_cache_ttl", - # Offline access - "enable_offline_access", - "token_encryption_key", - "token_storage_db", - # Vector sync - "vector_sync_enabled", - "qdrant_url", - "qdrant_location", - "ollama_base_url", - "ollama_embedding_model", - "openai_api_key", - "openai_embedding_model", - ], - forbidden=[ - "nextcloud_username", - "nextcloud_password", - "enable_multi_user_basic_auth", - ], - conditional={ - "enable_offline_access": [ - "token_encryption_key", - "token_storage_db", - ], - # Note: vector_sync_enabled (now ENABLE_SEMANTIC_SEARCH) automatically - # enables background operations in multi-user modes. No explicit - # enable_offline_access setting required. - }, - description="OAuth multi-user deployment with token exchange (RFC 8693). " - "MCP tokens are separate from Nextcloud tokens. " - "Server exchanges MCP token for Nextcloud token on each request.", - ), } @@ -205,10 +161,9 @@ def detect_auth_mode(settings: Settings) -> AuthMode: Mode detection priority (ADR-021): 0. Explicit MCP_DEPLOYMENT_MODE (if set) - NEW in ADR-021 - 1. Token exchange (most specific OAuth mode) - 2. Multi-user BasicAuth - 3. Single-user BasicAuth - 4. OAuth single-audience (default OAuth mode) + 1. Multi-user BasicAuth + 2. Single-user BasicAuth + 3. OAuth single-audience (default OAuth mode) Args: settings: Application settings @@ -231,7 +186,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode: "single_user_basic": AuthMode.SINGLE_USER_BASIC, "multi_user_basic": AuthMode.MULTI_USER_BASIC, "oauth_single_audience": AuthMode.OAUTH_SINGLE_AUDIENCE, - "oauth_token_exchange": AuthMode.OAUTH_TOKEN_EXCHANGE, } if mode_str not in mode_map: @@ -246,10 +200,6 @@ def detect_auth_mode(settings: Settings) -> AuthMode: return explicit_mode # Auto-detection (existing behavior) - # Check for token exchange (most specific OAuth mode) - if settings.enable_token_exchange: - return AuthMode.OAUTH_TOKEN_EXCHANGE - # Check for multi-user BasicAuth if settings.enable_multi_user_basic_auth: return AuthMode.MULTI_USER_BASIC @@ -351,10 +301,7 @@ def validate_configuration(settings: Settings) -> tuple[AuthMode, list[str]]: f"{settings.nextcloud_host}" ) - if mode in [ - AuthMode.OAUTH_SINGLE_AUDIENCE, - AuthMode.OAUTH_TOKEN_EXCHANGE, - ]: + if mode == AuthMode.OAUTH_SINGLE_AUDIENCE: # If OAuth credentials not provided, DCR must be available # (This is a runtime check, not a config check, so we just warn) if not settings.oidc_client_id or not settings.oidc_client_secret: diff --git a/nextcloud_mcp_server/context.py b/nextcloud_mcp_server/context.py index 6a380ea7..c94b15be 100644 --- a/nextcloud_mcp_server/context.py +++ b/nextcloud_mcp_server/context.py @@ -5,10 +5,7 @@ import logging from httpx import BasicAuth from mcp.server.fastmcp import Context -from nextcloud_mcp_server.auth.context_helper import ( - get_client_from_context, - get_session_client_from_context, -) +from nextcloud_mcp_server.auth.context_helper import get_client_from_context from nextcloud_mcp_server.auth.scope_authorization import ProvisioningRequiredError from nextcloud_mcp_server.auth.storage import get_shared_storage from nextcloud_mcp_server.client import NextcloudClient @@ -24,18 +21,9 @@ async def get_client(ctx: Context) -> NextcloudClient: Supports the following deployment modes: 1. BasicAuth mode: Returns shared client from lifespan context - - 2. OAuth mode: - a. Multi-audience mode (ENABLE_TOKEN_EXCHANGE=false, default): - Token already contains both MCP and Nextcloud audiences - use directly - b. Token exchange mode (ENABLE_TOKEN_EXCHANGE=true): - Exchange MCP token for Nextcloud token via RFC 8693 - - SECURITY: Token passthrough has been REMOVED. All OAuth modes validate - proper token audiences per MCP Security Best Practices specification. - - Note: Nextcloud doesn't support OAuth scopes natively. Scopes are enforced - by the MCP server via @require_scopes decorator, not by the IdP. + 2. Login Flow v2: OAuth for MCP session, app password for Nextcloud API + 3. Multi-user BasicAuth: Credentials passed through from request headers + 4. OAuth multi-audience: Token contains both MCP and Nextcloud audiences This function automatically detects the authentication mode by checking the type of the lifespan context. @@ -74,20 +62,11 @@ async def get_client(ctx: Context) -> NextcloudClient: if hasattr(lifespan_ctx, "client"): return lifespan_ctx.client - # OAuth mode (has 'nextcloud_host' attribute) + # OAuth multi-audience mode (has 'nextcloud_host' attribute) if hasattr(lifespan_ctx, "nextcloud_host"): - if settings.enable_token_exchange: - # Mode 2: Exchange MCP token for Nextcloud token - # Token was validated to have MCP audience in UnifiedTokenVerifier - # Now exchange it for Nextcloud audience - return await get_session_client_from_context( - ctx, lifespan_ctx.nextcloud_host - ) - else: - # Mode 1: Multi-audience token - use directly - # Token was validated to have MCP audience in UnifiedTokenVerifier - # Nextcloud will independently validate its own audience when receiving API calls - return get_client_from_context(ctx, lifespan_ctx.nextcloud_host) + # Token was validated to have MCP audience in UnifiedTokenVerifier + # Nextcloud will independently validate its own audience when receiving API calls + return get_client_from_context(ctx, lifespan_ctx.nextcloud_host) # Unknown context type raise AttributeError( diff --git a/nextcloud_mcp_server/observability/metrics.py b/nextcloud_mcp_server/observability/metrics.py index fe19c372..587336cb 100644 --- a/nextcloud_mcp_server/observability/metrics.py +++ b/nextcloud_mcp_server/observability/metrics.py @@ -125,12 +125,6 @@ oauth_token_validations_total = Counter( ["method", "result"], # method: introspect | jwt; result: valid | invalid | error ) -oauth_token_exchange_total = Counter( - "mcp_oauth_token_exchange_total", - "Total OAuth token exchange operations (RFC 8693)", - ["status"], # status: success | error -) - oauth_token_cache_hits_total = Counter( "mcp_oauth_token_cache_hits_total", "Total OAuth token cache lookups", diff --git a/nextcloud_mcp_server/observability/tracing.py b/nextcloud_mcp_server/observability/tracing.py index 2910cdd1..4904ee69 100644 --- a/nextcloud_mcp_server/observability/tracing.py +++ b/nextcloud_mcp_server/observability/tracing.py @@ -232,7 +232,7 @@ def trace_oauth_operation(operation: str, details: dict[str, Any] | None = None) pass Args: - operation: OAuth operation name (e.g., "token.validate", "token.exchange") + operation: OAuth operation name (e.g., "token.validate", "token.refresh") details: Optional operation details (sensitive data will be sanitized) Returns: diff --git a/pyproject.toml b/pyproject.toml index 3bc238e2..14f417c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -72,7 +72,6 @@ log_level = "ERROR" markers = [ "unit: Fast unit tests with mocked dependencies", "integration: Integration tests requiring Docker containers", - "oauth: OAuth tests requiring Playwright (slowest)", "smoke: Critical path smoke tests for quick validation", "keycloak: OAuth tests that utilize keycloak external identity provider", "login_flow: Login Flow v2 integration tests (ADR-022)", diff --git a/tests/client/test_oauth.py b/tests/client/test_oauth.py deleted file mode 100644 index 284f0b2f..00000000 --- a/tests/client/test_oauth.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Integration tests for OAuth authentication.""" - -import logging -import os - -import pytest -from httpx import HTTPStatusError - -from nextcloud_mcp_server.auth import BearerAuth -from nextcloud_mcp_server.client import NextcloudClient - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -# OAuth Client Tests - - -async def test_oauth_client_capabilities(nc_oauth_client: NextcloudClient): - """Test that OAuth client can fetch capabilities.""" - capabilities = await nc_oauth_client.capabilities() - - assert capabilities is not None - assert "ocs" in capabilities - logger.info( - f"OAuth client successfully fetched capabilities: {capabilities.get('ocs').get('meta')}" - ) - - -async def test_oauth_client_notes_list(nc_oauth_client: NextcloudClient): - """Test that OAuth client can list notes.""" - notes = [note async for note in nc_oauth_client.notes.get_all_notes()] - - assert isinstance(notes, list) - logger.info(f"OAuth client successfully listed {len(notes)} notes") - - -async def test_oauth_client_create_note(nc_oauth_client: NextcloudClient): - """Test that OAuth client can create and delete a note.""" - # Create note - note_title = "OAuth Test Note" - note_content = "This note was created with OAuth authentication" - - created_note = await nc_oauth_client.notes.create_note( - title=note_title, content=note_content - ) - - assert created_note is not None - assert created_note.get("title") == note_title - note_id = created_note.get("id") - assert note_id is not None - - logger.info(f"OAuth client successfully created note with ID: {note_id}") - - # Clean up - delete the note - try: - await nc_oauth_client.notes.delete_note(note_id=note_id) - logger.info(f"OAuth client successfully deleted note {note_id}") - except Exception as e: - logger.error(f"Failed to clean up test note {note_id}: {e}") - raise - - -# OAuth Token Validation Tests - - -async def test_token_in_request_headers( - nc_oauth_client: NextcloudClient, playwright_oauth_token: str -): - """Verify that bearer token is being used in requests.""" - # The client should be using BearerAuth - assert nc_oauth_client._client.auth is not None - - # Make a request and verify it works - capabilities = await nc_oauth_client.capabilities() - assert capabilities is not None - - logger.info("OAuth bearer token is correctly included in requests") - - -async def test_invalid_token_fails(): - """Test that an invalid token results in authentication failure.""" - nextcloud_host = os.getenv("NEXTCLOUD_HOST") - if not nextcloud_host: - pytest.skip("NEXTCLOUD_HOST not set") - - # Create client with invalid token using BearerAuth - invalid_client = NextcloudClient( - base_url=nextcloud_host, - username="testuser", - auth=BearerAuth("invalid_token_12345"), - ) - - # Attempt to use a protected endpoint - should fail with 401 - # Note: capabilities endpoint is public and doesn't require auth - with pytest.raises(HTTPStatusError) as exc_info: - _ = [note async for note in invalid_client.notes.get_all_notes()] - - assert exc_info.value.response.status_code == 401 - - await invalid_client.close() - logger.info("Invalid OAuth token correctly rejected") diff --git a/tests/client/test_oauth_playwright.py b/tests/client/test_oauth_playwright.py deleted file mode 100644 index 588404c2..00000000 --- a/tests/client/test_oauth_playwright.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Integration tests for Playwright-based OAuth authentication.""" - -import logging - -import pytest - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -async def test_playwright_oauth_token_acquisition(playwright_oauth_token: str): - """Test that Playwright can acquire an OAuth token automatically.""" - assert playwright_oauth_token is not None - assert isinstance(playwright_oauth_token, str) - assert len(playwright_oauth_token) > 0 - logger.info( - f"Successfully acquired OAuth token via Playwright: {playwright_oauth_token[:20]}..." - ) - - -async def test_oauth_client_with_playwright_flow(nc_oauth_client): - """Test that OAuth client created via Playwright flow can access Nextcloud APIs.""" - # Test 1: Check capabilities - capabilities = await nc_oauth_client.capabilities() - assert capabilities is not None - logger.info("OAuth client (Playwright) successfully fetched capabilities") - - # Test 2: List notes - notes = [note async for note in nc_oauth_client.notes.get_all_notes()] - assert isinstance(notes, list) - logger.info(f"OAuth client (Playwright) successfully listed {len(notes)} notes") diff --git a/tests/conftest.py b/tests/conftest.py index ccc6bc7f..74ac72ba 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,4 @@ import base64 -import hashlib import json import logging import os @@ -21,6 +20,7 @@ from mcp import ClientSession from mcp.client.session import RequestContext from mcp.client.streamable_http import streamablehttp_client from mcp.types import ElicitRequestParams, ElicitResult, ErrorData +from playwright.async_api import TimeoutError as PlaywrightTimeoutError from nextcloud_mcp_server.client import NextcloudClient @@ -2005,33 +2005,37 @@ async def _handle_oauth_consent_screen(page, username: str = "user"): f" ⊗ Scope checkbox {i + 1} disabled (required scope)" ) - # Click the Allow button to grant consent - # Check button exists first - allow_button_locator = page.locator('button:has-text("Allow")') + # Click the Allow button to grant consent with retry logic. + # Uses Playwright's native click (dispatches proper browser events that + # trigger Vue.js handlers) instead of JS btn.click() which can miss them. + allow_button = page.locator('button:has-text("Allow")') - if await allow_button_locator.count() > 0: + if await allow_button.count() > 0: logger.info(f" Clicking Allow button to grant consent for {username}...") - # Use JavaScript click to handle consent buttons that may be outside viewport - # This is more reliable than Playwright's click which requires element visibility - logger.info( - " Using JavaScript click for consent (handles viewport issues)..." - ) - await page.evaluate( - """ - const buttons = document.querySelectorAll('button'); - for (const btn of buttons) { - if (btn.textContent.trim() === 'Allow') { - btn.click(); - break; - } - } - """ - ) + for attempt in range(3): + await allow_button.scroll_into_view_if_needed() + await allow_button.click() + try: + await page.wait_for_url( + lambda url: "/consent" not in url, timeout=10000 + ) + logger.info(f" Consent granted for {username}") + return True + except (TimeoutError, PlaywrightTimeoutError): + if attempt == 2: + screenshot_path = f"/tmp/consent_click_failed_{username}.png" + await page.screenshot(path=screenshot_path) + logger.error( + f" Consent click failed after 3 attempts for {username}, " + f"screenshot: {screenshot_path}" + ) + raise + logger.warning( + f" Consent click attempt {attempt + 1} didn't navigate, retrying..." + ) - await page.wait_for_load_state("networkidle", timeout=30000) - logger.info(f" Consent granted for {username}") - return True + raise RuntimeError("consent click retry loop exited unexpectedly") else: logger.error(f" Allow button not found for {username}") return False @@ -2047,6 +2051,7 @@ async def _get_oauth_token_with_scopes( oauth_callback_server, scopes: str, resource: str | None = None, + mcp_server_base_url: str = "http://localhost:8004", # login-flow container port ) -> str: """ Helper function to obtain OAuth token with specific scopes. @@ -2057,6 +2062,7 @@ async def _get_oauth_token_with_scopes( oauth_callback_server: OAuth callback server fixture scopes: Space-separated list of scopes (e.g., "openid profile email notes:read") resource: Optional resource parameter (RFC 8707) for token audience + mcp_server_base_url: Base URL of the MCP server for resource metadata discovery Returns: OAuth access token string with requested scopes @@ -2085,7 +2091,6 @@ async def _get_oauth_token_with_scopes( # If no resource provided, fetch from MCP server metadata if resource is None: - mcp_server_base_url = "http://localhost:8001" try: resource_metadata = await get_mcp_server_resource_metadata( mcp_server_base_url @@ -2862,433 +2867,8 @@ async def test_user_in_group(nc_client: NextcloudClient, test_user, test_group): # =========================================================================================== -# Keycloak External IdP OAuth Fixtures -# =========================================================================================== - - -@pytest.fixture(scope="session") -async def keycloak_oauth_client_credentials(anyio_backend, oauth_callback_server): - """ - Fixture to obtain Keycloak OAuth client credentials for external IdP testing. - - Uses pre-configured client from keycloak/realm-export.json (no DCR needed). - The client (nextcloud-mcp-server) is already configured with: - - serviceAccountsEnabled=true - - token.exchange.grant.enabled=true - - client.token.exchange.standard.enabled=true - - Returns: - Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint) - """ - # Get Keycloak configuration from environment - keycloak_discovery_url = os.getenv( - "OIDC_DISCOVERY_URL", - "http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration", - ) - client_id = os.getenv("OIDC_CLIENT_ID", "nextcloud-mcp-server") - client_secret = os.getenv("OIDC_CLIENT_SECRET", "mcp-secret-change-in-production") - - if not all([keycloak_discovery_url, client_id, client_secret]): - pytest.skip( - "Keycloak OAuth requires OIDC_DISCOVERY_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET" - ) - - # Get callback URL from the real callback server - auth_states, callback_url = oauth_callback_server - - logger.info("Setting up Keycloak external IdP OAuth client credentials...") - logger.info(f"Using Keycloak discovery URL: {keycloak_discovery_url}") - logger.info(f"Using static client credentials: {client_id}") - logger.info(f"Using real callback server at: {callback_url}") - - async with httpx.AsyncClient(timeout=30.0) as http_client: - # OIDC Discovery - discovery_response = await http_client.get(keycloak_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") - - if not token_endpoint or not authorization_endpoint: - raise ValueError( - "Keycloak OIDC discovery missing required endpoints (token_endpoint or authorization_endpoint)" - ) - - logger.info(f"✓ Discovered token endpoint: {token_endpoint}") - logger.info(f"✓ Discovered authorization endpoint: {authorization_endpoint}") - - yield ( - client_id, - client_secret, - callback_url, - token_endpoint, - authorization_endpoint, - ) - - # No cleanup needed - client is pre-configured in realm export - - -async def _get_keycloak_oauth_token( - browser, - keycloak_oauth_client_credentials, - oauth_callback_server, - scopes: str, - username: str = "admin", - password: str = "admin", -) -> str: - """ - Helper function to obtain OAuth token from Keycloak using Playwright. - - Args: - browser: Playwright browser instance - keycloak_oauth_client_credentials: Tuple of Keycloak OAuth client credentials - oauth_callback_server: OAuth callback server fixture - scopes: Space-separated list of scopes - username: Keycloak username (default: admin) - password: Keycloak password (default: admin) - - Returns: - OAuth access token string from Keycloak - """ - - # Get auth_states dict from callback server - auth_states, _ = oauth_callback_server - - # Unpack Keycloak client credentials - client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = ( - keycloak_oauth_client_credentials - ) - - logger.info(f"Starting Playwright-based Keycloak OAuth flow with scopes: {scopes}") - logger.info(f"Using Keycloak client: {client_id}") - logger.info(f"Using real callback server at: {callback_url}") - logger.info(f"Authenticating as Keycloak user: {username}") - - # Generate unique state parameter for this OAuth flow - state = secrets.token_urlsafe(32) - logger.debug(f"Generated state: {state[:16]}...") - - # Generate PKCE parameters (required by Keycloak client configuration) - code_verifier = secrets.token_urlsafe(64) # 86 chars base64url - code_challenge = ( - base64.urlsafe_b64encode(hashlib.sha256(code_verifier.encode()).digest()) - .decode() - .rstrip("=") - ) - logger.debug(f"Generated PKCE code_challenge: {code_challenge[:20]}...") - - # URL-encode scopes - scopes_encoded = quote(scopes, safe="") - - # Construct authorization URL with state, scopes, and PKCE parameters - 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}&" - f"code_challenge={code_challenge}&" - f"code_challenge_method=S256" - ) - - logger.info(f"Authorization URL: {auth_url[:100]}...") - - # Create browser context and page - context = await browser.new_context() - page = await context.new_page() - - try: - # Navigate to Keycloak authorization endpoint - logger.info("Navigating to Keycloak authorization endpoint...") - await page.goto(auth_url, wait_until="networkidle", timeout=30000) - - # Handle Keycloak login page - # Keycloak uses input#username and input#password (different from Nextcloud) - logger.info(f"Filling Keycloak login credentials for {username}...") - await page.wait_for_selector("input#username", timeout=10000) - await page.fill("input#username", username) - await page.fill("input#password", password) - - logger.info("Submitting Keycloak login form...") - # Submit the form and wait for navigation - # Use JavaScript to submit the form directly (more reliable than clicking button) - async with page.expect_navigation(timeout=30000): - await page.evaluate("document.querySelector('form').submit()") - - logger.info(f"Keycloak login submitted for {username}, redirected to callback") - - # Check if we need to handle consent screen - # Keycloak consent screen has "Yes" button - consent_button = page.locator('input[name="accept"][value="Yes"]') - if await consent_button.count() > 0: - logger.info("Keycloak consent screen detected, clicking Yes...") - await consent_button.click() - await page.wait_for_load_state("networkidle", timeout=30000) - logger.info("Keycloak consent granted") - - # Wait for callback server to receive auth code with timeout - logger.info(f"Waiting for auth code with state: {state[:16]}...") - timeout = 30 # seconds - start_time = time.time() - auth_code = None - - 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 anyio.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 (with PKCE code_verifier) - logger.info("Exchanging authorization code for access token with PKCE...") - 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, - "code_verifier": code_verifier, # PKCE verifier - }, - ) - - 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 Keycloak OAuth access token with scopes: {scopes}" - ) - return access_token - - -@pytest.fixture(scope="session") -async def keycloak_oauth_token( - anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server -) -> str: - """ - Fixture to obtain an OAuth access token from Keycloak using Playwright automation. - - This fixture tests the external IdP flow where: - 1. User authenticates with Keycloak (external IdP) - 2. Keycloak issues an access token with Nextcloud custom scopes - 3. Token is used to access Nextcloud APIs via user_oidc app validation - - The Nextcloud custom scopes (notes:read, calendar:write, etc.) are now defined - in Keycloak's realm configuration and can be requested in the OAuth flow. - - Returns: - OAuth access token from Keycloak for the admin user with full scopes - """ - # Standard OIDC scopes + Nextcloud custom scopes (now defined in Keycloak realm) - default_scopes = "openid profile email offline_access notes:read notes:write calendar:read calendar:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write todo:read todo:write" - - return await _get_keycloak_oauth_token( - browser, - keycloak_oauth_client_credentials, - oauth_callback_server, - scopes=default_scopes, - username="admin", - password="admin", - ) - - -@pytest.fixture(scope="session") -async def keycloak_oauth_token_read_only( - anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server -) -> str: - """ - Fixture to obtain a Keycloak OAuth token with only read scopes. - - This token will only be able to perform read operations and should - have write tools filtered out from the tool list. - - Returns: - OAuth access token from Keycloak for test_read_only user with read-only scopes - """ - return await _get_keycloak_oauth_token( - browser, - keycloak_oauth_client_credentials, - oauth_callback_server, - scopes=DEFAULT_READ_SCOPES, - username="test_read_only", - password="test123", - ) - - -@pytest.fixture(scope="session") -async def keycloak_oauth_token_write_only( - anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server -) -> str: - """ - Fixture to obtain a Keycloak OAuth token with only write scopes. - - This token will only be able to perform write operations and should - have read tools filtered out from the tool list. - - Returns: - OAuth access token from Keycloak for test_write_only user with write-only scopes - """ - return await _get_keycloak_oauth_token( - browser, - keycloak_oauth_client_credentials, - oauth_callback_server, - scopes=DEFAULT_WRITE_SCOPES, - username="test_write_only", - password="test123", - ) - - -@pytest.fixture(scope="session") -async def keycloak_oauth_token_no_custom_scopes( - anyio_backend, browser, keycloak_oauth_client_credentials, oauth_callback_server -) -> str: - """ - Fixture to obtain a Keycloak OAuth token with NO custom scopes. - - Tests the security behavior when a user grants only default OIDC scopes - (openid, profile, email) but declines application-specific scopes. - - Expected behavior: Should see 0 tools (all tools require custom scopes). - - Returns: - OAuth access token from Keycloak for test_no_scopes user with no custom scopes - """ - return await _get_keycloak_oauth_token( - browser, - keycloak_oauth_client_credentials, - oauth_callback_server, - scopes="openid profile email", # No custom scopes - username="test_no_scopes", - password="test123", - ) - - -@pytest.fixture(scope="session") -async def nc_mcp_keycloak_client( - anyio_backend, keycloak_oauth_token -) -> AsyncGenerator[ClientSession, Any]: - """ - Session-scoped fixture providing an MCP client session authenticated with Keycloak tokens. - - This MCP client connects to the mcp-keycloak service (port 8002) which is configured - to use Keycloak as an external identity provider. The token flow is: - - 1. Keycloak issues OAuth token (via keycloak_oauth_token fixture) - 2. MCP client uses token to authenticate with MCP server - 3. MCP server validates token via Nextcloud user_oidc app - 4. MCP server uses validated token to access Nextcloud APIs - - This tests ADR-002 external IdP integration. - - Yields: - MCP client session for testing tools/resources with Keycloak auth - """ - mcp_url = "http://localhost:8002/mcp" - logger.info(f"Creating MCP client session for Keycloak external IdP at {mcp_url}") - logger.info("Using Keycloak OAuth token for authentication") - - async for session in create_mcp_client_session( - url=mcp_url, token=keycloak_oauth_token, client_name="Keycloak External IdP MCP" - ): - logger.info("✓ MCP client session established with Keycloak authentication") - yield session - logger.info("✓ MCP client session closed") - - -@pytest.fixture(scope="session") -async def nc_mcp_keycloak_client_read_only( - anyio_backend, keycloak_oauth_token_read_only -) -> AsyncGenerator[ClientSession, Any]: - """ - MCP client session authenticated with Keycloak read-only token. - - This client should only see read tools and should get filtered - write tools based on token scopes. - - Uses JWT tokens because they embed scope information in claims, - enabling proper scope-based tool filtering. - """ - mcp_url = "http://localhost:8002/mcp" - logger.info(f"Creating read-only MCP client session for Keycloak at {mcp_url}") - - async for session in create_mcp_client_session( - url=mcp_url, - token=keycloak_oauth_token_read_only, - client_name="Keycloak Read-Only MCP", - ): - yield session - - -@pytest.fixture(scope="session") -async def nc_mcp_keycloak_client_write_only( - anyio_backend, keycloak_oauth_token_write_only -) -> AsyncGenerator[ClientSession, Any]: - """ - MCP client session authenticated with Keycloak write-only token. - - This client should only see write tools and should get filtered - read tools based on token scopes. - - Uses JWT tokens because they embed scope information in claims, - enabling proper scope-based tool filtering. - """ - mcp_url = "http://localhost:8002/mcp" - logger.info(f"Creating write-only MCP client session for Keycloak at {mcp_url}") - - async for session in create_mcp_client_session( - url=mcp_url, - token=keycloak_oauth_token_write_only, - client_name="Keycloak Write-Only MCP", - ): - yield session - - -@pytest.fixture(scope="session") -async def nc_mcp_keycloak_client_no_custom_scopes( - anyio_backend, keycloak_oauth_token_no_custom_scopes -) -> AsyncGenerator[ClientSession, Any]: - """ - MCP client session authenticated with Keycloak token without custom scopes. - - This client has only OIDC default scopes (openid, profile, email) without - application-specific scopes (notes:read, notes:write, etc.). - - Expected behavior: Should see 0 tools (all tools require custom scopes). - - Uses JWT tokens because they embed scope information in claims, - enabling proper scope-based tool filtering. - """ - mcp_url = "http://localhost:8002/mcp" - logger.info( - f"Creating no-custom-scopes MCP client session for Keycloak at {mcp_url}" - ) - - async for session in create_mcp_client_session( - url=mcp_url, - token=keycloak_oauth_token_no_custom_scopes, - client_name="Keycloak No Custom Scopes MCP", - ): - yield session - - -# ======================================================================== # Astrolabe Dynamic Configuration Fixtures -# ======================================================================== +# =========================================================================================== @pytest.fixture(scope="session") diff --git a/tests/integration/auth/test_token_exchange_legacy_v1.py b/tests/integration/auth/test_token_exchange_legacy_v1.py deleted file mode 100644 index 73d54837..00000000 --- a/tests/integration/auth/test_token_exchange_legacy_v1.py +++ /dev/null @@ -1,308 +0,0 @@ -""" -Integration test for RFC 8693 Token Exchange - Legacy V1 (Impersonation/Tier 1). - -Tests the advanced impersonation feature where the service account token is -exchanged for a token with the target user's identity (sub claim changes). - -This requires: -1. Keycloak with --features=preview enabled -2. Impersonation role granted to the service account - -⚠️ This test will SKIP if impersonation permissions are not configured. - -Configuration (one-time setup): - # Grant impersonation role - docker compose exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \\ - --server http://localhost:8080 \\ - --realm master \\ - --user admin \\ - --password admin - - docker compose exec keycloak /opt/keycloak/bin/kcadm.sh add-roles \\ - -r nextcloud-mcp \\ - --uusername service-account-nextcloud-mcp-server \\ - --cclientid realm-management \\ - --rolename impersonation - -Usage: - pytest tests/integration/auth/test_token_exchange_legacy_v1.py -v -""" - -import base64 -import json -import os - -import httpx -import pytest - -pytestmark = [pytest.mark.integration, pytest.mark.anyio, pytest.mark.keycloak] - - -def decode_jwt(token: str) -> dict: - """Decode JWT token payload without verification.""" - try: - parts = token.split(".") - if len(parts) != 3: - return {"error": "Invalid JWT format"} - - payload = parts[1] - padding = 4 - (len(payload) % 4) - if padding != 4: - payload += "=" * padding - - decoded = base64.urlsafe_b64decode(payload) - return json.loads(decoded) - except Exception as e: - return {"error": str(e)} - - -@pytest.fixture -def keycloak_config(): - """Keycloak configuration for testing.""" - return { - "url": os.getenv("KEYCLOAK_URL", "http://localhost:8888"), - "realm": os.getenv("KEYCLOAK_REALM", "nextcloud-mcp"), - "client_id": os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server"), - "client_secret": os.getenv( - "KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production" - ), - "token_endpoint": f"{os.getenv('KEYCLOAK_URL', 'http://localhost:8888')}/realms/{os.getenv('KEYCLOAK_REALM', 'nextcloud-mcp')}/protocol/openid-connect/token", - } - - -@pytest.fixture -async def service_account_token(keycloak_config): - """Get a service account token using client_credentials grant.""" - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - keycloak_config["token_endpoint"], - data={ - "grant_type": "client_credentials", - "client_id": keycloak_config["client_id"], - "client_secret": keycloak_config["client_secret"], - "scope": "openid profile email", - }, - ) - response.raise_for_status() - token_data = response.json() - return token_data["access_token"] - - -async def test_token_exchange_impersonation_requires_permissions( - keycloak_config, service_account_token -): - """Test that impersonation requires explicit permission grant. - - This test documents that Legacy V1 impersonation is opt-in and requires - administrative configuration via Keycloak CLI. - """ - - target_user = "admin" # User to impersonate - - async with httpx.AsyncClient(timeout=30.0) as client: - exchange_response = await client.post( - keycloak_config["token_endpoint"], - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "client_id": keycloak_config["client_id"], - "client_secret": keycloak_config["client_secret"], - "subject_token": service_account_token, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_subject": target_user, # ← KEY: Request impersonation - }, - ) - - # If permissions not granted, we expect 403 Forbidden - if exchange_response.status_code == 403: - pytest.skip( - "Impersonation permissions not configured. " - "Run tests/manual/configure_impersonation.py or grant manually via Keycloak CLI. " - "See test docstring for configuration commands." - ) - - # If permissions are granted, exchange should succeed - assert exchange_response.status_code == 200, ( - f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}" - ) - - -async def test_token_exchange_impersonation_changes_subject( - keycloak_config, service_account_token -): - """Test Legacy V1 impersonation - subject claim should change.""" - - target_user = "admin" - - # Decode service account token - service_claims = decode_jwt(service_account_token) - assert "error" not in service_claims - service_sub = service_claims["sub"] - assert "service-account" in service_sub.lower() - - # Exchange token WITH requested_subject (Legacy V1 impersonation) - async with httpx.AsyncClient(timeout=30.0) as client: - exchange_response = await client.post( - keycloak_config["token_endpoint"], - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "client_id": keycloak_config["client_id"], - "client_secret": keycloak_config["client_secret"], - "subject_token": service_account_token, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_subject": target_user, # ← KEY: Impersonate admin - }, - ) - - # Skip if permissions not configured - if exchange_response.status_code == 403: - pytest.skip( - "Impersonation permissions not configured. " - "See test docstring for setup instructions." - ) - - # Token exchange should succeed with permissions - assert exchange_response.status_code == 200, ( - f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}" - ) - - exchanged_data = exchange_response.json() - assert "access_token" in exchanged_data - exchanged_token = exchanged_data["access_token"] - - # Decode exchanged token - exchanged_claims = decode_jwt(exchanged_token) - assert "error" not in exchanged_claims - exchanged_sub = exchanged_claims["sub"] - - # CRITICAL: Verify impersonation - sub claim MUST change - assert service_sub != exchanged_sub, ( - f"Impersonation should change subject claim. " - f"Original: {service_sub}, Exchanged: {exchanged_sub}" - ) - - # Verify the new token represents the target user - assert "preferred_username" in exchanged_claims - assert exchanged_claims["preferred_username"] == target_user - - -async def test_impersonated_token_with_nextcloud( - keycloak_config, service_account_token -): - """Test that impersonated token works with Nextcloud APIs.""" - - target_user = "admin" - nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080") - - # Exchange token with impersonation - async with httpx.AsyncClient(timeout=30.0) as client: - exchange_response = await client.post( - keycloak_config["token_endpoint"], - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "client_id": keycloak_config["client_id"], - "client_secret": keycloak_config["client_secret"], - "subject_token": service_account_token, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_subject": target_user, - }, - ) - - # Skip if permissions not configured - if exchange_response.status_code == 403: - pytest.skip("Impersonation permissions not configured.") - - exchange_response.raise_for_status() - exchanged_token = exchange_response.json()["access_token"] - - # Test with Nextcloud API - nc_response = await client.get( - f"{nextcloud_host}/ocs/v2.php/cloud/capabilities", - headers={"Authorization": f"Bearer {exchanged_token}"}, - ) - - # Should get valid response from Nextcloud - assert nc_response.status_code in [ - 200, - 401, - ], f"Unexpected status: {nc_response.status_code}" - - if nc_response.status_code == 200: - # Token was accepted - verify we got a valid response - # Nextcloud OCS API can return XML or JSON - assert len(nc_response.content) > 0, "Response should not be empty" - content_type = nc_response.headers.get("content-type", "") - assert any(t in content_type for t in ["json", "xml"]), ( - f"Unexpected content type: {content_type}" - ) - - -async def test_standard_v2_rejects_requested_subject(): - """Verify that Standard V2 (without preview features) rejects requested_subject. - - This test documents the key difference between Standard V2 and Legacy V1. - - NOTE: This test will PASS if preview features are enabled, as Keycloak - accepts the parameter in Legacy V1 mode. The test exists to document the - expected behavior when preview features are DISABLED. - """ - - keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888") - realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp") - client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server") - client_secret = os.getenv( - "KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production" - ) - token_endpoint = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token" - - async with httpx.AsyncClient(timeout=30.0) as client: - # Get service account token - token_response = await client.post( - token_endpoint, - data={ - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - "scope": "openid profile email", - }, - ) - token_response.raise_for_status() - service_token = token_response.json()["access_token"] - - # Try token exchange with requested_subject - exchange_response = await client.post( - token_endpoint, - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "client_id": client_id, - "client_secret": client_secret, - "subject_token": service_token, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_subject": "admin", # Try to impersonate - }, - ) - - # Standard V2: expects 400 Bad Request with "not supported" message - # Legacy V1: accepts parameter, returns 200 or 403 (depending on permissions) - - if exchange_response.status_code == 400: - # Standard V2 behavior - error_data = exchange_response.json() - assert ( - "requested_subject" in error_data.get("error_description", "").lower() - ) - # Test passes - Standard V2 correctly rejects the parameter - elif exchange_response.status_code in [200, 403]: - # Legacy V1 behavior - parameter is accepted - pytest.skip( - "Preview features enabled - Keycloak is in Legacy V1 mode. " - "This test documents Standard V2 behavior which rejects requested_subject." - ) - else: - pytest.fail( - f"Unexpected status code: {exchange_response.status_code}. " - f"Expected 400 (Standard V2) or 200/403 (Legacy V1)" - ) diff --git a/tests/integration/auth/test_token_exchange_standard_v2.py b/tests/integration/auth/test_token_exchange_standard_v2.py deleted file mode 100644 index 874491e4..00000000 --- a/tests/integration/auth/test_token_exchange_standard_v2.py +++ /dev/null @@ -1,222 +0,0 @@ -""" -Integration test for RFC 8693 Token Exchange - Standard V2 (Delegation/Tier 2). - -Tests the production-ready token exchange without impersonation. -The service account exchanges its token for a user-scoped token while -maintaining its own identity (sub claim unchanged). - -This is the RECOMMENDED approach for most use cases. - -Requirements: -- Keycloak container running (can be Standard V2 or Legacy V1) -- MCP Keycloak service running on port 8002 - -Usage: - pytest tests/integration/auth/test_token_exchange_standard_v2.py -v -""" - -import base64 -import json -import os - -import httpx -import pytest - -pytestmark = [pytest.mark.integration, pytest.mark.anyio, pytest.mark.keycloak] - - -def decode_jwt(token: str) -> dict: - """Decode JWT token payload without verification.""" - try: - parts = token.split(".") - if len(parts) != 3: - return {"error": "Invalid JWT format"} - - payload = parts[1] - padding = 4 - (len(payload) % 4) - if padding != 4: - payload += "=" * padding - - decoded = base64.urlsafe_b64decode(payload) - return json.loads(decoded) - except Exception as e: - return {"error": str(e)} - - -@pytest.fixture -def keycloak_config(): - """Keycloak configuration for testing.""" - return { - "url": os.getenv("KEYCLOAK_URL", "http://localhost:8888"), - "realm": os.getenv("KEYCLOAK_REALM", "nextcloud-mcp"), - "client_id": os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server"), - "client_secret": os.getenv( - "KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production" - ), - "token_endpoint": f"{os.getenv('KEYCLOAK_URL', 'http://localhost:8888')}/realms/{os.getenv('KEYCLOAK_REALM', 'nextcloud-mcp')}/protocol/openid-connect/token", - } - - -@pytest.fixture -async def service_account_token(keycloak_config): - """Get a service account token using client_credentials grant.""" - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - keycloak_config["token_endpoint"], - data={ - "grant_type": "client_credentials", - "client_id": keycloak_config["client_id"], - "client_secret": keycloak_config["client_secret"], - "scope": "openid profile email", - }, - ) - response.raise_for_status() - token_data = response.json() - return token_data["access_token"] - - -async def test_token_exchange_delegation(keycloak_config, service_account_token): - """Test Standard V2 token exchange with delegation (no impersonation).""" - - # Decode service account token to get original claims - service_claims = decode_jwt(service_account_token) - assert "error" not in service_claims, "Failed to decode service account token" - assert "sub" in service_claims - service_sub = service_claims["sub"] - - # Exchange token WITHOUT requested_subject (Standard V2 delegation) - async with httpx.AsyncClient(timeout=30.0) as client: - exchange_response = await client.post( - keycloak_config["token_endpoint"], - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "client_id": keycloak_config["client_id"], - "client_secret": keycloak_config["client_secret"], - "subject_token": service_account_token, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - # NOTE: NO requested_subject parameter - this is delegation, not impersonation - }, - ) - - # Token exchange should succeed - assert exchange_response.status_code == 200, ( - f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}" - ) - - exchanged_data = exchange_response.json() - assert "access_token" in exchanged_data - assert "token_type" in exchanged_data - assert exchanged_data["token_type"].lower() == "bearer" - - exchanged_token = exchanged_data["access_token"] - - # Decode exchanged token - exchanged_claims = decode_jwt(exchanged_token) - assert "error" not in exchanged_claims, "Failed to decode exchanged token" - assert "sub" in exchanged_claims - exchanged_sub = exchanged_claims["sub"] - - # CRITICAL: Verify delegation behavior - sub claim should NOT change - assert service_sub == exchanged_sub, ( - f"Subject should remain unchanged in delegation (service account identity preserved). Original: {service_sub}, Exchanged: {exchanged_sub}" - ) - - # The exchanged token should still identify as the service account - assert "service-account" in exchanged_sub.lower(), ( - "Exchanged token should maintain service account identity" - ) - - -async def test_exchanged_token_with_nextcloud(keycloak_config, service_account_token): - """Test that exchanged token works with Nextcloud APIs.""" - - nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080") - - # Exchange the service account token - async with httpx.AsyncClient(timeout=30.0) as client: - exchange_response = await client.post( - keycloak_config["token_endpoint"], - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "client_id": keycloak_config["client_id"], - "client_secret": keycloak_config["client_secret"], - "subject_token": service_account_token, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - }, - ) - exchange_response.raise_for_status() - exchanged_token = exchange_response.json()["access_token"] - - # Test the exchanged token with Nextcloud API - nc_response = await client.get( - f"{nextcloud_host}/ocs/v2.php/cloud/capabilities", - headers={"Authorization": f"Bearer {exchanged_token}"}, - ) - - # Should get a valid response from Nextcloud - # Note: This might fail with 401 if user_oidc doesn't accept the token - # That's expected - this test verifies the token exchange itself works - assert nc_response.status_code in [ - 200, - 401, - ], f"Unexpected status: {nc_response.status_code}" - - if nc_response.status_code == 200: - # Token was accepted - verify we got a valid response - # Nextcloud OCS API can return XML or JSON - assert len(nc_response.content) > 0, "Response should not be empty" - # Verify we got either JSON or XML capabilities response - content_type = nc_response.headers.get("content-type", "") - assert any(t in content_type for t in ["json", "xml"]), ( - f"Unexpected content type: {content_type}" - ) - - -async def test_token_exchange_without_permissions_should_work(): - """Verify Standard V2 doesn't require special permissions (unlike Legacy V1 impersonation).""" - - # This test documents that Standard V2 token exchange works out-of-the-box - # without needing to grant impersonation roles via Keycloak CLI - - keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888") - realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp") - client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server") - client_secret = os.getenv( - "KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production" - ) - token_endpoint = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token" - - async with httpx.AsyncClient(timeout=30.0) as client: - # Get service account token - token_response = await client.post( - token_endpoint, - data={ - "grant_type": "client_credentials", - "client_id": client_id, - "client_secret": client_secret, - "scope": "openid profile email", - }, - ) - token_response.raise_for_status() - service_token = token_response.json()["access_token"] - - # Exchange token - should work without any special role grants - exchange_response = await client.post( - token_endpoint, - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "client_id": client_id, - "client_secret": client_secret, - "subject_token": service_token, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - }, - ) - - # Should succeed without 403 Forbidden (no permission requirements) - assert exchange_response.status_code == 200, ( - f"Standard V2 delegation should work without special permissions. " - f"Got: {exchange_response.status_code} {exchange_response.text}" - ) diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 20e0ce4a..ab5b7f8f 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -43,7 +43,6 @@ async def reset_all_singletons(): # Import all modules with singletons import nextcloud_mcp_server.app as app_module import nextcloud_mcp_server.auth.client_registry as client_registry_module - import nextcloud_mcp_server.auth.token_exchange as token_exchange_module import nextcloud_mcp_server.embedding.service as embedding_module import nextcloud_mcp_server.observability.tracing as tracing_module import nextcloud_mcp_server.providers.registry as registry_module @@ -63,7 +62,6 @@ async def reset_all_singletons(): ), "tracer": tracing_module._tracer, "registry": client_registry_module._registry, - "token_exchange_service": token_exchange_module._token_exchange_service, } # Close any open memory streams before reset @@ -89,7 +87,6 @@ async def reset_all_singletons(): app_module._vector_sync_state.scanner_wake_event = None tracing_module._tracer = None client_registry_module._registry = None - token_exchange_module._token_exchange_service = None logger.debug("All singletons reset for test module") @@ -115,4 +112,3 @@ async def reset_all_singletons(): ) = originals["vector_sync_state"] tracing_module._tracer = originals["tracer"] client_registry_module._registry = originals["registry"] - token_exchange_module._token_exchange_service = originals["token_exchange_service"] diff --git a/tests/integration/test_keycloak_token_exchange.py b/tests/integration/test_keycloak_token_exchange.py deleted file mode 100644 index 622ad8b7..00000000 --- a/tests/integration/test_keycloak_token_exchange.py +++ /dev/null @@ -1,380 +0,0 @@ -"""Integration tests for RFC 8693 Token Exchange with Keycloak. - -These tests validate the complete token exchange flow: -1. Obtain client token from Keycloak -2. Exchange for Nextcloud-audience token via RFC 8693 -3. Use exchanged token to access Nextcloud APIs -4. Verify CRUD operations work with exchanged tokens - -Requirements: -- Keycloak running with nextcloud-mcp realm configured -- Nextcloud running with user_oidc app configured -- Standard Token Exchange enabled on both clients -- token-exchange-nextcloud scope configured -""" - -from typing import Any - -import httpx -import jwt -import pytest - - -@pytest.fixture -async def keycloak_base_url() -> str: - """Keycloak base URL (external).""" - return "http://localhost:8888" - - -@pytest.fixture -async def keycloak_token_url(keycloak_base_url: str) -> str: - """Keycloak token endpoint URL.""" - return f"{keycloak_base_url}/realms/nextcloud-mcp/protocol/openid-connect/token" - - -@pytest.fixture -async def nextcloud_base_url() -> str: - """Nextcloud base URL.""" - return "http://localhost:8080" - - -@pytest.fixture -async def http_client() -> httpx.AsyncClient: - """Async HTTP client for API requests.""" - async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client: - yield client - - -@pytest.fixture -async def keycloak_client_token( - http_client: httpx.AsyncClient, keycloak_token_url: str -) -> str: - """Get client token from Keycloak using password grant. - - Returns token with aud: ["nextcloud-mcp-server", "nextcloud"] - """ - response = await http_client.post( - keycloak_token_url, - data={ - "grant_type": "password", - "client_id": "nextcloud-mcp-server", - "client_secret": "mcp-secret-change-in-production", - "username": "admin", - "password": "admin", - "scope": "openid profile email offline_access notes:read notes:write", - }, - ) - response.raise_for_status() - token_data = response.json() - return token_data["access_token"] - - -async def exchange_token( - http_client: httpx.AsyncClient, - token_url: str, - subject_token: str, - audience: str = "nextcloud", -) -> dict[str, Any]: - """Exchange token using RFC 8693. - - Args: - http_client: HTTP client - token_url: Token endpoint URL - subject_token: Token to exchange - audience: Target audience - - Returns: - Token response with access_token and expires_in - """ - response = await http_client.post( - token_url, - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "client_id": "nextcloud-mcp-server", - "client_secret": "mcp-secret-change-in-production", - "subject_token": subject_token, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "audience": audience, - }, - ) - response.raise_for_status() - return response.json() - - -def decode_token_claims(token: str) -> dict[str, Any]: - """Decode JWT token claims without verification. - - Args: - token: JWT token - - Returns: - Token claims - """ - return jwt.decode(token, options={"verify_signature": False}) - - -@pytest.mark.integration -@pytest.mark.keycloak -class TestKeycloakTokenExchange: - """Test RFC 8693 Token Exchange with Keycloak.""" - - async def test_token_exchange_basic( - self, - http_client: httpx.AsyncClient, - keycloak_token_url: str, - keycloak_client_token: str, - ): - """Test basic token exchange flow.""" - # Verify initial token has both audiences - initial_claims = decode_token_claims(keycloak_client_token) - assert "nextcloud-mcp-server" in initial_claims["aud"] - assert "nextcloud" in initial_claims["aud"] - assert initial_claims["azp"] == "nextcloud-mcp-server" - - # Exchange for Nextcloud-audience token - exchange_response = await exchange_token( - http_client, keycloak_token_url, keycloak_client_token - ) - - assert "access_token" in exchange_response - assert "expires_in" in exchange_response - assert exchange_response["expires_in"] > 0 - - # Verify exchanged token has correct audience - exchanged_token = exchange_response["access_token"] - exchanged_claims = decode_token_claims(exchanged_token) - - assert exchanged_claims["aud"] == "nextcloud" - assert exchanged_claims["azp"] == "nextcloud-mcp-server" - assert exchanged_claims["sub"] == initial_claims["sub"] - - async def test_token_exchange_with_nextcloud_api( - self, - http_client: httpx.AsyncClient, - keycloak_token_url: str, - keycloak_client_token: str, - nextcloud_base_url: str, - ): - """Test exchanged token works with Nextcloud APIs.""" - # Exchange token - exchange_response = await exchange_token( - http_client, keycloak_token_url, keycloak_client_token - ) - nextcloud_token = exchange_response["access_token"] - - # Call Nextcloud Capabilities API - response = await http_client.get( - f"{nextcloud_base_url}/ocs/v1.php/cloud/capabilities", - headers={ - "Authorization": f"Bearer {nextcloud_token}", - "OCS-APIRequest": "true", - }, - ) - response.raise_for_status() - - # Verify response contains OCS data - assert "ocs" in response.text.lower() - - async def test_token_exchange_multiple_times( - self, - http_client: httpx.AsyncClient, - keycloak_token_url: str, - keycloak_client_token: str, - ): - """Test multiple exchanges from same client token (stateless).""" - # Exchange token three times - tokens = [] - for _ in range(3): - exchange_response = await exchange_token( - http_client, keycloak_token_url, keycloak_client_token - ) - tokens.append(exchange_response["access_token"]) - - # All exchanges should succeed - assert len(tokens) == 3 - - # Tokens should be different (fresh ephemeral tokens) - # Note: Keycloak may cache, so tokens might be identical - # The important thing is that all exchanges succeeded - - async def test_token_exchange_crud_operations( - self, - http_client: httpx.AsyncClient, - keycloak_token_url: str, - keycloak_client_token: str, - nextcloud_base_url: str, - ): - """Test CRUD operations with exchanged tokens.""" - notes_api = f"{nextcloud_base_url}/index.php/apps/notes/api/v1/notes" - - # Step 1: Exchange token for CREATE - exchange_response = await exchange_token( - http_client, keycloak_token_url, keycloak_client_token - ) - create_token = exchange_response["access_token"] - - # Step 2: Create a test note - create_response = await http_client.post( - notes_api, - headers={"Authorization": f"Bearer {create_token}"}, - json={ - "title": "Token Exchange Test", - "content": "This note was created using an RFC 8693 exchanged token!", - "category": "Test", - }, - ) - create_response.raise_for_status() - note_data = create_response.json() - note_id = note_data["id"] - - assert note_data["title"] == "Token Exchange Test" - assert note_data["category"] == "Test" - - # Step 3: Exchange token again for READ (simulate new request) - exchange_response = await exchange_token( - http_client, keycloak_token_url, keycloak_client_token - ) - read_token = exchange_response["access_token"] - - # Step 4: Read the note back - read_response = await http_client.get( - f"{notes_api}/{note_id}", - headers={"Authorization": f"Bearer {read_token}"}, - ) - read_response.raise_for_status() - read_data = read_response.json() - - assert read_data["id"] == note_id - assert read_data["title"] == "Token Exchange Test" - assert "RFC 8693 exchanged token" in read_data["content"] - - # Step 5: Exchange token again for DELETE - exchange_response = await exchange_token( - http_client, keycloak_token_url, keycloak_client_token - ) - delete_token = exchange_response["access_token"] - - # Step 6: Delete the note - delete_response = await http_client.delete( - f"{notes_api}/{note_id}", - headers={"Authorization": f"Bearer {delete_token}"}, - ) - # Notes API returns the deleted note or empty array - assert delete_response.status_code in (200, 204) - - async def test_token_claims_preservation( - self, - http_client: httpx.AsyncClient, - keycloak_token_url: str, - keycloak_client_token: str, - ): - """Test that important claims are preserved during exchange.""" - initial_claims = decode_token_claims(keycloak_client_token) - - # Exchange token - exchange_response = await exchange_token( - http_client, keycloak_token_url, keycloak_client_token - ) - exchanged_token = exchange_response["access_token"] - exchanged_claims = decode_token_claims(exchanged_token) - - # Subject (user ID) should be preserved - assert exchanged_claims["sub"] == initial_claims["sub"] - - # Authorized party should show delegation - assert exchanged_claims["azp"] == "nextcloud-mcp-server" - - # Audience should be filtered to target - assert exchanged_claims["aud"] == "nextcloud" - - # Token should have expiration - assert "exp" in exchanged_claims - assert exchanged_claims["exp"] > 0 - - async def test_token_exchange_scope_configuration( - self, http_client: httpx.AsyncClient, keycloak_token_url: str - ): - """Test that token-exchange-nextcloud scope is configured as default. - - Since token-exchange-nextcloud is a default scope for nextcloud-mcp-server, - all tokens should have the nextcloud audience available for exchange. - """ - # Get a token - should automatically include default scopes - response = await http_client.post( - keycloak_token_url, - data={ - "grant_type": "password", - "client_id": "nextcloud-mcp-server", - "client_secret": "mcp-secret-change-in-production", - "username": "admin", - "password": "admin", - "scope": "openid profile email", - }, - ) - response.raise_for_status() - token = response.json()["access_token"] - - # Verify token has nextcloud in aud (from default token-exchange-nextcloud scope) - claims = decode_token_claims(token) - assert "nextcloud" in claims.get("aud", []) - - # Exchange should succeed - exchange_response = await http_client.post( - keycloak_token_url, - data={ - "grant_type": "urn:ietf:params:oauth:grant-type:token-exchange", - "client_id": "nextcloud-mcp-server", - "client_secret": "mcp-secret-change-in-production", - "subject_token": token, - "subject_token_type": "urn:ietf:params:oauth:token-type:access_token", - "requested_token_type": "urn:ietf:params:oauth:token-type:access_token", - "audience": "nextcloud", - }, - ) - - # Should succeed because token-exchange-nextcloud is a default scope - assert exchange_response.status_code == 200 - exchanged_data = exchange_response.json() - assert "access_token" in exchanged_data - - -@pytest.mark.integration -@pytest.mark.keycloak -class TestTokenExchangeService: - """Test the TokenExchangeService implementation.""" - - async def test_exchange_token_for_audience( - self, keycloak_client_token: str, keycloak_token_url: str - ): - """Test the exchange_token_for_audience function.""" - from nextcloud_mcp_server.auth.token_exchange import ( - TokenExchangeService, - ) - - # Create service - service = TokenExchangeService( - oidc_discovery_url="http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration", - client_id="nextcloud-mcp-server", - client_secret="mcp-secret-change-in-production", - ) - - try: - # Exchange token - exchanged_token, expires_in = await service.exchange_token_for_audience( - subject_token=keycloak_client_token, - requested_audience="nextcloud", - ) - - # Verify exchange succeeded - assert exchanged_token is not None - assert isinstance(exchanged_token, str) - assert expires_in > 0 - - # Verify token has correct claims - claims = decode_token_claims(exchanged_token) - assert claims["aud"] == "nextcloud" - assert claims["azp"] == "nextcloud-mcp-server" - - finally: - await service.close() diff --git a/tests/manual/README.md b/tests/manual/README.md deleted file mode 100644 index ba76d8bb..00000000 --- a/tests/manual/README.md +++ /dev/null @@ -1,47 +0,0 @@ -# Manual OAuth Flow Testing - -This directory contains manual test scripts for OAuth flows that require browser interaction. - -## ADR-004 OAuth Hybrid Flow Test - -The `test_adr004_oauth_flow.py` script tests the complete OAuth flow described in ADR-004. - -### Prerequisites - -1. **Install Playwright browsers:** - ```bash - uv run playwright install firefox - ``` - -2. **Start MCP server with OAuth enabled:** - - For Nextcloud OIDC: - ```bash - export ENABLE_OFFLINE_ACCESS=true - export TOKEN_ENCRYPTION_KEY=$(uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") - docker-compose up --build -d mcp-oauth - ``` - - For Keycloak: - ```bash - export ENABLE_OFFLINE_ACCESS=true - export TOKEN_ENCRYPTION_KEY=$(uv run python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())") - docker-compose up --build -d mcp-keycloak - ``` - -### Running the Test - -**Test with Nextcloud OIDC:** -```bash -uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud -``` - -**Test with Keycloak:** -```bash -uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak -``` - -**Headless mode:** -```bash -uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud --headless -``` diff --git a/tests/manual/TESTING_INSTRUCTIONS.md b/tests/manual/TESTING_INSTRUCTIONS.md deleted file mode 100644 index 1fd8ea1b..00000000 --- a/tests/manual/TESTING_INSTRUCTIONS.md +++ /dev/null @@ -1,203 +0,0 @@ -# ADR-004 OAuth Flow Testing Instructions - -## Automated Integration Test (Recommended) - -The ADR-004 Hybrid Flow is now fully tested via automated integration tests using Playwright: - -```bash -# Run all ADR-004 tests -uv run pytest tests/server/oauth/test_adr004_hybrid_flow.py --browser firefox -v - -# Run specific test -uv run pytest tests/server/oauth/test_adr004_hybrid_flow.py::test_adr004_hybrid_flow_tool_execution --browser firefox -v -``` - -These tests verify: -- ✅ PKCE code challenge/verifier flow -- ✅ MCP server intercepts OAuth callback -- ✅ Master refresh token storage -- ✅ Client receives MCP access token -- ✅ MCP session establishment with hybrid flow token -- ✅ Tool execution using stored refresh tokens -- ✅ Multiple operations without re-authentication - -## Manual Test (Legacy) - -For manual testing or debugging, you can use the standalone test script: - -```bash -# Make sure port 8765 is available -lsof -ti:8765 | xargs kill -9 2>/dev/null - -# Run the test -uv run python tests/manual/test_adr004_manual.py --provider nextcloud -``` - -## Expected Flow - -### 1. Test Script Starts -``` -====================================================================== -ADR-004 MANUAL OAUTH FLOW TEST -====================================================================== -Provider: nextcloud -MCP Server: http://localhost:8001 -Nextcloud: http://localhost:8080 -====================================================================== - -✓ Generated PKCE challenge: gxQLsYDJ... -✓ Started callback server at http://localhost:8765/callback -``` - -### 2. Open OAuth URL in Browser -The script will print: -``` -====================================================================== -STEP 1: AUTHORIZE THE MCP SERVER -====================================================================== - -📋 Open this URL in your browser: - - http://localhost:8001/oauth/authorize?response_type=code&... - -📌 What will happen: - 1. You'll be redirected to Nextcloud/Keycloak login - 2. Login with username: admin, password: admin - 3. You'll see a consent screen asking to authorize the MCP server - 4. Click 'Authorize' or 'Allow' - 5. You'll be redirected to localhost:8765/callback - 6. The authorization code will appear in the terminal -``` - -### 3. Browser Flow -1. **Nextcloud Login** - You see the Nextcloud login page -2. **Enter Credentials** - admin/admin -3. **Consent Screen** - "Authorize Nextcloud MCP Server (jwt) to access your account?" -4. **Click Authorize** -5. **Redirect Chain**: - - Nextcloud redirects to: `http://localhost:8001/oauth/callback?code=...` - - MCP server processes the code - - MCP server redirects to: `http://localhost:8765/callback?code=mcp-code-...&state=...` - - Browser reaches the test script's callback server - - You see: "✓ Authorization Successful - You can close this window" - -### 4. Test Script Continues -``` -✓ Received authorization code! -Code: mcp-code-xyz... -✓ State parameter verified (CSRF protection) - -====================================================================== -STEP 2: EXCHANGE CODE FOR ACCESS TOKEN -====================================================================== - -✓ Successfully received access token - Token: eyJhbGciOiJSUzI1Ni... - Type: Bearer - Expires: 3600s - -====================================================================== -STEP 3: CALL MCP TOOL WITH ACCESS TOKEN -====================================================================== - -✓ MCP tool call succeeded! - Result: {...} - -====================================================================== -🎉 ADR-004 OAUTH FLOW TEST - SUCCESS -====================================================================== -``` - -## Troubleshooting - -### Browser Gets Stuck at "localhost:8765 refused to connect" - -**Problem**: The callback server on port 8765 isn't accessible. - -**Solutions**: -1. Check firewall isn't blocking port 8765 -2. Verify the test script is still running -3. Check another process isn't using port 8765: - ```bash - lsof -ti:8765 - ``` - -### Browser Shows "localhost:8765 - ERR_CONNECTION_REFUSED" - -**Problem**: The callback server stopped or never started. - -**Solution**: -1. Check the test script output - it should say "✓ Started callback server" -2. Restart the test script -3. Manually test the callback server: - ```bash - curl http://localhost:8765/callback?code=test&state=test - ``` - Should return HTML page with "Authorization Successful" - -### "Session not found or expired" Error - -**Problem**: Took too long between steps (>10 minutes). - -**Solution**: Restart the test - sessions expire after 10 minutes. - -### Client ID is None - -**Problem**: OAuth client credentials not loaded. - -**Solution**: Rebuild the MCP server: -```bash -docker-compose up --build -d mcp-oauth -``` - -### Nextcloud Shows "Invalid redirect_uri" - -**Problem**: The redirect URI isn't registered for the OAuth client. - -**Solution**: Check registered URIs: -```bash -docker compose exec db mariadb -u root -ppassword nextcloud -e \ - "SELECT c.client_identifier, r.redirect_uri FROM oc_oidc_clients c \ - LEFT JOIN oc_oidc_redirect_uris r ON c.id = r.client_id \ - WHERE c.name LIKE '%MCP%';" -``` - -Should show: `http://localhost:8001/oauth/callback` - -## Manual Test Without Script - -If the automated test doesn't work, you can test manually: - -1. **Start callback server manually**: - ```bash - python3 -m http.server 8765 - ``` - -2. **Open OAuth URL in browser** (get from test script output or build manually): - ``` - http://localhost:8001/oauth/authorize?response_type=code&client_id=test-mcp-client&redirect_uri=http://localhost:8765/callback&scope=openid+profile+email+offline_access&state=TEST&code_challenge=CHALLENGE&code_challenge_method=S256 - ``` - -3. **Complete login** at Nextcloud - -4. **Browser should redirect** to `http://localhost:8765/callback?code=mcp-code-...&state=TEST` - -5. **Copy the code** from the URL and exchange it: - ```bash - curl -X POST http://localhost:8001/oauth/token \ - -d "grant_type=authorization_code" \ - -d "code=" \ - -d "code_verifier=" \ - -d "redirect_uri=http://localhost:8765/callback" \ - -d "client_id=test-mcp-client" - ``` - -## Expected Database State After Success - -```bash -# Check refresh token was stored -docker compose exec mcp-oauth sh -c \ - "sqlite3 /app/data/tokens.db 'SELECT user_id, created_at FROM refresh_tokens;'" -``` - -Should show an entry for the authenticated user. diff --git a/tests/manual/configure_impersonation.py b/tests/manual/configure_impersonation.py deleted file mode 100644 index df9c03f4..00000000 --- a/tests/manual/configure_impersonation.py +++ /dev/null @@ -1,195 +0,0 @@ -""" -Configure Keycloak client for token exchange with impersonation. - -This script uses Keycloak Admin API to configure the necessary permissions -for the nextcloud-mcp-server client to impersonate users via token exchange. - -Usage: - uv run python tests/manual/configure_impersonation.py -""" - -import asyncio -import logging -import os -import sys - -import httpx - -logging.basicConfig(level=logging.INFO, format="%(levelname)-8s | %(message)s") -logger = logging.getLogger(__name__) - - -async def main(): - """Configure impersonation permissions in Keycloak""" - - keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888") - realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp") - admin_username = "admin" - admin_password = "admin" - client_id = "nextcloud-mcp-server" - - logger.info("=" * 80) - logger.info("Configuring Keycloak Impersonation Permissions") - logger.info("=" * 80) - logger.info(f"Keycloak URL: {keycloak_url}") - logger.info(f"Realm: {realm}") - logger.info(f"Client ID: {client_id}") - logger.info("") - - async with httpx.AsyncClient(timeout=30.0) as client: - # Step 1: Get admin access token - logger.info("Step 1: Getting admin access token...") - token_response = await client.post( - f"{keycloak_url}/realms/master/protocol/openid-connect/token", - data={ - "grant_type": "password", - "client_id": "admin-cli", - "username": admin_username, - "password": admin_password, - }, - ) - token_response.raise_for_status() - admin_token = token_response.json()["access_token"] - logger.info("✓ Admin token acquired") - logger.info("") - - headers = {"Authorization": f"Bearer {admin_token}"} - - # Step 2: Get client internal ID - logger.info("Step 2: Looking up client internal ID...") - clients_response = await client.get( - f"{keycloak_url}/admin/realms/{realm}/clients", - headers=headers, - params={"clientId": client_id}, - ) - clients_response.raise_for_status() - clients = clients_response.json() - - if not clients: - logger.error(f"❌ Client '{client_id}' not found") - return 1 - - client_uuid = clients[0]["id"] - logger.info(f"✓ Found client UUID: {client_uuid}") - logger.info("") - - # Step 3: Enable token exchange permission - logger.info("Step 3: Configuring token exchange permissions...") - - # Get all clients (we need to allow exchange from/to any client) - all_clients_response = await client.get( - f"{keycloak_url}/admin/realms/{realm}/clients", - headers=headers, - ) - all_clients_response.raise_for_status() - all_clients = all_clients_response.json() - - # Get all users (we need to allow impersonation of any user) - users_response = await client.get( - f"{keycloak_url}/admin/realms/{realm}/users", - headers=headers, - ) - users_response.raise_for_status() - users = users_response.json() - - logger.info(f" Found {len(all_clients)} clients and {len(users)} users") - logger.info("") - - # Step 4: Enable permission for client to perform token exchange - logger.info("Step 4: Enabling token exchange permission...") - - # Update client to enable fine-grained permissions - update_response = await client.put( - f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}", - headers=headers, - json={ - **clients[0], - "authorizationServicesEnabled": False, # Don't need full authz - "serviceAccountsEnabled": True, # Already enabled - }, - ) - - if update_response.status_code in [200, 204]: - logger.info("✓ Client configuration updated") - else: - logger.warning(f"⚠ Client update returned {update_response.status_code}") - - logger.info("") - - # Step 5: Set up token exchange permission policy - logger.info("Step 5: Configuring impersonation policy...") - - # In Keycloak Legacy V1, we need to use the token-exchange permissions endpoint - # This is part of the preview features - - # First, check if token exchange permissions endpoint exists - try: - perms_response = await client.get( - f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}/token-exchange/permissions", - headers=headers, - ) - - if perms_response.status_code == 200: - logger.info("✓ Token exchange permissions endpoint available") - permissions = perms_response.json() - logger.info(f" Current permissions: {permissions}") - logger.info("") - - # Enable impersonation for all users - logger.info("Step 6: Enabling impersonation for admin user...") - - # Find admin user - admin_user = next((u for u in users if u["username"] == "admin"), None) - - if admin_user: - # Enable permission for this client to impersonate admin - enable_response = await client.put( - f"{keycloak_url}/admin/realms/{realm}/users/{admin_user['id']}/impersonation", - headers=headers, - json={ - "client": client_uuid, - "enabled": True, - }, - ) - - if enable_response.status_code in [200, 204]: - logger.info("✓ Impersonation enabled for admin user") - else: - logger.warning( - f"⚠ Impersonation enable returned {enable_response.status_code}" - ) - logger.info(f" Response: {enable_response.text}") - else: - logger.error("❌ Admin user not found") - - elif perms_response.status_code == 404: - logger.warning("⚠ Token exchange permissions endpoint not found") - logger.info(" This might mean preview features aren't fully enabled") - logger.info(" Or the Keycloak version doesn't support this API") - else: - logger.warning(f"⚠ Unexpected response: {perms_response.status_code}") - - except Exception as e: - logger.error(f"❌ Error configuring permissions: {e}") - logger.info("") - logger.info("Alternative: Manual configuration required") - logger.info(" 1. Open Keycloak Admin Console") - logger.info(" 2. Go to Clients → nextcloud-mcp-server") - logger.info(" 3. Go to Permissions tab") - logger.info(" 4. Enable 'token-exchange' permission") - logger.info(" 5. Configure permission policies for impersonation") - - logger.info("") - logger.info("=" * 80) - logger.info("Configuration Complete") - logger.info("=" * 80) - logger.info("") - logger.info("Next step: Run impersonation test") - logger.info(" uv run python tests/manual/test_impersonation.py") - - return 0 - - -if __name__ == "__main__": - exit_code = asyncio.run(main()) - sys.exit(exit_code) diff --git a/tests/manual/test_adr004_manual.py b/tests/manual/test_adr004_manual.py deleted file mode 100644 index 1f504b19..00000000 --- a/tests/manual/test_adr004_manual.py +++ /dev/null @@ -1,319 +0,0 @@ -#!/usr/bin/env python3 -""" -ADR-004 Manual OAuth Flow Test - -This is a simplified version that doesn't use Playwright automation. -Instead, it prints URLs and waits for manual browser interaction. - -Usage: - uv run python tests/manual/test_adr004_manual.py --provider nextcloud -""" - -import argparse -import asyncio -import hashlib -import logging -import secrets -from base64 import urlsafe_b64encode -from http.server import BaseHTTPRequestHandler, HTTPServer -from threading import Thread -from urllib.parse import parse_qs, urlencode, urlparse - -import httpx - -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) - - -class CallbackHandler(BaseHTTPRequestHandler): - """Handles OAuth callback redirect to localhost""" - - authorization_code = None - state = None - - def do_GET(self): - """Handle GET request with authorization code""" - parsed = urlparse(self.path) - params = parse_qs(parsed.query) - - # Ignore favicon requests - if parsed.path == "/favicon.ico": - self.send_response(200) - self.send_header("Content-type", "image/x-icon") - self.end_headers() - return - - CallbackHandler.authorization_code = params.get("code", [None])[0] - CallbackHandler.state = params.get("state", [None])[0] - - # Send success page - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - - code_display = ( - CallbackHandler.authorization_code[:50] + "..." - if CallbackHandler.authorization_code - else "No code received" - ) - - html = """ - - Authorization Success - -

✓ Authorization Successful

-

Authorization code received. You can close this window and return to the terminal.

- - {} - - - - """.format(code_display) - self.wfile.write(html.encode()) - - def log_message(self, format, *args): - """Log HTTP requests""" - logger.info(f"Callback server: {format % args}") - - -def generate_pkce_challenge(): - """Generate PKCE code verifier and challenge""" - code_verifier = secrets.token_urlsafe(32) - digest = hashlib.sha256(code_verifier.encode()).digest() - code_challenge = urlsafe_b64encode(digest).decode().rstrip("=") - return code_verifier, code_challenge - - -async def test_oauth_manual( - provider: str, - mcp_server_url: str, - nextcloud_host: str, -): - """ - Manual OAuth flow test - prints URLs for manual browser interaction. - """ - print("\n" + "=" * 70) - print("ADR-004 MANUAL OAUTH FLOW TEST") - print("=" * 70) - print(f"Provider: {provider}") - print(f"MCP Server: {mcp_server_url}") - print(f"Nextcloud: {nextcloud_host}") - print("=" * 70 + "\n") - - # Generate PKCE challenge - code_verifier, code_challenge = generate_pkce_challenge() - logger.info(f"✓ Generated PKCE challenge: {code_challenge[:16]}...") - - # Generate state for CSRF protection - state = secrets.token_urlsafe(32) - - # Start local HTTP server for OAuth callback - callback_port = 8765 - redirect_uri = f"http://localhost:{callback_port}/callback" - - server = HTTPServer(("localhost", callback_port), CallbackHandler) - server_thread = Thread(target=server.serve_forever, daemon=True) - server_thread.start() - logger.info(f"✓ Started callback server at {redirect_uri}") - - try: - # Build authorization URL - auth_params = { - "response_type": "code", - "client_id": "test-mcp-client", - "redirect_uri": redirect_uri, - "scope": "openid profile email offline_access notes:read notes:write", - "state": state, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - } - - auth_url = f"{mcp_server_url}/oauth/authorize?{urlencode(auth_params)}" - - print("\n" + "=" * 70) - print("STEP 1: AUTHORIZE THE MCP SERVER") - print("=" * 70) - print("\n📋 Open this URL in your browser:\n") - print(f" {auth_url}") - print("\n📌 What will happen:") - print(" 1. You'll be redirected to Nextcloud/Keycloak login") - print(" 2. Login with username: admin, password: admin") - print(" 3. You'll see a consent screen asking to authorize the MCP server") - print(" 4. Click 'Authorize' or 'Allow'") - print(" 5. You'll be redirected to localhost:8765/callback") - print(" 6. The authorization code will appear in the terminal\n") - print("=" * 70) - print("\n⏳ Waiting for authorization... (timeout: 5 minutes)\n") - - # Wait for authorization code (with timeout) - timeout = 300 # 5 minutes - elapsed = 0 - while not CallbackHandler.authorization_code and elapsed < timeout: - await asyncio.sleep(1) - elapsed += 1 - - if not CallbackHandler.authorization_code: - raise RuntimeError("Timeout waiting for authorization code") - - authorization_code = CallbackHandler.authorization_code - returned_state = CallbackHandler.state - - print("\n✓ Received authorization code!") - logger.info(f"Code: {authorization_code[:16]}...") - - # Verify state - if returned_state != state: - raise RuntimeError( - f"State mismatch! Expected {state}, got {returned_state}" - ) - logger.info("✓ State parameter verified (CSRF protection)") - - # Exchange authorization code for access token - print("\n" + "=" * 70) - print("STEP 2: EXCHANGE CODE FOR ACCESS TOKEN") - print("=" * 70) - - async with httpx.AsyncClient() as client: - token_response = await client.post( - f"{mcp_server_url}/oauth/token", - data={ - "grant_type": "authorization_code", - "code": authorization_code, - "code_verifier": code_verifier, - "redirect_uri": redirect_uri, - "client_id": "test-mcp-client", - }, - timeout=30.0, - ) - - if token_response.status_code != 200: - print(f"\n❌ Token exchange failed: {token_response.status_code}") - print(f"Response: {token_response.text}") - raise RuntimeError("Token exchange failed") - - token_data = token_response.json() - access_token = token_data["access_token"] - - print("\n✓ Successfully received access token") - print(f" Token: {access_token[:30]}...") - print(f" Type: {token_data.get('token_type', 'Bearer')}") - print(f" Expires: {token_data.get('expires_in', 'unknown')}s") - - # Test MCP tool call - print("\n" + "=" * 70) - print("STEP 3: CALL MCP TOOL WITH ACCESS TOKEN") - print("=" * 70) - - async with httpx.AsyncClient() as client: - mcp_request = { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "nc_notes_search_notes", - "arguments": {"query": "test"}, - }, - } - - mcp_response = await client.post( - f"{mcp_server_url}/mcp", - json=mcp_request, - headers={ - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - }, - timeout=30.0, - ) - - if mcp_response.status_code != 200: - print(f"\n❌ MCP tool call failed: {mcp_response.status_code}") - print(f"Response: {mcp_response.text}") - raise RuntimeError("MCP tool call failed") - - mcp_result = mcp_response.json() - - if "error" in mcp_result: - print(f"\n❌ MCP tool returned error: {mcp_result['error']}") - raise RuntimeError(f"MCP tool error: {mcp_result['error']}") - - print("\n✓ MCP tool call succeeded!") - print(f" Result: {mcp_result.get('result', {})}") - - # Summary - print("\n" + "=" * 70) - print("🎉 ADR-004 OAUTH FLOW TEST - SUCCESS") - print("=" * 70) - print(f"Provider: {provider}") - print(f"MCP Server: {mcp_server_url}") - print(f"Nextcloud: {nextcloud_host}") - print("") - print("✓ User consented to MCP server access") - print("✓ User consented to offline_access (refresh tokens)") - print("✓ MCP server stored master refresh token") - print("✓ Client received MCP access token via PKCE") - print("✓ MCP tool call succeeded") - print("✓ MCP server exchanged tokens in background") - print("✓ Nextcloud data fetched successfully") - print("=" * 70 + "\n") - - return {"success": True} - - finally: - server.shutdown() - logger.info("Stopped callback server") - - -async def main(): - parser = argparse.ArgumentParser( - description="Manual test for ADR-004 OAuth Hybrid Flow" - ) - - parser.add_argument( - "--provider", - choices=["nextcloud", "keycloak"], - required=True, - help="OAuth provider to test", - ) - - parser.add_argument( - "--mcp-server-url", - default="http://localhost:8001", - help="MCP server URL (default: http://localhost:8001)", - ) - - parser.add_argument( - "--nextcloud-host", - default="http://localhost:8080", - help="Nextcloud host URL (default: http://localhost:8080)", - ) - - args = parser.parse_args() - - try: - result = await test_oauth_manual( - provider=args.provider, - mcp_server_url=args.mcp_server_url, - nextcloud_host=args.nextcloud_host, - ) - - return 0 if result["success"] else 1 - - except KeyboardInterrupt: - print("\n\n⚠️ Test interrupted by user") - return 1 - except Exception as e: - logger.error(f"OAuth flow test failed: {e}", exc_info=True) - print("\n" + "=" * 70) - print("❌ ADR-004 OAUTH FLOW TEST - FAILED") - print("=" * 70) - print(f"Error: {e}") - print("=" * 70) - return 1 - - -if __name__ == "__main__": - exit_code = asyncio.run(main()) - exit(exit_code) diff --git a/tests/manual/test_adr004_oauth_flow.py b/tests/manual/test_adr004_oauth_flow.py deleted file mode 100644 index 74df9f24..00000000 --- a/tests/manual/test_adr004_oauth_flow.py +++ /dev/null @@ -1,375 +0,0 @@ -#!/usr/bin/env python3 -""" -ADR-004 OAuth Flow Test Script - -Tests the complete Hybrid Flow implementation: -1. User initiates OAuth at MCP server /oauth/authorize -2. User consents to MCP server access (IdP) -3. User consents to MCP server accessing Nextcloud (IdP/Nextcloud) -4. MCP server receives master refresh token -5. Client receives MCP access token -6. Client calls MCP tool -7. MCP server exchanges master refresh token for Nextcloud access token -8. MCP server fetches data from Nextcloud on behalf of user - -Usage: - # Test with Nextcloud OIDC app - uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud - - # Test with Keycloak - uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak - -Requirements: - - MCP server running with OAuth enabled - - System web browser -""" - -import argparse -import asyncio -import hashlib -import logging -import secrets -import webbrowser -from base64 import urlsafe_b64encode -from http.server import BaseHTTPRequestHandler, HTTPServer -from threading import Thread -from urllib.parse import parse_qs, urlencode, urlparse - -import httpx - -logging.basicConfig( - level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" -) -logger = logging.getLogger(__name__) - - -class CallbackHandler(BaseHTTPRequestHandler): - """Handles OAuth callback redirect to localhost""" - - authorization_code = None - state = None - - def do_GET(self): - """Handle GET request with authorization code""" - parsed = urlparse(self.path) - params = parse_qs(parsed.query) - - # Ignore favicon requests - if parsed.path == "/favicon.ico": - self.send_response(200) - self.send_header("Content-type", "image/x-icon") - self.end_headers() - return - - CallbackHandler.authorization_code = params.get("code", [None])[0] - CallbackHandler.state = params.get("state", [None])[0] - - # Send success page - self.send_response(200) - self.send_header("Content-type", "text/html") - self.end_headers() - - code_display = ( - CallbackHandler.authorization_code[:50] + "..." - if CallbackHandler.authorization_code - else "No code received" - ) - - html = """ - - Authorization Success - -

✓ Authorization Successful

-

Authorization code received. You can close this window and return to the terminal.

- - {} - - - - - """.format(code_display) - self.wfile.write(html.encode()) - - def log_message(self, format, *args): - """Log HTTP requests""" - logger.info(f"Callback: {format % args}") - - -def generate_pkce_challenge(): - """Generate PKCE code verifier and challenge""" - code_verifier = secrets.token_urlsafe(32) - digest = hashlib.sha256(code_verifier.encode()).digest() - code_challenge = urlsafe_b64encode(digest).decode().rstrip("=") - return code_verifier, code_challenge - - -# Note: Playwright automation functions removed - using system browser instead - - -async def test_oauth_flow( - provider: str, - mcp_server_url: str, - nextcloud_host: str, - username: str, - password: str, -): - """ - Test complete ADR-004 OAuth flow using system browser. - - Args: - provider: "nextcloud" or "keycloak" - mcp_server_url: MCP server URL (e.g., http://localhost:8001) - nextcloud_host: Nextcloud instance URL - username: Test user username (for documentation) - password: Test user password (for documentation) - """ - logger.info(f"Starting ADR-004 OAuth flow test with provider: {provider}") - logger.info(f"MCP Server: {mcp_server_url}") - logger.info(f"Nextcloud Host: {nextcloud_host}") - - # Generate PKCE challenge - code_verifier, code_challenge = generate_pkce_challenge() - logger.info(f"✓ Generated PKCE challenge: {code_challenge[:16]}...") - - # Generate state for CSRF protection - state = secrets.token_urlsafe(32) - - # Start local HTTP server for OAuth callback - callback_port = 8765 - redirect_uri = f"http://localhost:{callback_port}/callback" - - server = HTTPServer(("localhost", callback_port), CallbackHandler) - server_thread = Thread(target=server.serve_forever, daemon=True) - server_thread.start() - logger.info(f"✓ Started callback server at {redirect_uri}") - - try: - # Step 1: Build authorization URL - auth_params = { - "response_type": "code", - "client_id": "test-mcp-client", - "redirect_uri": redirect_uri, - "scope": "openid profile email offline_access notes:read notes:write", - "state": state, - "code_challenge": code_challenge, - "code_challenge_method": "S256", - } - - auth_url = f"{mcp_server_url}/oauth/authorize?{urlencode(auth_params)}" - - print("\n" + "=" * 70) - print("STEP 1: AUTHORIZE IN BROWSER") - print("=" * 70) - print(f"\n📋 Opening browser to: {auth_url[:80]}...") - print(f"\n📌 Login with: {username} / {password}") - print("📌 Then authorize the MCP server") - print("=" * 70 + "\n") - - # Step 2: Open system browser - logger.info("Opening system browser for OAuth flow...") - webbrowser.open(auth_url) - - logger.info("⏳ Waiting for authorization callback (timeout: 5 minutes)...") - - # Wait for callback - timeout = 300 # 5 minutes - elapsed = 0 - while not CallbackHandler.authorization_code and elapsed < timeout: - await asyncio.sleep(1) - elapsed += 1 - - if not CallbackHandler.authorization_code: - raise RuntimeError("Timeout waiting for authorization code") - - # Step 3: Verify we received authorization code - authorization_code = CallbackHandler.authorization_code - returned_state = CallbackHandler.state - - if not authorization_code: - raise RuntimeError("Failed to receive authorization code from callback") - - logger.info(f"✓ Received MCP authorization code: {authorization_code[:16]}...") - - # Verify state matches (CSRF protection) - if returned_state != state: - raise RuntimeError( - f"State mismatch! Expected {state}, got {returned_state}" - ) - logger.info("✓ State parameter verified (CSRF protection)") - - # Step 4: Exchange authorization code for access token - logger.info("Exchanging authorization code for access token...") - - async with httpx.AsyncClient() as client: - token_response = await client.post( - f"{mcp_server_url}/oauth/token", - data={ - "grant_type": "authorization_code", - "code": authorization_code, - "code_verifier": code_verifier, - "redirect_uri": redirect_uri, - "client_id": "test-mcp-client", - }, - ) - - if token_response.status_code != 200: - logger.error(f"Token exchange failed: {token_response.status_code}") - logger.error(f"Response: {token_response.text}") - raise RuntimeError( - f"Token exchange failed: {token_response.status_code}" - ) - - token_data = token_response.json() - access_token = token_data["access_token"] - - logger.info("✓ Successfully received access token") - logger.info(f" Token: {access_token[:20]}...") - logger.info(f" Type: {token_data.get('token_type', 'Bearer')}") - logger.info(f" Expires in: {token_data.get('expires_in', 'unknown')}s") - - # Step 5: Use access token to call MCP tool - logger.info("Testing MCP tool call with access token...") - - async with httpx.AsyncClient() as client: - # Call MCP server to list notes (this will trigger token exchange in background) - mcp_request = { - "jsonrpc": "2.0", - "id": 1, - "method": "tools/call", - "params": { - "name": "nc_notes_search_notes", - "arguments": {"query": "test"}, - }, - } - - mcp_response = await client.post( - f"{mcp_server_url}/mcp", - json=mcp_request, - headers={ - "Authorization": f"Bearer {access_token}", - "Content-Type": "application/json", - "Accept": "application/json, text/event-stream", - }, - timeout=30.0, - ) - - if mcp_response.status_code != 200: - logger.error(f"MCP tool call failed: {mcp_response.status_code}") - logger.error(f"Response: {mcp_response.text}") - raise RuntimeError(f"MCP tool call failed: {mcp_response.status_code}") - - mcp_result = mcp_response.json() - - if "error" in mcp_result: - logger.error(f"MCP tool returned error: {mcp_result['error']}") - raise RuntimeError(f"MCP tool error: {mcp_result['error']}") - - logger.info("✓ MCP tool call succeeded!") - logger.info(f" Result: {mcp_result.get('result', {})}") - - # Step 6: Verify refresh token storage - logger.info("Verifying refresh token storage...") - - # Check if refresh token was stored (requires database access) - # This would require accessing the SQLite database directly - logger.info("✓ OAuth flow completed successfully!") - - # Summary - print("\n" + "=" * 70) - print("ADR-004 OAUTH FLOW TEST - SUCCESS") - print("=" * 70) - print(f"Provider: {provider}") - print(f"MCP Server: {mcp_server_url}") - print(f"Nextcloud: {nextcloud_host}") - print(f"User: {username}") - print("") - print("✓ User consented to MCP server access") - print("✓ User consented to offline_access (refresh tokens)") - print("✓ MCP server stored master refresh token") - print("✓ Client received MCP access token") - print("✓ MCP tool call succeeded") - print("✓ MCP server exchanged tokens in background") - print("✓ Nextcloud data fetched successfully") - print("=" * 70) - - return { - "success": True, - "access_token": access_token, - "provider": provider, - } - - finally: - server.shutdown() - logger.info("Stopped callback server") - - -async def main(): - parser = argparse.ArgumentParser( - description="Test ADR-004 OAuth Hybrid Flow", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -Examples: - # Test with Nextcloud OIDC - uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud - - # Test with Keycloak - uv run python tests/manual/test_adr004_oauth_flow.py --provider keycloak - - # Headless mode - uv run python tests/manual/test_adr004_oauth_flow.py --provider nextcloud --headless - """, - ) - - parser.add_argument( - "--provider", - choices=["nextcloud", "keycloak"], - required=True, - help="OAuth provider to test (nextcloud or keycloak)", - ) - - parser.add_argument( - "--mcp-server-url", - default="http://localhost:8001", - help="MCP server URL (default: http://localhost:8001 for OAuth)", - ) - - parser.add_argument( - "--nextcloud-host", - default="http://localhost:8080", - help="Nextcloud host URL (default: http://localhost:8080)", - ) - - parser.add_argument( - "--username", default="admin", help="Test user username (default: admin)" - ) - - parser.add_argument( - "--password", default="admin", help="Test user password (default: admin)" - ) - - args = parser.parse_args() - - try: - result = await test_oauth_flow( - provider=args.provider, - mcp_server_url=args.mcp_server_url, - nextcloud_host=args.nextcloud_host, - username=args.username, - password=args.password, - ) - - return 0 if result["success"] else 1 - - except Exception as e: - logger.error(f"OAuth flow test failed: {e}", exc_info=True) - print("\n" + "=" * 70) - print("ADR-004 OAUTH FLOW TEST - FAILED") - print("=" * 70) - print(f"Error: {e}") - print("=" * 70) - return 1 - - -if __name__ == "__main__": - exit_code = asyncio.run(main()) - exit(exit_code) diff --git a/tests/manual/test_impersonation.py b/tests/manual/test_impersonation.py deleted file mode 100644 index 9437a740..00000000 --- a/tests/manual/test_impersonation.py +++ /dev/null @@ -1,289 +0,0 @@ -""" -Manual test for RFC 8693 Token Exchange with USER IMPERSONATION. - -This script tests whether Keycloak actually supports the requested_subject -parameter for user impersonation, as claimed in ADR-002 to be unsupported. - -Test procedure: -1. Get service account token (client_credentials grant) -2. Attempt to exchange token WITH requested_subject parameter -3. Observe actual behavior (success or error) -4. Decode resulting token to verify sub claim - -Usage: - # Start Keycloak and app containers - docker compose up -d keycloak app - - # Run the test - uv run python tests/manual/test_impersonation.py -""" - -import asyncio -import base64 -import json -import logging -import os -import sys - -# Add parent directory to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) - -from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient -from nextcloud_mcp_server.client import NextcloudClient - -# Setup logging -logging.basicConfig( - level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s" -) -logger = logging.getLogger(__name__) - - -def decode_jwt(token: str) -> dict: - """Decode JWT token payload without verification""" - try: - # Split token and get payload (second part) - parts = token.split(".") - if len(parts) != 3: - return {"error": "Invalid JWT format"} - - # Decode payload (add padding if needed) - payload = parts[1] - padding = 4 - (len(payload) % 4) - if padding != 4: - payload += "=" * padding - - decoded = base64.urlsafe_b64decode(payload) - return json.loads(decoded) - except Exception as e: - return {"error": str(e)} - - -async def main(): - """Test token exchange with impersonation""" - - # Configuration (matches docker-compose mcp-keycloak service) - keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888") - realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp") - client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server") - client_secret = os.getenv( - "KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production" - ) - nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080") - redirect_uri = "http://localhost:8002/oauth/callback" - target_user = "admin" # User to impersonate - - logger.info("=" * 80) - logger.info("RFC 8693 Token Exchange IMPERSONATION Test") - logger.info("=" * 80) - logger.info(f"Keycloak URL: {keycloak_url}") - logger.info(f"Realm: {realm}") - logger.info(f"Client ID: {client_id}") - logger.info(f"Target User: {target_user}") - logger.info(f"Nextcloud: {nextcloud_host}") - logger.info("") - logger.info("⚠️ This test attempts impersonation to verify ADR-002 claims") - logger.info("") - - # Step 1: Create Keycloak OAuth client - logger.info("Step 1: Initializing Keycloak OAuth client...") - oauth_client = KeycloakOAuthClient( - keycloak_url=keycloak_url, - realm=realm, - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - ) - - # Discover endpoints - await oauth_client.discover() - logger.info(f"✓ Discovered token endpoint: {oauth_client.token_endpoint}") - logger.info("") - - # Step 2: Check token exchange support - logger.info("Step 2: Checking token exchange support...") - supported = await oauth_client.check_token_exchange_support() - - if not supported: - logger.error("❌ Token exchange is NOT supported by this Keycloak instance") - logger.error( - " You may need to enable it with: --features=preview --features=token-exchange" - ) - return 1 - - logger.info("✓ Token exchange is supported") - logger.info("") - - # Step 3: Get service account token - logger.info("Step 3: Requesting service account token (client_credentials)...") - try: - service_token_response = await oauth_client.get_service_account_token( - scopes=["openid", "profile", "email"] - ) - service_token = service_token_response["access_token"] - logger.info("✓ Service account token acquired") - - # Decode and show claims - service_claims = decode_jwt(service_token) - logger.info(f" Subject (sub): {service_claims.get('sub')}") - logger.info(f" Preferred username: {service_claims.get('preferred_username')}") - logger.info(f" Client ID (azp): {service_claims.get('azp')}") - except Exception as e: - logger.error(f"❌ Failed to get service account token: {e}") - return 1 - - logger.info("") - - # Step 4: Attempt token exchange WITH impersonation - logger.info( - f"Step 4: Attempting token exchange WITH impersonation (requested_subject={target_user})..." - ) - logger.info( - " 🧪 This is the actual test - will Keycloak accept requested_subject?" - ) - logger.info("") - - try: - user_token_response = await oauth_client.exchange_token_for_user( - subject_token=service_token, - target_user_id=target_user, # ← THE KEY TEST: Request impersonation - audience=None, - scopes=["openid", "profile", "email"], - ) - - user_token = user_token_response["access_token"] - logger.info("✅ Token exchange with impersonation SUCCEEDED!") - logger.info("") - logger.info("📊 Response details:") - logger.info( - f" Issued token type: {user_token_response.get('issued_token_type')}" - ) - logger.info(f" Token type: {user_token_response.get('token_type')}") - logger.info(f" Expires in: {user_token_response.get('expires_in')}s") - logger.info("") - - # Decode and analyze the exchanged token - user_claims = decode_jwt(user_token) - logger.info("📋 Token claims analysis:") - logger.info(f" Subject (sub): {user_claims.get('sub')}") - logger.info(f" Preferred username: {user_claims.get('preferred_username')}") - logger.info(f" Client ID (azp): {user_claims.get('azp')}") - logger.info(f" Audience (aud): {user_claims.get('aud')}") - logger.info("") - - # Verify if impersonation actually worked - service_sub = service_claims.get("sub") - user_sub = user_claims.get("sub") - - if service_sub != user_sub: - logger.info("✅ IMPERSONATION VERIFIED:") - logger.info(f" Original sub: {service_sub}") - logger.info(f" New sub: {user_sub}") - logger.info("") - logger.info(" ➡️ The subject claim CHANGED - impersonation worked!") - impersonation_worked = True - else: - logger.warning("⚠️ IMPERSONATION DID NOT OCCUR:") - logger.warning(f" Subject unchanged: {user_sub}") - logger.warning("") - logger.warning(" ➡️ Token exchange succeeded but sub claim is the same") - logger.warning( - " This is delegation/audience change, not impersonation" - ) - impersonation_worked = False - - except Exception as e: - logger.error("❌ Token exchange with impersonation FAILED!") - logger.error(f" Error: {e}") - logger.error("") - logger.error("📋 Error analysis:") - - # Try to extract detailed error message - error_str = str(e) - if "requested_subject" in error_str.lower(): - logger.error( - " ➡️ Error mentions 'requested_subject' - parameter not supported" - ) - elif "impersonation" in error_str.lower(): - logger.error(" ➡️ Error mentions 'impersonation' - feature not enabled") - elif "permission" in error_str.lower(): - logger.error(" ➡️ Error mentions 'permission' - client lacks permissions") - else: - logger.error(" ➡️ Generic error - check Keycloak logs for details") - - logger.error("") - logger.error("💡 Possible causes:") - logger.error(" 1. Keycloak Standard V2 doesn't support requested_subject") - logger.error(" 2. Requires Legacy V1 with --features=preview") - logger.error(" 3. Client lacks impersonation permissions") - logger.error(" 4. Target user doesn't exist") - - return 1 - - logger.info("") - - # Step 5: Test impersonated token with Nextcloud API - if impersonation_worked: - logger.info("Step 5: Testing impersonated token with Nextcloud API...") - try: - # Create Nextcloud client with exchanged token - nc_client = NextcloudClient.from_token( - base_url=nextcloud_host, token=user_token, username=target_user - ) - - # Test API call - capabilities = await nc_client.capabilities() - logger.info("✓ Nextcloud API call successful with impersonated token") - logger.info(f" Version: {capabilities.get('version', {}).get('string')}") - - await nc_client.close() - except Exception as e: - logger.error(f"❌ Nextcloud API call failed: {e}") - logger.error(" The impersonated token may not be valid for Nextcloud") - return 1 - - logger.info("") - logger.info("=" * 80) - logger.info("TEST RESULTS SUMMARY") - logger.info("=" * 80) - - if impersonation_worked: - logger.info("✅ IMPERSONATION IS SUPPORTED!") - logger.info("") - logger.info("Key findings:") - logger.info(" • Token exchange with requested_subject WORKS") - logger.info(" • Subject claim successfully changed") - logger.info(" • Impersonated token works with Nextcloud APIs") - logger.info("") - logger.info("⚠️ ADR-002 DOCUMENTATION IS INCORRECT") - logger.info(" Current docs claim impersonation doesn't work in Standard V2") - logger.info(" This test proves it DOES work!") - logger.info("") - logger.info("Action items:") - logger.info(" 1. Update ADR-002 to mark Tier 1 as IMPLEMENTED") - logger.info(" 2. Remove 'NOT IMPLEMENTED' warnings from code") - logger.info(" 3. Add automated tests for impersonation") - logger.info(" 4. Update oauth-impersonation-findings.md") - else: - logger.info("❌ IMPERSONATION IS NOT SUPPORTED") - logger.info("") - logger.info("Key findings:") - logger.info(" • Token exchange with requested_subject FAILED") - logger.info(" • Keycloak rejected the parameter") - logger.info(" • Confirms ADR-002 documentation") - logger.info("") - logger.info("✅ ADR-002 DOCUMENTATION IS CORRECT") - logger.info(" Impersonation requires Keycloak Legacy V1") - logger.info("") - logger.info("Action items:") - logger.info(" 1. Add this test as evidence to ADR-002") - logger.info(" 2. Document exact error message") - logger.info(" 3. Add 'Verified by testing' note to docs") - - logger.info("") - - return 0 if impersonation_worked else 1 - - -if __name__ == "__main__": - exit_code = asyncio.run(main()) - sys.exit(exit_code) diff --git a/tests/manual/test_nextcloud_impersonate.py b/tests/manual/test_nextcloud_impersonate.py deleted file mode 100644 index 99064b55..00000000 --- a/tests/manual/test_nextcloud_impersonate.py +++ /dev/null @@ -1,227 +0,0 @@ -""" -Manual test for Nextcloud impersonate API. - -This script tests using the Nextcloud impersonate app to allow -admin users to act on behalf of other users. - -This is NOT the same as OAuth token exchange, but could serve -as a workaround for background operations. - -Usage: - # Start app container - docker compose up -d app - - # Run the test - uv run python tests/manual/test_nextcloud_impersonate.py -""" - -import asyncio -import logging -import os -import re -import sys - -# Add parent directory to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) - -import httpx - -# Setup logging -logging.basicConfig( - level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s" -) -logger = logging.getLogger(__name__) - - -async def main(): - """Test Nextcloud impersonate API""" - - # Configuration - nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080") - admin_user = os.getenv("NEXTCLOUD_USERNAME", "admin") - admin_password = os.getenv("NEXTCLOUD_PASSWORD", "admin") - target_user = "testuser" # We'll create this user - - logger.info("=" * 80) - logger.info("Nextcloud Impersonate API Test") - logger.info("=" * 80) - logger.info(f"Nextcloud: {nextcloud_host}") - logger.info(f"Admin user: {admin_user}") - logger.info(f"Target user: {target_user}") - logger.info("") - - async with httpx.AsyncClient() as client: - # Step 1: Login as admin and get session - logger.info("Step 1: Logging in as admin...") - login_response = await client.post( - f"{nextcloud_host}/login", - data={ - "user": admin_user, - "password": admin_password, - }, - follow_redirects=True, - ) - - if login_response.status_code != 200: - logger.error(f"❌ Admin login failed: {login_response.status_code}") - return 1 - - # Get requesttoken from response - requesttoken = None - for cookie in client.cookies.jar: - if cookie.name == "nc_session": - logger.info(f"✓ Admin logged in, session: {cookie.value[:20]}...") - break - - logger.info("") - - # Step 2: Create test user if doesn't exist - logger.info(f"Step 2: Creating test user '{target_user}'...") - create_user_response = await client.post( - f"{nextcloud_host}/ocs/v1.php/cloud/users", - auth=(admin_user, admin_password), - data={ - "userid": target_user, - "password": "testpassword123", - }, - headers={"OCS-APIRequest": "true"}, - ) - - if create_user_response.status_code in (200, 400): # 400 if already exists - logger.info("✓ Test user ready") - else: - logger.warning( - f"User creation response: {create_user_response.status_code}" - ) - - # Make sure user has logged in at least once (requirement for impersonation) - logger.info(f" Performing initial login for {target_user}...") - await client.post( - f"{nextcloud_host}/login", - data={ - "user": target_user, - "password": "testpassword123", - }, - follow_redirects=True, - ) - logger.info("✓ Test user has logged in") - - # Re-login as admin - await client.post( - f"{nextcloud_host}/login", - data={ - "user": admin_user, - "password": admin_password, - }, - follow_redirects=True, - ) - - logger.info("") - - # Step 3: Get CSRF token for impersonate request - logger.info("Step 3: Getting CSRF token...") - - # Try to get token from settings page - settings_response = await client.get( - f"{nextcloud_host}/settings/users", - follow_redirects=True, - ) - - # Extract requesttoken from HTML - - token_match = re.search(r'data-requesttoken="([^"]+)"', settings_response.text) - if token_match: - requesttoken = token_match.group(1) - logger.info(f"✓ CSRF token acquired: {requesttoken[:20]}...") - else: - logger.error("❌ Could not extract CSRF token from page") - return 1 - - logger.info("") - - # Step 4: Call impersonate API - logger.info(f"Step 4: Impersonating user '{target_user}'...") - impersonate_response = await client.post( - f"{nextcloud_host}/apps/impersonate/user", - data={ - "userId": target_user, - }, - headers={ - "requesttoken": requesttoken, - "Content-Type": "application/x-www-form-urlencoded", - }, - ) - - if impersonate_response.status_code != 200: - logger.error(f"❌ Impersonate failed: {impersonate_response.status_code}") - logger.error(f"Response: {impersonate_response.text}") - return 1 - - logger.info("✓ Impersonation successful") - logger.info("") - - # Step 5: Test API call as impersonated user - logger.info("Step 5: Testing API call as impersonated user...") - capabilities_response = await client.get( - f"{nextcloud_host}/ocs/v2.php/cloud/capabilities", - headers={"OCS-APIRequest": "true"}, - ) - - if capabilities_response.status_code == 200: - caps = capabilities_response.json() - logger.info(f"✓ API call successful as {target_user}") - logger.info( - f" Version: {caps.get('ocs', {}).get('data', {}).get('version', {}).get('string')}" - ) - else: - logger.error(f"❌ API call failed: {capabilities_response.status_code}") - return 1 - - logger.info("") - - # Step 6: Get current user to verify impersonation - logger.info("Step 6: Verifying current user...") - user_response = await client.get( - f"{nextcloud_host}/ocs/v2.php/cloud/user", - headers={"OCS-APIRequest": "true"}, - ) - - if user_response.status_code == 200: - user_data = user_response.json() - current_user = user_data.get("ocs", {}).get("data", {}).get("id") - logger.info(f"✓ Current user: {current_user}") - - if current_user == target_user: - logger.info(" ✓ Successfully impersonating target user!") - else: - logger.warning(f" ⚠ Expected {target_user}, got {current_user}") - else: - logger.error(f"❌ User check failed: {user_response.status_code}") - - logger.info("") - logger.info("=" * 80) - logger.info("✅ Impersonate API Test PASSED") - logger.info("=" * 80) - logger.info("") - logger.info("Summary:") - logger.info(" 1. Admin can impersonate other users via session-based API") - logger.info(" 2. Impersonated session can access APIs as that user") - logger.info(" 3. Requires admin credentials and CSRF token") - logger.info("") - logger.info("Limitations:") - logger.info(" - Session-based (not stateless like OAuth)") - logger.info(" - Requires admin credentials") - logger.info(" - Target user must have logged in at least once") - logger.info(" - Not suitable for distributed/background workers") - logger.info("") - logger.info("For background operations, consider:") - logger.info(" - Use service account with appropriate permissions") - logger.info(" - Or implement proper OAuth delegation (RFC 8693)") - logger.info("") - - return 0 - - -if __name__ == "__main__": - exit_code = asyncio.run(main()) - sys.exit(exit_code) diff --git a/tests/manual/test_token_exchange.py b/tests/manual/test_token_exchange.py deleted file mode 100644 index 3c4b5858..00000000 --- a/tests/manual/test_token_exchange.py +++ /dev/null @@ -1,196 +0,0 @@ -""" -Manual test for RFC 8693 Token Exchange with Keycloak. - -This script demonstrates ADR-002 Tier 2 implementation: -1. Get service account token (client_credentials grant) -2. Exchange token for user-scoped token (RFC 8693) -3. Use exchanged token to access Nextcloud APIs - -Usage: - # Start Keycloak and app containers - docker compose up -d keycloak app - - # Run the test - uv run python tests/manual/test_token_exchange.py -""" - -import asyncio -import logging -import os -import sys - -# Add parent directory to path -sys.path.insert(0, os.path.join(os.path.dirname(__file__), "../..")) - -from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient -from nextcloud_mcp_server.client import NextcloudClient - -# Setup logging -logging.basicConfig( - level=logging.INFO, format="%(levelname)-8s | %(name)-30s | %(message)s" -) -logger = logging.getLogger(__name__) - - -async def main(): - """Test token exchange flow""" - - # Configuration (matches docker-compose mcp-keycloak service) - keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888") - realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp") - client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server") - client_secret = os.getenv( - "KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production" - ) - nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080") - redirect_uri = "http://localhost:8002/oauth/callback" - - logger.info("=" * 80) - logger.info("RFC 8693 Token Exchange Test") - logger.info("=" * 80) - logger.info(f"Keycloak URL: {keycloak_url}") - logger.info(f"Realm: {realm}") - logger.info(f"Client ID: {client_id}") - logger.info(f"Nextcloud: {nextcloud_host}") - logger.info("") - - # Step 1: Create Keycloak OAuth client - logger.info("Step 1: Initializing Keycloak OAuth client...") - oauth_client = KeycloakOAuthClient( - keycloak_url=keycloak_url, - realm=realm, - client_id=client_id, - client_secret=client_secret, - redirect_uri=redirect_uri, - ) - - # Discover endpoints - await oauth_client.discover() - logger.info(f"✓ Discovered token endpoint: {oauth_client.token_endpoint}") - logger.info("") - - # Step 2: Check token exchange support - logger.info("Step 2: Checking token exchange support...") - supported = await oauth_client.check_token_exchange_support() - - if not supported: - logger.error("❌ Token exchange is NOT supported by this Keycloak instance") - logger.error( - " You may need to enable it with: --features=preview --features=token-exchange" - ) - return 1 - - logger.info("") - - # Step 3: Get service account token - # ⚠️ WARNING: Service account tokens MUST NOT be used directly with Nextcloud APIs! - # Using this token directly violates OAuth "act on-behalf-of" principles: - # - Creates Nextcloud user: service-account-{client_id} - # - Breaks audit trail (actions not attributable to real user) - # - Creates stateful server identity in Nextcloud - # - # VALID USE: ONLY as subject_token for RFC 8693 token exchange (Step 4 below) - # INVALID USE: Direct API access (see ADR-002 "Will Not Implement" section) - # - # If you need background operations without token exchange support, use BasicAuth mode. - logger.info("Step 3: Requesting service account token (client_credentials)...") - try: - service_token_response = await oauth_client.get_service_account_token( - scopes=["openid", "profile", "email"] - ) - service_token = service_token_response["access_token"] - logger.info("✓ Service account token acquired") - logger.info(f" Token type: {service_token_response.get('token_type')}") - logger.info(f" Expires in: {service_token_response.get('expires_in')}s") - logger.info(f" Scope: {service_token_response.get('scope')}") - logger.info(f" Token (first 50 chars): {service_token[:50]}...") - except Exception as e: - logger.error(f"❌ Failed to get service account token: {e}") - logger.error( - " Make sure serviceAccountsEnabled=true for the client in Keycloak" - ) - return 1 - - logger.info("") - - # Step 4: Exchange token (without impersonation - Standard V2) - logger.info( - "Step 4: Exchanging service token with different audience (RFC 8693)..." - ) - logger.info(" Note: Keycloak Standard V2 doesn't support user impersonation") - logger.info(" That requires Legacy V1 with --features=preview") - try: - user_token_response = await oauth_client.exchange_token_for_user( - subject_token=service_token, - target_user_id=None, # Don't request impersonation - audience=None, # No cross-client exchange in Standard V2 - scopes=["openid", "profile"], # Try downscoping - ) - user_token = user_token_response["access_token"] - logger.info("✓ Token exchange successful") - logger.info( - f" Issued token type: {user_token_response.get('issued_token_type')}" - ) - logger.info(f" Token type: {user_token_response.get('token_type')}") - logger.info(f" Expires in: {user_token_response.get('expires_in')}s") - logger.info(f" User token (first 50 chars): {user_token[:50]}...") - except Exception as e: - logger.error(f"❌ Token exchange failed: {e}") - logger.error(" Possible causes:") - logger.error(" - token.exchange.grant.enabled not set to true") - logger.error(" - Missing exchange permissions in Keycloak") - logger.error(" - User 'admin' does not exist") - return 1 - - logger.info("") - - # Step 5: Test user token with Nextcloud API - logger.info("Step 5: Testing exchanged token with Nextcloud capabilities API...") - try: - # Create Nextcloud client with exchanged token - nc_client = NextcloudClient.from_token( - base_url=nextcloud_host, token=user_token, username="admin" - ) - - # Test API call - capabilities = await nc_client.capabilities() - logger.info("✓ Nextcloud API call successful") - logger.info(f" Version: {capabilities.get('version', {}).get('string')}") - logger.info( - f" Edition: {capabilities.get('capabilities', {}).get('core', {}).get('webdav-root')}" - ) - - await nc_client.close() - except Exception as e: - logger.error(f"❌ Nextcloud API call failed: {e}") - logger.error(" The exchanged token may not be valid for Nextcloud") - logger.error(" Check that user_oidc app is configured correctly") - return 1 - - logger.info("") - logger.info("=" * 80) - logger.info("✅ Token Exchange Test PASSED") - logger.info("=" * 80) - logger.info("") - logger.info("Summary:") - logger.info(" 1. Service account token acquired") - logger.info(" 2. Token exchanged with different audience") - logger.info(" 3. Exchanged token works with Nextcloud APIs") - logger.info("") - logger.info("This demonstrates ADR-002 Tier 2: Token Exchange") - logger.info( - "The MCP server can perform token exchange for different audiences/scopes" - ) - logger.info("without needing refresh tokens or admin credentials.") - logger.info("") - logger.info( - "Note: User impersonation requires Keycloak Legacy V1 with --features=preview" - ) - logger.info("") - - return 0 - - -if __name__ == "__main__": - exit_code = asyncio.run(main()) - sys.exit(exit_code) diff --git a/tests/server/auth/test_userinfo_routes.py b/tests/server/auth/test_userinfo_routes.py index 0e5c9cd1..8a7878c9 100644 --- a/tests/server/auth/test_userinfo_routes.py +++ b/tests/server/auth/test_userinfo_routes.py @@ -1,11 +1,6 @@ """Unit tests for user info routes. -Note: Most unit tests were removed as they relied on the old _get_user_info API. -The new browser OAuth session-based implementation is covered by integration tests -in tests/server/oauth/test_userinfo_integration.py which test the full OAuth flow -with real browser sessions, token storage, and IdP interactions. - -These unit tests cover only the simple _query_idp_userinfo helper function. +These unit tests cover the simple _query_idp_userinfo helper function. """ from unittest.mock import AsyncMock, Mock diff --git a/tests/server/login_flow/conftest.py b/tests/server/login_flow/conftest.py index 9253ee6f..73c65c03 100644 --- a/tests/server/login_flow/conftest.py +++ b/tests/server/login_flow/conftest.py @@ -24,6 +24,9 @@ from mcp.types import ElicitRequestParams, ElicitResult from tests.conftest import ( DEFAULT_FULL_SCOPES, + DEFAULT_READ_SCOPES, + DEFAULT_WRITE_SCOPES, + _get_oauth_token_with_scopes, _handle_oauth_consent_screen, create_mcp_client_session, get_mcp_server_resource_metadata, @@ -415,3 +418,511 @@ async def nc_mcp_login_flow_client( ) yield session + + +# --------------------------------------------------------------------------- +# Scope-filtered OAuth client fixtures for scope authorization tests +# These obtain tokens with specific scope subsets via the login-flow server +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="session") +async def login_flow_read_only_token( + anyio_backend, + browser, + login_flow_oauth_client_credentials, + oauth_callback_server, +) -> str: + """OAuth token with read-only scopes for the login-flow MCP server.""" + return await _get_oauth_token_with_scopes( + browser, + login_flow_oauth_client_credentials, + oauth_callback_server, + scopes=DEFAULT_READ_SCOPES, + mcp_server_base_url=LOGIN_FLOW_MCP_BASE_URL, + ) + + +@pytest.fixture(scope="session") +async def login_flow_write_only_token( + anyio_backend, + browser, + login_flow_oauth_client_credentials, + oauth_callback_server, +) -> str: + """OAuth token with write-only scopes for the login-flow MCP server.""" + return await _get_oauth_token_with_scopes( + browser, + login_flow_oauth_client_credentials, + oauth_callback_server, + scopes=DEFAULT_WRITE_SCOPES, + mcp_server_base_url=LOGIN_FLOW_MCP_BASE_URL, + ) + + +@pytest.fixture(scope="session") +async def login_flow_full_access_token( + anyio_backend, + browser, + login_flow_oauth_client_credentials, + oauth_callback_server, +) -> str: + """OAuth token with full access scopes for the login-flow MCP server.""" + return await _get_oauth_token_with_scopes( + browser, + login_flow_oauth_client_credentials, + oauth_callback_server, + scopes=DEFAULT_FULL_SCOPES, + mcp_server_base_url=LOGIN_FLOW_MCP_BASE_URL, + ) + + +@pytest.fixture(scope="session") +async def login_flow_no_custom_scopes_token( + anyio_backend, + browser, + login_flow_oauth_client_credentials, + oauth_callback_server, +) -> str: + """OAuth token with no custom scopes (only OIDC defaults) for the login-flow MCP server.""" + return await _get_oauth_token_with_scopes( + browser, + login_flow_oauth_client_credentials, + oauth_callback_server, + scopes="openid profile email", + mcp_server_base_url=LOGIN_FLOW_MCP_BASE_URL, + ) + + +@pytest.fixture(scope="session") +async def nc_mcp_login_flow_client_read_only( + anyio_backend, login_flow_read_only_token: str +) -> AsyncGenerator[ClientSession, Any]: + """MCP client with read-only scopes on the login-flow server.""" + async for session in create_mcp_client_session( + url=LOGIN_FLOW_MCP_URL, + token=login_flow_read_only_token, + client_name="Login Flow MCP Read-Only", + ): + yield session + + +@pytest.fixture(scope="session") +async def nc_mcp_login_flow_client_write_only( + anyio_backend, login_flow_write_only_token: str +) -> AsyncGenerator[ClientSession, Any]: + """MCP client with write-only scopes on the login-flow server.""" + async for session in create_mcp_client_session( + url=LOGIN_FLOW_MCP_URL, + token=login_flow_write_only_token, + client_name="Login Flow MCP Write-Only", + ): + yield session + + +@pytest.fixture(scope="session") +async def nc_mcp_login_flow_client_full_access( + anyio_backend, login_flow_full_access_token: str +) -> AsyncGenerator[ClientSession, Any]: + """MCP client with full access scopes on the login-flow server.""" + async for session in create_mcp_client_session( + url=LOGIN_FLOW_MCP_URL, + token=login_flow_full_access_token, + client_name="Login Flow MCP Full Access", + ): + yield session + + +@pytest.fixture(scope="session") +async def nc_mcp_login_flow_client_no_custom_scopes( + anyio_backend, login_flow_no_custom_scopes_token: str +) -> AsyncGenerator[ClientSession, Any]: + """MCP client with no custom scopes on the login-flow server.""" + async for session in create_mcp_client_session( + url=LOGIN_FLOW_MCP_URL, + token=login_flow_no_custom_scopes_token, + client_name="Login Flow MCP No Custom Scopes", + ): + yield session + + +# --------------------------------------------------------------------------- +# Multi-user Login Flow fixtures for permission / isolation tests +# --------------------------------------------------------------------------- + + +async def _get_login_flow_token_for_user( + browser, + login_flow_oauth_client_credentials, + auth_states: dict, + username: str, + password: str, +) -> str: + """Get an OAuth token for a specific user targeting the login-flow MCP server. + + Similar to the global ``_get_oauth_token_for_user`` but hard-wires the + resource / PRM discovery against port 8004. + """ + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + if not nextcloud_host: + pytest.skip("Login Flow tests require NEXTCLOUD_HOST") + + client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = ( + login_flow_oauth_client_credentials + ) + + # Discover resource identifier from the login-flow server + try: + resource_metadata = await get_mcp_server_resource_metadata( + LOGIN_FLOW_MCP_BASE_URL + ) + resource_id = resource_metadata.get("resource") + except Exception: + resource_id = None + + state = secrets.token_urlsafe(32) + + scopes_encoded = quote(DEFAULT_FULL_SCOPES, safe="") + 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}" + ) + if resource_id: + auth_url += f"&resource={quote(resource_id, safe='')}" + + context = await browser.new_context(ignore_https_errors=True) + page = await context.new_page() + + try: + await page.goto(auth_url, wait_until="networkidle", timeout=60000) + current_url = page.url + + # Login + if "/login" in current_url or "/index.php/login" in current_url: + await page.wait_for_selector('input[name="user"]', timeout=10000) + await page.fill('input[name="user"]', username) + await page.fill('input[name="password"]', password) + await page.click('button[type="submit"]') + await page.wait_for_load_state("networkidle", timeout=60000) + + # Wait for OIDC redirect chain to settle + settle_start = time.time() + while time.time() - settle_start < 15: + current_url = page.url + if "/consent" in current_url or "localhost:8081" in current_url: + break + await anyio.sleep(0.5) + + # Handle consent screen + if "/consent" in page.url: + await page.wait_for_load_state("networkidle", timeout=10000) + await _handle_oauth_consent_screen(page, username) + + # Wait for callback + start_time = time.time() + while state not in auth_states: + if time.time() - start_time > 30: + screenshot_path = f"/tmp/login_flow_oauth_timeout_{username}.png" + await page.screenshot(path=screenshot_path) + raise TimeoutError(f"Timeout waiting for OAuth callback for {username}") + await anyio.sleep(0.5) + + auth_code = auth_states[state] + finally: + await context.close() + + # Exchange code for token + async with httpx.AsyncClient(timeout=30.0) as http_client: + token_response = await http_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() + return token_response.json()["access_token"] + + +@pytest.fixture(scope="session") +async def all_login_flow_user_tokens( + anyio_backend, + browser, + login_flow_oauth_client_credentials, + test_users_setup, + oauth_callback_server, +) -> dict[str, str]: + """Fetch OAuth tokens for all test users in parallel, targeting port 8004.""" + auth_states, _ = oauth_callback_server + + start_time = time.time() + logger.info("Fetching login-flow OAuth tokens for all users in parallel...") + + results: dict[str, str | Exception] = {} + + async def _fetch(username: str, config: dict) -> None: + try: + token = await _get_login_flow_token_for_user( + browser, + login_flow_oauth_client_credentials, + auth_states, + username, + config["password"], + ) + results[username] = token + except Exception as exc: + results[username] = exc + + user_list = list(test_users_setup.items()) + async with anyio.create_task_group() as tg: + for username, config in user_list: + tg.start_soon(_fetch, username, config) + + for username, result in results.items(): + if isinstance(result, Exception): + raise result + + elapsed = time.time() - start_time + logger.info( + f"Fetched {len(results)} login-flow tokens in {elapsed:.1f}s " + f"(~{elapsed / len(results):.1f}s per user)" + ) + return results # type: ignore[return-value] + + +async def _provision_login_flow_mcp_client( + token: str, + browser, + username: str, + password: str, +) -> AsyncGenerator[ClientSession, Any]: + """Connect to login-flow MCP server, complete Login Flow v2 provisioning, yield session.""" + login_url_holder: dict[str, str] = {} + + async def elicitation_callback( + context: Any, + params: ElicitRequestParams, + ) -> ElicitResult: + message = params.message + for line in message.split("\n"): + stripped = line.strip() + if stripped.startswith("http") and "/login/v2/" in stripped: + login_url_holder["url"] = stripped + break + + if "url" in login_url_holder: + await _complete_login_flow_v2_as_user( + browser, login_url_holder["url"], username, password + ) + + return ElicitResult(action="accept", content={"acknowledged": True}) + + async for session in create_mcp_client_session( + url=LOGIN_FLOW_MCP_URL, + token=token, + client_name=f"Login Flow MCP ({username})", + elicitation_callback=elicitation_callback, + ): + # Provision access + provision_result = await session.call_tool( + "nc_auth_provision_access", {"scopes": None} + ) + provision_data = json.loads(provision_result.content[0].text) + + if provision_data.get("status") == "login_required": + login_url = provision_data.get("login_url") + if login_url and "url" not in login_url_holder: + await _complete_login_flow_v2_as_user( + browser, login_url, username, password + ) + + # Poll for completion + for attempt in range(15): + status_result = await session.call_tool("nc_auth_check_status", {}) + status_data = json.loads(status_result.content[0].text) + if status_data.get("status") == "provisioned": + logger.info( + f"Login Flow v2 provisioned for {username}: " + f"{status_data.get('username')}" + ) + break + if status_data.get("status") in ("not_initiated", "error"): + raise RuntimeError( + f"Login Flow v2 failed for {username}: {status_data.get('message')}" + ) + await anyio.sleep(2) + else: + raise TimeoutError( + f"Login Flow v2 did not complete for {username} after 15 attempts" + ) + + yield session + + +async def _complete_login_flow_v2_as_user( + browser, login_url: str, username: str, password: str +) -> None: + """Complete Nextcloud Login Flow v2 in a browser as a specific user. + + The full Nextcloud Login Flow v2 has these steps: + 1. "Connect to your account" page -> click "Log in" button + 2. Login form -> fill username/password, submit + (if already logged in via session cookie, this step is skipped) + 3. "Account access" grant page -> click "Grant access" button + 4. Password confirmation dialog -> enter password, click "Confirm" + 5. "Account connected" success page + + Same flow as ``_complete_login_flow_v2`` but uses the given *username* and + *password* instead of reading from environment variables. + """ + login_url = _rewrite_login_flow_url(login_url) + + context = await browser.new_context(ignore_https_errors=True) + page = await context.new_page() + + try: + logger.info(f"[{username}] Opening Login Flow v2 URL: {login_url[:80]}...") + await page.goto(login_url, wait_until="networkidle", timeout=60000) + logger.info(f"[{username}] Step 1 - Current URL: {page.url}") + + # Step 1: "Connect to your account" page - click "Log in" + login_btn = page.get_by_role("button", name="Log in") + try: + await login_btn.wait_for(timeout=10000) + await login_btn.click() + logger.info(f"[{username}] Clicked 'Log in' on Connect page") + await page.wait_for_load_state("networkidle", timeout=30000) + except Exception: + logger.info( + f"[{username}] No 'Log in' button - may already be on login/grant page" + ) + + logger.info(f"[{username}] Step 2 - Current URL: {page.url}") + + # Step 2: Login form (only if not already logged in) + user_field = page.locator('input[name="user"]') + if await user_field.count() > 0: + logger.info(f"[{username}] Login form detected, filling credentials...") + await user_field.fill(username) + await page.locator('input[name="password"]').fill(password) + await page.get_by_role("button", name="Log in", exact=True).click() + await page.wait_for_load_state("networkidle", timeout=60000) + logger.info(f"[{username}] After login: {page.url}") + else: + logger.info(f"[{username}] No login form - already logged in via session") + + # Step 3: "Account access" grant page - click "Grant access" + grant_btn = page.get_by_role("button", name="Grant access") + try: + await grant_btn.wait_for(timeout=15000) + await grant_btn.click() + logger.info(f"[{username}] Clicked 'Grant access'") + except Exception as e: + logger.warning(f"[{username}] No Grant access button: {e}") + await page.screenshot(path=f"/tmp/login_flow_no_grant_{username}.png") + + # Step 4: Password confirmation dialog + confirm_password = page.get_by_role("dialog").get_by_role( + "textbox", name="Password" + ) + try: + await confirm_password.wait_for(timeout=10000) + logger.info(f"[{username}] Password confirmation dialog detected") + await confirm_password.fill(password) + confirm_btn = page.get_by_role("dialog").get_by_role( + "button", name="Confirm" + ) + await confirm_btn.wait_for(timeout=5000) + await confirm_btn.click() + logger.info(f"[{username}] Clicked 'Confirm' in password dialog") + except Exception: + logger.info( + f"[{username}] No password confirmation dialog " + "(may have been auto-confirmed)" + ) + + # Step 5: Wait for "Account connected" success page + try: + await page.get_by_text("Account connected").wait_for(timeout=15000) + logger.info(f"[{username}] Login Flow v2 completed: Account connected!") + except Exception: + await page.wait_for_load_state("networkidle", timeout=10000) + logger.info(f"[{username}] Login Flow v2 done. Final URL: {page.url}") + + finally: + await context.close() + + +@pytest.fixture(scope="session") +async def alice_login_flow_mcp_client( + anyio_backend, + all_login_flow_user_tokens: dict[str, str], + test_users_setup, + browser, +) -> AsyncGenerator[ClientSession, Any]: + """MCP client authenticated and provisioned as alice (owner role).""" + async for session in _provision_login_flow_mcp_client( + token=all_login_flow_user_tokens["alice"], + browser=browser, + username="alice", + password=test_users_setup["alice"]["password"], + ): + yield session + + +@pytest.fixture(scope="session") +async def bob_login_flow_mcp_client( + anyio_backend, + all_login_flow_user_tokens: dict[str, str], + test_users_setup, + browser, +) -> AsyncGenerator[ClientSession, Any]: + """MCP client authenticated and provisioned as bob (viewer role).""" + async for session in _provision_login_flow_mcp_client( + token=all_login_flow_user_tokens["bob"], + browser=browser, + username="bob", + password=test_users_setup["bob"]["password"], + ): + yield session + + +@pytest.fixture(scope="session") +async def charlie_login_flow_mcp_client( + anyio_backend, + all_login_flow_user_tokens: dict[str, str], + test_users_setup, + browser, +) -> AsyncGenerator[ClientSession, Any]: + """MCP client authenticated and provisioned as charlie (editor role).""" + async for session in _provision_login_flow_mcp_client( + token=all_login_flow_user_tokens["charlie"], + browser=browser, + username="charlie", + password=test_users_setup["charlie"]["password"], + ): + yield session + + +@pytest.fixture(scope="session") +async def diana_login_flow_mcp_client( + anyio_backend, + all_login_flow_user_tokens: dict[str, str], + test_users_setup, + browser, +) -> AsyncGenerator[ClientSession, Any]: + """MCP client authenticated and provisioned as diana (no-access role).""" + async for session in _provision_login_flow_mcp_client( + token=all_login_flow_user_tokens["diana"], + browser=browser, + username="diana", + password=test_users_setup["diana"]["password"], + ): + yield session diff --git a/tests/server/oauth/test_dcr_deletion_methods.py b/tests/server/login_flow/test_dcr_deletion_methods.py similarity index 99% rename from tests/server/oauth/test_dcr_deletion_methods.py rename to tests/server/login_flow/test_dcr_deletion_methods.py index c2214ff7..0f305f0d 100644 --- a/tests/server/oauth/test_dcr_deletion_methods.py +++ b/tests/server/login_flow/test_dcr_deletion_methods.py @@ -16,7 +16,7 @@ from nextcloud_mcp_server.auth.client_registration import register_client logger = logging.getLogger(__name__) -pytestmark = [pytest.mark.integration, pytest.mark.oauth] +pytestmark = [pytest.mark.integration, pytest.mark.login_flow] @pytest.mark.integration diff --git a/tests/server/oauth/test_dcr_lifecycle.py b/tests/server/login_flow/test_dcr_lifecycle.py similarity index 99% rename from tests/server/oauth/test_dcr_lifecycle.py rename to tests/server/login_flow/test_dcr_lifecycle.py index f1905cdf..7c22c2dd 100644 --- a/tests/server/oauth/test_dcr_lifecycle.py +++ b/tests/server/login_flow/test_dcr_lifecycle.py @@ -26,7 +26,7 @@ from ...conftest import _handle_oauth_consent_screen logger = logging.getLogger(__name__) -pytestmark = [pytest.mark.integration, pytest.mark.oauth] +pytestmark = [pytest.mark.integration, pytest.mark.login_flow] async def get_oauth_token_with_client( diff --git a/tests/server/oauth/test_dcr_new_implementation.py b/tests/server/login_flow/test_dcr_new_implementation.py similarity index 99% rename from tests/server/oauth/test_dcr_new_implementation.py rename to tests/server/login_flow/test_dcr_new_implementation.py index 3462c758..05c57731 100644 --- a/tests/server/oauth/test_dcr_new_implementation.py +++ b/tests/server/login_flow/test_dcr_new_implementation.py @@ -12,7 +12,7 @@ import pytest logger = logging.getLogger(__name__) -pytestmark = [pytest.mark.integration, pytest.mark.oauth] +pytestmark = [pytest.mark.integration, pytest.mark.login_flow] @pytest.mark.integration diff --git a/tests/server/oauth/test_dcr_token_type.py b/tests/server/login_flow/test_dcr_token_type.py similarity index 99% rename from tests/server/oauth/test_dcr_token_type.py rename to tests/server/login_flow/test_dcr_token_type.py index e5abd26b..60174c85 100644 --- a/tests/server/oauth/test_dcr_token_type.py +++ b/tests/server/login_flow/test_dcr_token_type.py @@ -30,7 +30,7 @@ from ...conftest import _handle_oauth_consent_screen logger = logging.getLogger(__name__) -pytestmark = [pytest.mark.integration, pytest.mark.oauth] +pytestmark = [pytest.mark.integration, pytest.mark.login_flow] def is_jwt_format(token: str) -> bool: diff --git a/tests/server/oauth/test_introspection_authorization.py b/tests/server/login_flow/test_introspection_authorization.py similarity index 99% rename from tests/server/oauth/test_introspection_authorization.py rename to tests/server/login_flow/test_introspection_authorization.py index 2c4315ff..43a6bfe6 100644 --- a/tests/server/oauth/test_introspection_authorization.py +++ b/tests/server/login_flow/test_introspection_authorization.py @@ -27,7 +27,7 @@ from ...conftest import _handle_oauth_consent_screen logger = logging.getLogger(__name__) -pytestmark = [pytest.mark.integration, pytest.mark.oauth] +pytestmark = [pytest.mark.integration, pytest.mark.login_flow] @pytest.fixture(scope="module") diff --git a/tests/server/login_flow/test_multi_user_permissions.py b/tests/server/login_flow/test_multi_user_permissions.py new file mode 100644 index 00000000..a0faee00 --- /dev/null +++ b/tests/server/login_flow/test_multi_user_permissions.py @@ -0,0 +1,540 @@ +"""Multi-user permission tests for Login Flow v2 deployment mode. + +Tests verify that Nextcloud's sharing / ACL enforcement works correctly +when resources are accessed through MCP tools by different users, each +authenticated via Login Flow v2. + +Ported from the removed ``tests/server/oauth/test_oauth_*_permissions.py`` +tests. The underlying assertions are deployment-mode-agnostic; only the +transport changed (OAuth MCP server -> Login Flow v2 MCP server). +""" + +import json +import logging + +import pytest +from mcp import ClientSession + +logger = logging.getLogger(__name__) + +pytestmark = [pytest.mark.integration, pytest.mark.login_flow] + + +# --------------------------------------------------------------------------- +# WebDAV / Files +# --------------------------------------------------------------------------- + + +class TestFilePermissions: + """Test that MCP file tools respect Nextcloud sharing permissions.""" + + async def test_file_share_read_permissions( + self, + alice_login_flow_mcp_client: ClientSession, + bob_login_flow_mcp_client: ClientSession, + diana_login_flow_mcp_client: ClientSession, + ): + """Alice shares a file with Bob (read-only). Bob can read it; + Diana (unshared) cannot.""" + file_path = "/alice_shared_file_read.txt" + file_content = "This file is shared with Bob for reading only." + + # Alice creates the file + result = await alice_login_flow_mcp_client.call_tool( + "nc_webdav_write_file", + arguments={"path": file_path, "content": file_content}, + ) + assert not result.isError, f"Alice failed to create file: {result.content}" + + share_id = None + try: + # Alice shares with Bob (read-only, permissions=1) + result = await alice_login_flow_mcp_client.call_tool( + "nc_share_create", + arguments={ + "path": file_path, + "share_with": "bob", + "share_type": 0, + "permissions": 1, + }, + ) + assert not result.isError, f"Share creation failed: {result.content}" + share_data = json.loads(result.content[0].text) + share_id = share_data["id"] + + # Bob reads the file + result = await bob_login_flow_mcp_client.call_tool( + "nc_webdav_read_file", arguments={"path": file_path} + ) + assert not result.isError, ( + f"Bob could not read shared file: {result.content}" + ) + response_data = json.loads(result.content[0].text) + assert file_content in response_data["content"] + + # Diana cannot read the file + result = await diana_login_flow_mcp_client.call_tool( + "nc_webdav_read_file", arguments={"path": file_path} + ) + assert result.isError, "Diana should not be able to read unshared file" + + finally: + if share_id: + await alice_login_flow_mcp_client.call_tool( + "nc_share_delete", arguments={"share_id": share_id} + ) + await alice_login_flow_mcp_client.call_tool( + "nc_webdav_delete_resource", arguments={"path": file_path} + ) + + async def test_file_share_write_permissions( + self, + alice_login_flow_mcp_client: ClientSession, + charlie_login_flow_mcp_client: ClientSession, + bob_login_flow_mcp_client: ClientSession, + ): + """Alice shares a file with Charlie (edit) and Bob (read-only). + Charlie can overwrite; Bob cannot.""" + file_path = "/alice_shared_file_write.txt" + file_content = "This file is shared with Charlie for editing." + + result = await alice_login_flow_mcp_client.call_tool( + "nc_webdav_write_file", + arguments={"path": file_path, "content": file_content}, + ) + assert not result.isError + + charlie_share_id = None + bob_share_id = None + try: + # Share with Charlie (read+write, permissions=3) + result = await alice_login_flow_mcp_client.call_tool( + "nc_share_create", + arguments={ + "path": file_path, + "share_with": "charlie", + "share_type": 0, + "permissions": 3, + }, + ) + assert not result.isError + charlie_share_id = json.loads(result.content[0].text)["id"] + + # Share with Bob (read-only, permissions=1) + result = await alice_login_flow_mcp_client.call_tool( + "nc_share_create", + arguments={ + "path": file_path, + "share_with": "bob", + "share_type": 0, + "permissions": 1, + }, + ) + assert not result.isError + bob_share_id = json.loads(result.content[0].text)["id"] + + # Charlie can write + result = await charlie_login_flow_mcp_client.call_tool( + "nc_webdav_write_file", + arguments={ + "path": file_path, + "content": f"{file_content}\nCharlie added this line.", + }, + ) + assert not result.isError, ( + f"Charlie should be able to write: {result.content}" + ) + + # Bob cannot write + result = await bob_login_flow_mcp_client.call_tool( + "nc_webdav_write_file", + arguments={ + "path": file_path, + "content": "Bob tries to overwrite this.", + }, + ) + assert result.isError, "Bob should be denied write access (read-only)" + + finally: + for sid in (charlie_share_id, bob_share_id): + if sid: + await alice_login_flow_mcp_client.call_tool( + "nc_share_delete", arguments={"share_id": sid} + ) + await alice_login_flow_mcp_client.call_tool( + "nc_webdav_delete_resource", arguments={"path": file_path} + ) + + async def test_folder_share_permissions( + self, + alice_login_flow_mcp_client: ClientSession, + bob_login_flow_mcp_client: ClientSession, + ): + """Alice shares a folder with Bob; Bob can list and read its contents.""" + folder_path = "/alice_shared_folder" + file_in_folder = f"{folder_path}/document.txt" + file_content = "Document in Alice's shared folder" + + result = await alice_login_flow_mcp_client.call_tool( + "nc_webdav_create_directory", arguments={"path": folder_path} + ) + assert not result.isError + + result = await alice_login_flow_mcp_client.call_tool( + "nc_webdav_write_file", + arguments={"path": file_in_folder, "content": file_content}, + ) + assert not result.isError + + share_id = None + try: + result = await alice_login_flow_mcp_client.call_tool( + "nc_share_create", + arguments={ + "path": folder_path, + "share_with": "bob", + "share_type": 0, + "permissions": 1, + }, + ) + assert not result.isError + share_id = json.loads(result.content[0].text)["id"] + + # Bob lists the shared folder + result = await bob_login_flow_mcp_client.call_tool( + "nc_webdav_list_directory", arguments={"path": folder_path} + ) + assert not result.isError, f"Bob should see shared folder: {result.content}" + response_data = json.loads(result.content[0].text) + file_names = [f["name"] for f in response_data.get("files", [])] + assert "document.txt" in file_names + + # Bob reads the file + result = await bob_login_flow_mcp_client.call_tool( + "nc_webdav_read_file", arguments={"path": file_in_folder} + ) + assert not result.isError + assert file_content in json.loads(result.content[0].text)["content"] + + finally: + if share_id: + await alice_login_flow_mcp_client.call_tool( + "nc_share_delete", arguments={"share_id": share_id} + ) + await alice_login_flow_mcp_client.call_tool( + "nc_webdav_delete_resource", arguments={"path": folder_path} + ) + + async def test_user_isolation_files( + self, + alice_login_flow_mcp_client: ClientSession, + bob_login_flow_mcp_client: ClientSession, + ): + """Users can only see their own files when nothing is shared.""" + alice_file = "/alice_private_file.txt" + bob_file = "/bob_private_file.txt" + + # Each user creates their own file + result = await alice_login_flow_mcp_client.call_tool( + "nc_webdav_write_file", + arguments={"path": alice_file, "content": "Alice's private file"}, + ) + assert not result.isError + + result = await bob_login_flow_mcp_client.call_tool( + "nc_webdav_write_file", + arguments={"path": bob_file, "content": "Bob's private file"}, + ) + assert not result.isError + + try: + # Bob lists root — should NOT see Alice's file + result = await bob_login_flow_mcp_client.call_tool( + "nc_webdav_list_directory", arguments={"path": "/"} + ) + assert not result.isError + bob_visible = [ + f["name"] for f in json.loads(result.content[0].text).get("files", []) + ] + assert "alice_private_file.txt" not in bob_visible, ( + "Bob should not see Alice's private file" + ) + + # Alice lists root — should NOT see Bob's file + result = await alice_login_flow_mcp_client.call_tool( + "nc_webdav_list_directory", arguments={"path": "/"} + ) + assert not result.isError + alice_visible = [ + f["name"] for f in json.loads(result.content[0].text).get("files", []) + ] + assert "bob_private_file.txt" not in alice_visible, ( + "Alice should not see Bob's private file" + ) + + finally: + await alice_login_flow_mcp_client.call_tool( + "nc_webdav_delete_resource", arguments={"path": alice_file} + ) + await bob_login_flow_mcp_client.call_tool( + "nc_webdav_delete_resource", arguments={"path": bob_file} + ) + + +# --------------------------------------------------------------------------- +# Deck +# --------------------------------------------------------------------------- + + +class TestDeckPermissions: + """Test that MCP Deck tools respect board ACL permissions.""" + + async def _add_board_acl( + self, nc_client, board_id: int, user: str, permission_type: int = 0 + ) -> int: + """Add ACL entry. permission_type: 0=view, 1=edit, 2=manage.""" + acl = await nc_client.deck.add_acl_rule( + board_id=board_id, + type=0, + participant=user, + permission_edit=permission_type >= 1, + permission_share=permission_type >= 2, + permission_manage=permission_type >= 2, + ) + return acl.id + + async def test_deck_board_view_permissions( + self, + nc_client, + alice_login_flow_mcp_client: ClientSession, + bob_login_flow_mcp_client: ClientSession, + diana_login_flow_mcp_client: ClientSession, + ): + """Admin creates a board, adds Bob (view). Bob can see it; Diana cannot.""" + board = await nc_client.deck.create_board("Shared Board - View Test", "FF0000") + board_id = board.id + bob_acl_id = None + + try: + bob_acl_id = await self._add_board_acl(nc_client, board_id, "bob", 0) + + # Bob can see the board + result = await bob_login_flow_mcp_client.call_tool( + "deck_get_boards", arguments={} + ) + assert not result.isError + board_ids = [ + b["id"] for b in json.loads(result.content[0].text).get("boards", []) + ] + assert board_id in board_ids, "Bob should see shared board" + + # Diana cannot see the board + result = await diana_login_flow_mcp_client.call_tool( + "deck_get_boards", arguments={} + ) + assert not result.isError + board_ids = [ + b["id"] for b in json.loads(result.content[0].text).get("boards", []) + ] + assert board_id not in board_ids, "Diana should not see board without ACL" + + finally: + if bob_acl_id: + await nc_client.deck.delete_acl_rule(board_id, bob_acl_id) + await nc_client.deck.delete_board(board_id) + + async def test_deck_board_edit_permissions( + self, + nc_client, + charlie_login_flow_mcp_client: ClientSession, + bob_login_flow_mcp_client: ClientSession, + ): + """Charlie (edit) can create cards; Bob (view-only) cannot.""" + board = await nc_client.deck.create_board("Shared Board - Edit Test", "00FF00") + board_id = board.id + stack = await nc_client.deck.create_stack(board_id, "Test Stack", 1) + stack_id = stack.id + charlie_acl_id = None + bob_acl_id = None + + try: + charlie_acl_id = await self._add_board_acl( + nc_client, board_id, "charlie", 1 + ) + bob_acl_id = await self._add_board_acl(nc_client, board_id, "bob", 0) + + # Charlie creates a card + result = await charlie_login_flow_mcp_client.call_tool( + "deck_create_card", + arguments={ + "board_id": board_id, + "stack_id": stack_id, + "title": "Charlie's Card", + "description": "Created by Charlie with edit permission", + }, + ) + assert not result.isError, f"Charlie should create cards: {result.content}" + card_id = json.loads(result.content[0].text).get("id") + if card_id: + await nc_client.deck.delete_card(board_id, stack_id, card_id) + + # Bob cannot create a card + result = await bob_login_flow_mcp_client.call_tool( + "deck_create_card", + arguments={ + "board_id": board_id, + "stack_id": stack_id, + "title": "Bob's Card", + "description": "Bob trying to create a card", + }, + ) + assert result.isError, "Bob should be denied card creation (view-only)" + + finally: + for acl_id in (charlie_acl_id, bob_acl_id): + if acl_id: + await nc_client.deck.delete_acl_rule(board_id, acl_id) + await nc_client.deck.delete_board(board_id) + + async def test_deck_user_isolation( + self, + nc_client, + alice_login_flow_mcp_client: ClientSession, + bob_login_flow_mcp_client: ClientSession, + ): + """Users can only see their own boards when nothing is shared.""" + alice_board = await nc_client.deck.create_board( + "Alice's Private Board", "FF00FF" + ) + bob_board = await nc_client.deck.create_board("Bob's Private Board", "00FFFF") + + try: + # Alice should NOT see Bob's board + result = await alice_login_flow_mcp_client.call_tool( + "deck_get_boards", arguments={} + ) + assert not result.isError + board_ids = [ + b["id"] for b in json.loads(result.content[0].text).get("boards", []) + ] + assert bob_board.id not in board_ids, ( + "Alice should not see Bob's private board" + ) + + # Bob should NOT see Alice's board + result = await bob_login_flow_mcp_client.call_tool( + "deck_get_boards", arguments={} + ) + assert not result.isError + board_ids = [ + b["id"] for b in json.loads(result.content[0].text).get("boards", []) + ] + assert alice_board.id not in board_ids, ( + "Bob should not see Alice's private board" + ) + + finally: + await nc_client.deck.delete_board(alice_board.id) + await nc_client.deck.delete_board(bob_board.id) + + +# --------------------------------------------------------------------------- +# Notes +# --------------------------------------------------------------------------- + + +class TestNotesPermissions: + """Test that MCP Notes tools respect user isolation. + + Nextcloud Notes are inherently single-user (no sharing API). These tests + verify that notes created by one user are invisible to others. + """ + + async def test_user_isolation_notes( + self, + alice_login_flow_mcp_client: ClientSession, + bob_login_flow_mcp_client: ClientSession, + ): + """Notes created by Alice are invisible to Bob and vice versa.""" + # Alice creates a note + result = await alice_login_flow_mcp_client.call_tool( + "nc_notes_create_note", + arguments={ + "title": "Alice's Private Note", + "content": "This is Alice's private content.", + "category": "PermTest", + }, + ) + assert not result.isError + alice_note_id = json.loads(result.content[0].text)["id"] + + # Bob creates a note + result = await bob_login_flow_mcp_client.call_tool( + "nc_notes_create_note", + arguments={ + "title": "Bob's Private Note", + "content": "This is Bob's private content.", + "category": "PermTest", + }, + ) + assert not result.isError + bob_note_id = json.loads(result.content[0].text)["id"] + + try: + # Alice searches — should NOT see Bob's note + result = await alice_login_flow_mcp_client.call_tool( + "nc_notes_search_notes", arguments={"query": "PermTest"} + ) + assert not result.isError + alice_visible_ids = [ + n["id"] for n in json.loads(result.content[0].text).get("results", []) + ] + assert bob_note_id not in alice_visible_ids, ( + "Alice should not see Bob's private note" + ) + + # Bob searches — should NOT see Alice's note + result = await bob_login_flow_mcp_client.call_tool( + "nc_notes_search_notes", arguments={"query": "PermTest"} + ) + assert not result.isError + bob_visible_ids = [ + n["id"] for n in json.loads(result.content[0].text).get("results", []) + ] + assert alice_note_id not in bob_visible_ids, ( + "Bob should not see Alice's private note" + ) + + finally: + await alice_login_flow_mcp_client.call_tool( + "nc_notes_delete_note", arguments={"note_id": alice_note_id} + ) + await bob_login_flow_mcp_client.call_tool( + "nc_notes_delete_note", arguments={"note_id": bob_note_id} + ) + + +# --------------------------------------------------------------------------- +# Smoke: all multi-user clients initialised +# --------------------------------------------------------------------------- + + +class TestMultiUserSmoke: + """Quick check that all multi-user MCP clients are functional.""" + + async def test_all_clients_can_list_tools( + self, + alice_login_flow_mcp_client: ClientSession, + bob_login_flow_mcp_client: ClientSession, + charlie_login_flow_mcp_client: ClientSession, + diana_login_flow_mcp_client: ClientSession, + ): + for name, client in [ + ("alice", alice_login_flow_mcp_client), + ("bob", bob_login_flow_mcp_client), + ("charlie", charlie_login_flow_mcp_client), + ("diana", diana_login_flow_mcp_client), + ]: + tools = await client.list_tools() + assert len(tools.tools) > 0, f"{name} MCP client has no tools" + logger.info(f"{name} MCP client working ({len(tools.tools)} tools)") diff --git a/tests/server/oauth/test_scope_authorization.py b/tests/server/login_flow/test_scope_authorization.py similarity index 90% rename from tests/server/oauth/test_scope_authorization.py rename to tests/server/login_flow/test_scope_authorization.py index adccc70a..679c2206 100644 --- a/tests/server/oauth/test_scope_authorization.py +++ b/tests/server/login_flow/test_scope_authorization.py @@ -18,22 +18,22 @@ import pytest @pytest.mark.integration -@pytest.mark.oauth +@pytest.mark.login_flow async def test_prm_endpoint(): """Test that the Protected Resource Metadata endpoint returns correct data.""" # Test the PRM endpoint directly (RFC 9728 - path includes /mcp resource) async with httpx.AsyncClient() as client: response = await client.get( - "http://localhost:8001/.well-known/oauth-protected-resource/mcp" + "http://localhost:8004/.well-known/oauth-protected-resource/mcp" ) assert response.status_code == 200 prm_data = response.json() - assert prm_data["resource"] == "http://localhost:8001/mcp" + assert prm_data["resource"] == "http://localhost:8004/mcp" assert "notes:read" in prm_data["scopes_supported"] assert "notes:write" in prm_data["scopes_supported"] - assert "http://localhost:8001" in prm_data["authorization_servers"] + assert "http://localhost:8004" in prm_data["authorization_servers"] assert "header" in prm_data["bearer_methods_supported"] assert "RS256" in prm_data["resource_signing_alg_values_supported"] @@ -61,14 +61,14 @@ async def test_basicauth_shows_all_tools(nc_mcp_client): @pytest.mark.integration -@pytest.mark.oauth -async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only): +@pytest.mark.login_flow +async def test_read_only_token_filters_write_tools(nc_mcp_login_flow_client_read_only): """Test that a token with only read scopes filters out write tools.""" logger = logging.getLogger(__name__) # Connect with token that has only "notes:read" scope - result = await nc_mcp_oauth_client_read_only.list_tools() + result = await nc_mcp_login_flow_client_read_only.list_tools() assert result is not None assert len(result.tools) > 0 @@ -110,14 +110,14 @@ async def test_read_only_token_filters_write_tools(nc_mcp_oauth_client_read_only @pytest.mark.integration -@pytest.mark.oauth -async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_only): +@pytest.mark.login_flow +async def test_write_only_token_filters_read_tools(nc_mcp_login_flow_client_write_only): """Test that a token with only write scopes filters out read tools.""" logger = logging.getLogger(__name__) # Connect with token that has only "notes:write" scope - result = await nc_mcp_oauth_client_write_only.list_tools() + result = await nc_mcp_login_flow_client_write_only.list_tools() assert result is not None assert len(result.tools) > 0 @@ -159,14 +159,14 @@ async def test_write_only_token_filters_read_tools(nc_mcp_oauth_client_write_onl @pytest.mark.integration -@pytest.mark.oauth -async def test_full_access_token_shows_all_tools(nc_mcp_oauth_client_full_access): +@pytest.mark.login_flow +async def test_full_access_token_shows_all_tools(nc_mcp_login_flow_client_full_access): """Test that a token with both read and write scopes scopes can see all tools.""" logger = logging.getLogger(__name__) # Connect with token that has both "notes:read" and "notes:write" scopes - result = await nc_mcp_oauth_client_full_access.list_tools() + result = await nc_mcp_login_flow_client_full_access.list_tools() assert result is not None assert len(result.tools) > 0 @@ -393,9 +393,9 @@ async def test_scope_metadata_coverage(nc_mcp_client): @pytest.mark.integration -@pytest.mark.oauth +@pytest.mark.login_flow async def test_jwt_with_no_custom_scopes_returns_zero_tools( - nc_mcp_oauth_client_no_custom_scopes, + nc_mcp_login_flow_client_no_custom_scopes, ): """ Test that a JWT token with only OIDC default scopes shows only OAuth provisioning tools. @@ -410,36 +410,39 @@ async def test_jwt_with_no_custom_scopes_returns_zero_tools( logger = logging.getLogger(__name__) # Connect with JWT token that has NO custom scopes (only openid, profile, email) - result = await nc_mcp_oauth_client_no_custom_scopes.list_tools() + result = await nc_mcp_login_flow_client_no_custom_scopes.list_tools() assert result is not None tool_names = [tool.name for tool in result.tools] logger.info( - f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 4 OAuth tools)" + f"JWT token with no custom scopes sees {len(tool_names)} tools (should be 7 auth tools)" ) - # Only OAuth provisioning tools should be visible (they require 'openid' scope) - expected_oauth_tools = [ + # Only auth/provisioning tools should be visible (they require 'openid' scope) + expected_auth_tools = [ "provision_nextcloud_access", "revoke_nextcloud_access", "check_provisioning_status", "check_logged_in", # Login elicitation tool (ADR-006) + "nc_auth_provision_access", # Login Flow v2 (ADR-022) + "nc_auth_check_status", # Login Flow v2 + "nc_auth_update_scopes", # Login Flow v2 ] - assert set(tool_names) == set(expected_oauth_tools), ( - f"Expected only OAuth provisioning tools {expected_oauth_tools} " + assert set(tool_names) == set(expected_auth_tools), ( + f"Expected only auth/provisioning tools {expected_auth_tools} " f"but got {tool_names}" ) logger.info( - f"✅ JWT token with only openid scope correctly shows {len(tool_names)} OAuth provisioning tools, " + f"✅ JWT token with only openid scope correctly shows {len(tool_names)} auth tools, " "resource tools filtered out" ) @pytest.mark.integration -@pytest.mark.oauth -async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only): +@pytest.mark.login_flow +async def test_jwt_consent_scenarios_read_only(nc_mcp_login_flow_client_read_only): """ Test JWT with only nc:read scope consented. @@ -449,7 +452,7 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only): logger = logging.getLogger(__name__) - result = await nc_mcp_oauth_client_read_only.list_tools() + result = await nc_mcp_login_flow_client_read_only.list_tools() assert result is not None assert len(result.tools) > 0 @@ -476,8 +479,8 @@ async def test_jwt_consent_scenarios_read_only(nc_mcp_oauth_client_read_only): @pytest.mark.integration -@pytest.mark.oauth -async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only): +@pytest.mark.login_flow +async def test_jwt_consent_scenarios_write_only(nc_mcp_login_flow_client_write_only): """ Test JWT with only nc:write scope consented. @@ -487,7 +490,7 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only): logger = logging.getLogger(__name__) - result = await nc_mcp_oauth_client_write_only.list_tools() + result = await nc_mcp_login_flow_client_write_only.list_tools() assert result is not None assert len(result.tools) > 0 @@ -514,8 +517,8 @@ async def test_jwt_consent_scenarios_write_only(nc_mcp_oauth_client_write_only): @pytest.mark.integration -@pytest.mark.oauth -async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access): +@pytest.mark.login_flow +async def test_jwt_consent_scenarios_full_access(nc_mcp_login_flow_client_full_access): """ Test JWT with both nc:read and nc:write scopes consented. @@ -525,7 +528,7 @@ async def test_jwt_consent_scenarios_full_access(nc_mcp_oauth_client_full_access logger = logging.getLogger(__name__) - result = await nc_mcp_oauth_client_full_access.list_tools() + result = await nc_mcp_login_flow_client_full_access.list_tools() assert result is not None assert len(result.tools) > 0 diff --git a/tests/server/oauth/__init__.py b/tests/server/oauth/__init__.py deleted file mode 100644 index 4d3b9d88..00000000 --- a/tests/server/oauth/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""OAuth-specific integration tests.""" diff --git a/tests/server/oauth/test_astrolabe_multi_server_integration.py b/tests/server/oauth/test_astrolabe_multi_server_integration.py deleted file mode 100644 index f393d34d..00000000 --- a/tests/server/oauth/test_astrolabe_multi_server_integration.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Test Astrolabe integration with multiple MCP server deployments. - -Cross-system interface test: Tests the MCP server's integration with the -Astrolabe Nextcloud app, which is installed from the Nextcloud app store via -app-hooks/post-installation/20-install-astrolabe-app.sh. Astrolabe source -lives in a separate repository (https://github.com/cbcoutinho/astrolabe). - -This test suite verifies that the Astrolabe app can be dynamically configured -to connect to different MCP server deployments (mcp-oauth, mcp-keycloak, etc.). - -The configuration is managed dynamically during tests using the -configure_astrolabe_for_mcp_server fixture, which allows testing multiple -deployment scenarios without requiring static post-installation configuration. -""" - -import logging - -import pytest - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -class TestAstrolabeMultiServerIntegration: - """Test suite for Astrolabe integration with multiple MCP servers.""" - - @pytest.mark.parametrize( - "mcp_server_config", - [ - { - "name": "mcp-oauth", - "internal_url": "http://mcp-oauth:8001", - "public_url": "http://localhost:8001", - }, - { - "name": "mcp-keycloak", - "internal_url": "http://mcp-keycloak:8002", - "public_url": "http://localhost:8002", - }, - # Add more MCP server configurations as needed: - # { - # "name": "mcp-multi-user-basic", - # "internal_url": "http://mcp-multi-user-basic:8000", - # "public_url": "http://localhost:8003", - # }, - ], - ) - async def test_astrolabe_configuration_for_different_servers( - self, configure_astrolabe_for_mcp_server, mcp_server_config - ): - """Test that Astrolabe can be configured for different MCP servers. - - This test verifies that: - 1. The configure_astrolabe_for_mcp_server fixture successfully configures - the Astrolabe app for different MCP server endpoints - 2. OAuth client credentials are properly generated and stored - 3. The configuration can be dynamically changed between tests - """ - logger.info(f"Configuring Astrolabe for {mcp_server_config['name']}...") - - # Configure Astrolabe for the specific MCP server - credentials = await configure_astrolabe_for_mcp_server( - mcp_server_internal_url=mcp_server_config["internal_url"], - mcp_server_public_url=mcp_server_config["public_url"], - ) - - # Verify credentials were returned - assert "client_id" in credentials - assert "client_secret" in credentials - assert credentials["client_id"] == "nextcloudMcpServerUIPublicClient" - assert len(credentials["client_secret"]) > 0 - - logger.info( - f"✓ Astrolabe successfully configured for {mcp_server_config['name']}" - ) - logger.info(f" Internal URL: {mcp_server_config['internal_url']}") - logger.info(f" Public URL: {mcp_server_config['public_url']}") - logger.info(f" Client ID: {credentials['client_id']}") - logger.info(f" Client Secret: {credentials['client_secret'][:8]}...") - - async def test_astrolabe_reconfiguration(self, configure_astrolabe_for_mcp_server): - """Test that Astrolabe can be reconfigured multiple times in the same session. - - This verifies that the OAuth client can be recreated with different - settings without conflicts. - """ - # First configuration: mcp-oauth - logger.info("First configuration: mcp-oauth") - credentials1 = await configure_astrolabe_for_mcp_server( - mcp_server_internal_url="http://mcp-oauth:8001", - mcp_server_public_url="http://localhost:8001", - ) - - assert credentials1["client_id"] == "nextcloudMcpServerUIPublicClient" - - # Second configuration: mcp-keycloak (reconfiguration) - logger.info("Second configuration: mcp-keycloak (reconfiguration)") - credentials2 = await configure_astrolabe_for_mcp_server( - mcp_server_internal_url="http://mcp-keycloak:8002", - mcp_server_public_url="http://localhost:8002", - ) - - assert credentials2["client_id"] == "nextcloudMcpServerUIPublicClient" - - # Client secrets should be different (new client created) - assert credentials1["client_secret"] != credentials2["client_secret"] - - logger.info("✓ Astrolabe successfully reconfigured without conflicts") diff --git a/tests/server/oauth/test_elicitation_integration.py b/tests/server/oauth/test_elicitation_integration.py deleted file mode 100644 index dfec2a74..00000000 --- a/tests/server/oauth/test_elicitation_integration.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Integration tests for login elicitation with real MCP client callback support. - -These tests verify the complete end-to-end login elicitation flow (ADR-006) -using the python-sdk MCP client with actual elicitation callback implementation. - -Unlike test_login_elicitation.py which validates response formats, these tests -exercise the REAL elicitation protocol: -1. MCP client with elicitation callback connects to server -2. Tool triggers elicitation (ctx.elicit()) -3. Client callback receives elicitation request -4. Callback completes OAuth flow via Playwright automation -5. Client returns acceptance -6. Tool proceeds with authenticated operation - -This validates that: -- python-sdk MCP client can handle elicitation requests -- OAuth flow completion via callback works end-to-end -- Refresh tokens are properly stored after elicitation -- check_logged_in returns "yes" after successful OAuth -""" - -import logging - -import pytest - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -async def revoke_refresh_tokens(client): - """Helper to revoke all refresh tokens from MCP server. - - This forces check_logged_in to trigger elicitation by removing - any existing refresh tokens via the revoke_nextcloud_access tool. - """ - logger.info("Revoking refresh tokens via revoke_nextcloud_access tool...") - - result = await client.call_tool("revoke_nextcloud_access", arguments={}) - - logger.info(f"Revoke result: isError={result.isError}") - if not result.isError: - logger.info(f"✓ Revoke response: {result.content[0].text}") - else: - logger.warning(f"Revoke failed: {result.content}") - - -async def test_check_logged_in_with_real_elicitation_callback( - nc_mcp_oauth_client_with_elicitation, -): - """Test check_logged_in with actual elicitation callback that completes OAuth. - - This test validates the COMPLETE elicitation flow: - 1. Call check_logged_in tool (which triggers elicitation) - 2. Elicitation callback extracts OAuth URL - 3. Playwright automation completes OAuth flow - 4. Callback returns acceptance - 5. Tool returns "yes" (logged in) - 6. Refresh token is stored - - This is the ONLY test that exercises the real MCP elicitation protocol - with python-sdk's ClientSession elicitation callback support. - """ - client = nc_mcp_oauth_client_with_elicitation - - logger.info("=" * 80) - logger.info("TEST: Real elicitation callback with OAuth completion") - logger.info("=" * 80) - - # Revoke refresh tokens to force elicitation - await revoke_refresh_tokens(client) - - # Call check_logged_in - this should trigger elicitation - logger.info("Calling check_logged_in tool...") - result = await client.call_tool("check_logged_in", arguments={}) - - logger.info("Tool execution completed") - logger.info(f" Is error: {result.isError}") - if result.content: - response_text = result.content[0].text - logger.info(f" Response: {response_text}") - else: - logger.warning(" No content in response") - - # Validate tool execution succeeded - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None, "No content in tool response" - - response_text = result.content[0].text.lower() - - # Validate elicitation was triggered - elicitation_count = client.elicitation_triggered["count"] - logger.info(f"✓ Elicitation triggered {elicitation_count} time(s)") - assert elicitation_count >= 1, ( - "Elicitation callback should have been invoked at least once" - ) - - # Validate OAuth completed successfully and tool returned "yes" - assert "yes" in response_text, ( - f"Expected 'yes' after successful OAuth via elicitation, got: {response_text}" - ) - - logger.info("✅ Test passed: Real elicitation callback completed OAuth flow") - logger.info("=" * 80) - - -async def test_elicitation_callback_url_extraction( - nc_mcp_oauth_client_with_elicitation, -): - """Test that elicitation callback correctly extracts OAuth URL. - - This validates the URL extraction logic in the callback by examining - the elicitation message format returned by check_logged_in. - """ - client = nc_mcp_oauth_client_with_elicitation - - logger.info("Testing OAuth URL extraction from elicitation message...") - - # Revoke refresh tokens to force elicitation - await revoke_refresh_tokens(client) - - # Call check_logged_in to trigger elicitation - result = await client.call_tool("check_logged_in", arguments={}) - - # Should succeed (callback extracts URL and completes OAuth) - assert result.isError is False - assert "yes" in result.content[0].text.lower() - - # Elicitation should have been triggered - assert client.elicitation_triggered["count"] >= 1 - - logger.info("✓ URL extraction and OAuth completion successful") - - -async def test_elicitation_stores_refresh_token( - nc_mcp_oauth_client_with_elicitation, -): - """Test that refresh token is stored after elicitation completes. - - Validates that after successful OAuth via elicitation: - 1. check_logged_in returns "yes" - 2. check_provisioning_status shows is_provisioned=true - """ - client = nc_mcp_oauth_client_with_elicitation - - logger.info("Testing refresh token storage after elicitation...") - - # Revoke refresh tokens to force elicitation - await revoke_refresh_tokens(client) - - # Complete OAuth via elicitation - result = await client.call_tool("check_logged_in", arguments={}) - assert result.isError is False - assert "yes" in result.content[0].text.lower() - - # Verify refresh token was stored - logger.info("Checking provisioning status...") - status_result = await client.call_tool("check_provisioning_status", arguments={}) - - assert status_result.isError is False - status_text = status_result.content[0].text.lower() - - # Server should report provisioning complete - assert "is_provisioned" in status_text or "offline" in status_text, ( - f"Expected provisioning status, got: {status_text}" - ) - - logger.info("✓ Refresh token stored successfully after elicitation") - - -async def test_second_check_logged_in_does_not_elicit( - nc_mcp_oauth_client_with_elicitation, -): - """Test that second call to check_logged_in does not trigger elicitation. - - After successful OAuth via elicitation: - - First call: triggers elicitation, completes OAuth, returns "yes" - - Second call: no elicitation (already logged in), returns "yes" - """ - client = nc_mcp_oauth_client_with_elicitation - - logger.info("Testing that already-logged-in users don't get elicited...") - - # First call: triggers elicitation - result1 = await client.call_tool("check_logged_in", arguments={}) - assert result1.isError is False - assert "yes" in result1.content[0].text.lower() - - elicitation_count_after_first = client.elicitation_triggered["count"] - logger.info(f"After first call: {elicitation_count_after_first} elicitations") - - # Second call: should NOT trigger elicitation (already logged in) - result2 = await client.call_tool("check_logged_in", arguments={}) - assert result2.isError is False - assert "yes" in result2.content[0].text.lower() - - elicitation_count_after_second = client.elicitation_triggered["count"] - logger.info(f"After second call: {elicitation_count_after_second} elicitations") - - # Elicitation count should be the same (no new elicitation) - assert elicitation_count_after_second == elicitation_count_after_first, ( - "Second check_logged_in should not trigger elicitation " - "(user is already logged in)" - ) - - logger.info("✓ Already-logged-in users don't get redundant elicitations") diff --git a/tests/server/oauth/test_keycloak_dcr.py b/tests/server/oauth/test_keycloak_dcr.py deleted file mode 100644 index 59113bb1..00000000 --- a/tests/server/oauth/test_keycloak_dcr.py +++ /dev/null @@ -1,630 +0,0 @@ -""" -Tests for Dynamic Client Registration (DCR) with Keycloak external IdP. - -These tests verify that DCR (RFC 7591) and client deletion (RFC 7592) -work correctly with Keycloak as an external identity provider: - -1. Client registration via Keycloak's DCR endpoint -2. Token acquisition with dynamically registered client -3. MCP tool execution with Keycloak-issued tokens -4. Client deletion via RFC 7592 -5. Error handling for DCR operations - -This validates ADR-002 external IdP integration where clients are -dynamically provisioned rather than pre-configured. - -Architecture: - MCP Client → Keycloak DCR → Keycloak OAuth → MCP Server → Nextcloud APIs -""" - -import json -import logging -import os -import secrets -import time -from urllib.parse import quote - -import anyio -import httpx -import pytest - -from nextcloud_mcp_server.auth.client_registration import delete_client, register_client - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.keycloak] - - -# ============================================================================ -# Helper Functions -# ============================================================================ - - -async def handle_keycloak_login(page, username: str, password: str): - """ - Handle Keycloak login page. - - Keycloak uses: - - input#username for username field - - input#password for password field - - Form submission via JavaScript (more reliable than clicking button) - """ - logger.info(f"Handling Keycloak login for user: {username}") - logger.info(f"Current URL before login: {page.url}") - - # Wait for username field and fill it - await page.wait_for_selector("input#username", timeout=10000) - await page.fill("input#username", username) - - # Fill password field - await page.wait_for_selector("input#password", timeout=10000) - await page.fill("input#password", password) - - # Submit form using JavaScript (more reliable than clicking button) - logger.info("Submitting Keycloak login form...") - async with page.expect_navigation(timeout=60000): - await page.evaluate("document.querySelector('form').submit()") - - logger.info(f"✓ Keycloak login completed, redirected to: {page.url}") - - -async def handle_keycloak_consent(page, client_name: str): - """ - Handle Keycloak OAuth consent screen. - - Keycloak consent screen has: - - Checkbox inputs for each scope - - Button with name="accept" to grant consent - - Button with name="cancel" to deny consent - """ - logger.info(f"Handling Keycloak consent for client: {client_name}") - - try: - # Wait for consent screen (button with name="accept") - await page.wait_for_selector('button[name="accept"]', timeout=5000) - - # Click accept button and wait for navigation - async with page.expect_navigation(timeout=60000): - await page.click('button[name="accept"]') - - logger.info("✓ Keycloak consent granted") - except Exception as e: - # Consent screen might not appear if already consented - logger.debug(f"No consent screen or already authorized: {e}") - - -async def get_keycloak_oauth_token_with_client( - browser, - client_id: str, - client_secret: str, - token_endpoint: str, - authorization_endpoint: str, - callback_url: str, - auth_states: dict, - scopes: str = "openid profile email notes:read notes:write", - username: str = "admin", - password: str = "admin", -) -> str: - """ - Obtain OAuth access token from Keycloak using dynamically registered client. - - Args: - browser: Playwright browser instance - client_id: OAuth client ID (from DCR registration) - client_secret: OAuth client secret (from DCR registration) - token_endpoint: Keycloak token endpoint URL - authorization_endpoint: Keycloak authorization endpoint URL - callback_url: Callback URL for OAuth redirect - auth_states: Dict for storing auth codes (from callback server) - scopes: Space-separated list of scopes to request - username: Keycloak username (default: admin) - password: Keycloak password (default: admin) - - Returns: - Access token string - """ - # Generate unique state parameter - state = secrets.token_urlsafe(32) - - # URL-encode scopes - scopes_encoded = quote(scopes, safe="") - - # Construct authorization URL - 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}" - ) - - logger.info("Starting OAuth flow with Keycloak...") - logger.info(f"Authorization URL: {auth_url[:100]}...") - - # Browser automation - context = await browser.new_context(ignore_https_errors=True) - page = await context.new_page() - - try: - await page.goto(auth_url, wait_until="networkidle", timeout=60000) - current_url = page.url - logger.info(f"Current URL after navigation: {current_url[:100]}...") - - # Check if we're on Keycloak login page - if "/realms/" in current_url and "/protocol/openid-connect/auth" in current_url: - # We're on the Keycloak authorization page, might need to login - try: - # Check if login form is present - await page.wait_for_selector("input#username", timeout=3000) - await handle_keycloak_login(page, username, password) - except Exception as e: - logger.debug(f"No login form found, might already be logged in: {e}") - - # Handle consent screen if present - await handle_keycloak_consent(page, "DCR Test Client") - - # Wait for callback - logger.info("Waiting for OAuth callback...") - timeout_seconds = 30 - start_time = time.time() - while state not in auth_states: - if time.time() - start_time > timeout_seconds: - raise TimeoutError( - f"Timeout waiting for OAuth callback (state={state[:16]}...)" - ) - await anyio.sleep(0.5) - - auth_code = auth_states[state] - logger.info(f"Got auth code: {auth_code[:20]}...") - - finally: - await context.close() - - # Exchange code for token - logger.info("Exchanging authorization code for access token...") - async with httpx.AsyncClient(timeout=30.0) as http_client: - token_response = await http_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("Successfully obtained access token from Keycloak") - return access_token - - -# ============================================================================ -# DCR Registration Tests -# ============================================================================ - - -@pytest.mark.integration -async def test_keycloak_dcr_registration(anyio_backend, oauth_callback_server): - """ - Test that DCR registration works with Keycloak. - - Verifies: - - Keycloak's DCR endpoint is discoverable via OIDC discovery - - Client registration succeeds (RFC 7591) - - Registration response includes client_id, client_secret - - Registration response includes RFC 7592 fields (registration_access_token, registration_client_uri) - """ - keycloak_discovery_url = os.getenv( - "OIDC_DISCOVERY_URL", - "http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration", - ) - - auth_states, callback_url = oauth_callback_server - - # OIDC Discovery - logger.info("Discovering Keycloak OIDC endpoints...") - async with httpx.AsyncClient(timeout=30.0) as client: - discovery_response = await client.get(keycloak_discovery_url) - discovery_response.raise_for_status() - oidc_config = discovery_response.json() - - registration_endpoint = oidc_config.get("registration_endpoint") - - if not registration_endpoint: - pytest.skip( - "Keycloak DCR not enabled (no registration_endpoint in discovery)" - ) - - logger.info(f"✓ Found registration endpoint: {registration_endpoint}") - - # Register client - logger.info("Registering OAuth client via Keycloak DCR...") - client_info = await register_client( - nextcloud_url=keycloak_discovery_url.replace( - "/.well-known/openid-configuration", "" - ), - registration_endpoint=registration_endpoint, - client_name="Keycloak DCR Test Client", - redirect_uris=[callback_url], - scopes="openid profile email notes:read notes:write", - token_type=None, # Keycloak doesn't support token_type field - ) - - assert client_info.client_id, "Registration should return client_id" - assert client_info.client_secret, "Registration should return client_secret" - logger.info(f"✓ Client registered: {client_info.client_id[:16]}...") - - # Verify RFC 7592 fields are present - assert client_info.registration_access_token, ( - "Keycloak should return registration_access_token for RFC 7592 deletion" - ) - assert client_info.registration_client_uri, ( - "Keycloak should return registration_client_uri for RFC 7592 operations" - ) - logger.info("✓ RFC 7592 fields present in registration response") - - # Cleanup: Delete the client - logger.info("Cleaning up: deleting test client...") - keycloak_host = keycloak_discovery_url.replace( - "/.well-known/openid-configuration", "" - ) - success = await delete_client( - nextcloud_url=keycloak_host, - client_id=client_info.client_id, - registration_access_token=client_info.registration_access_token, - client_secret=client_info.client_secret, - registration_client_uri=client_info.registration_client_uri, - ) - - assert success, "Cleanup deletion should succeed" - logger.info("✓ Test client deleted successfully") - - -# ============================================================================ -# Complete DCR Lifecycle Tests -# ============================================================================ - - -@pytest.mark.integration -async def test_keycloak_dcr_complete_lifecycle( - anyio_backend, - browser, - oauth_callback_server, - nc_mcp_keycloak_client, -): - """ - Test the complete DCR lifecycle with Keycloak: - 1. Register client via DCR (RFC 7591) - 2. Obtain OAuth token with registered client - 3. Use token to access MCP tools - 4. Delete client via RFC 7592 - - This is the end-to-end test that validates DCR works for external IdPs. - """ - keycloak_discovery_url = os.getenv( - "OIDC_DISCOVERY_URL", - "http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration", - ) - - auth_states, callback_url = oauth_callback_server - - # Step 1: OIDC Discovery - logger.info("Step 1: Discovering Keycloak OIDC endpoints...") - async with httpx.AsyncClient(timeout=30.0) as client: - discovery_response = await client.get(keycloak_discovery_url) - discovery_response.raise_for_status() - oidc_config = discovery_response.json() - - registration_endpoint = oidc_config.get("registration_endpoint") - token_endpoint = oidc_config.get("token_endpoint") - authorization_endpoint = oidc_config.get("authorization_endpoint") - - if not registration_endpoint: - pytest.skip( - "Keycloak DCR not enabled (no registration_endpoint in discovery)" - ) - - logger.info(f"✓ Registration endpoint: {registration_endpoint}") - logger.info(f"✓ Token endpoint: {token_endpoint}") - logger.info(f"✓ Authorization endpoint: {authorization_endpoint}") - - # Step 2: Register client - logger.info("Step 2: Registering OAuth client via Keycloak DCR...") - keycloak_host = keycloak_discovery_url.replace( - "/.well-known/openid-configuration", "" - ) - client_info = await register_client( - nextcloud_url=keycloak_host, - registration_endpoint=registration_endpoint, - client_name="Keycloak DCR Lifecycle Test", - redirect_uris=[callback_url], - scopes="openid profile email notes:read notes:write calendar:read", - token_type=None, # Keycloak doesn't support token_type field - ) - - logger.info(f"✓ Client registered: {client_info.client_id[:16]}...") - logger.info(f" Client secret: {client_info.client_secret[:16]}...") - logger.info( - f" Registration token: {client_info.registration_access_token[:16]}..." - ) - - # Step 3: Obtain OAuth token - logger.info("Step 3: Obtaining OAuth token with registered client...") - access_token = await get_keycloak_oauth_token_with_client( - browser=browser, - client_id=client_info.client_id, - client_secret=client_info.client_secret, - token_endpoint=token_endpoint, - authorization_endpoint=authorization_endpoint, - callback_url=callback_url, - auth_states=auth_states, - scopes="openid profile email notes:read notes:write calendar:read", - username="admin", - password="admin", - ) - - assert access_token, "Failed to obtain access token" - logger.info(f"✓ Access token obtained: {access_token[:30]}...") - - # Step 4: Verify token works with MCP server (optional - requires MCP client setup) - # This step is optional since we already have nc_mcp_keycloak_client fixture - # that uses the pre-configured client. For a full test, you'd create a new - # MCP client with the dynamically registered client, but that's complex. - logger.info("✓ Token can be used with MCP server (verified in other tests)") - - # Step 5: Delete client - logger.info("Step 4: Deleting OAuth client via RFC 7592...") - success = await delete_client( - nextcloud_url=keycloak_host, - client_id=client_info.client_id, - registration_access_token=client_info.registration_access_token, - client_secret=client_info.client_secret, - registration_client_uri=client_info.registration_client_uri, - ) - - assert success, "Client deletion should succeed" - logger.info(f"✓ Client deleted successfully: {client_info.client_id[:16]}...") - - # Step 6: Verify deleted client cannot be used - logger.info("Step 5: Verifying deleted client cannot obtain new tokens...") - async with httpx.AsyncClient(timeout=30.0) as http_client: - try: - # Try to use client credentials grant (should fail) - token_response = await http_client.post( - token_endpoint, - data={ - "grant_type": "client_credentials", - "client_id": client_info.client_id, - "client_secret": client_info.client_secret, - }, - ) - - # Accept 400 or 401 as valid rejection - if token_response.status_code in [400, 401]: - logger.info( - f"✓ Deleted client correctly rejected ({token_response.status_code})" - ) - else: - pytest.fail( - f"Deleted client should not be able to obtain tokens, " - f"but got status {token_response.status_code}" - ) - - except httpx.HTTPStatusError as e: - if e.response.status_code in [400, 401]: - logger.info("✓ Deleted client correctly rejected") - else: - raise - - logger.info("✅ Complete Keycloak DCR lifecycle test passed!") - - -# ============================================================================ -# Error Handling Tests -# ============================================================================ - - -@pytest.mark.integration -async def test_keycloak_dcr_delete_with_wrong_token( - anyio_backend, - oauth_callback_server, -): - """ - Test that deletion fails with wrong registration_access_token. - - Verifies: - 1. Client registration succeeds - 2. Deletion with wrong registration_access_token fails - 3. Deletion with correct registration_access_token succeeds - """ - keycloak_discovery_url = os.getenv( - "OIDC_DISCOVERY_URL", - "http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration", - ) - - auth_states, callback_url = oauth_callback_server - - # OIDC Discovery - async with httpx.AsyncClient(timeout=30.0) as client: - discovery_response = await client.get(keycloak_discovery_url) - discovery_response.raise_for_status() - oidc_config = discovery_response.json() - - registration_endpoint = oidc_config.get("registration_endpoint") - - if not registration_endpoint: - pytest.skip("Keycloak DCR not enabled") - - # Register client - logger.info("Registering OAuth client for wrong token test...") - keycloak_host = keycloak_discovery_url.replace( - "/.well-known/openid-configuration", "" - ) - client_info = await register_client( - nextcloud_url=keycloak_host, - registration_endpoint=registration_endpoint, - client_name="Keycloak DCR Wrong Token Test", - redirect_uris=[callback_url], - scopes="openid profile email", - token_type=None, # Keycloak doesn't support token_type field - ) - - logger.info(f"Client registered: {client_info.client_id[:16]}...") - - # Try to delete with wrong registration_access_token - logger.info("Attempting deletion with wrong registration_access_token...") - wrong_token = "wrong_token_" + secrets.token_urlsafe(32) - - success = await delete_client( - nextcloud_url=keycloak_host, - client_id=client_info.client_id, - registration_access_token=wrong_token, - client_secret=client_info.client_secret, - registration_client_uri=client_info.registration_client_uri, - ) - - assert not success, "Deletion with wrong token should fail" - logger.info("✓ Deletion correctly failed with wrong token") - - # Clean up: Delete with correct token - logger.info("Cleaning up: deleting with correct registration_access_token...") - success = await delete_client( - nextcloud_url=keycloak_host, - client_id=client_info.client_id, - registration_access_token=client_info.registration_access_token, - client_secret=client_info.client_secret, - registration_client_uri=client_info.registration_client_uri, - ) - - assert success, "Deletion with correct token should succeed" - logger.info("✓ Cleanup successful") - - -@pytest.mark.integration -async def test_keycloak_dcr_deletion_is_idempotent( - anyio_backend, - oauth_callback_server, -): - """ - Test that deleting the same client twice fails gracefully on second attempt. - - Verifies: - 1. First deletion succeeds - 2. Second deletion fails gracefully (no exception, returns False) - """ - keycloak_discovery_url = os.getenv( - "OIDC_DISCOVERY_URL", - "http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration", - ) - - auth_states, callback_url = oauth_callback_server - - # OIDC Discovery - async with httpx.AsyncClient(timeout=30.0) as client: - discovery_response = await client.get(keycloak_discovery_url) - discovery_response.raise_for_status() - oidc_config = discovery_response.json() - - registration_endpoint = oidc_config.get("registration_endpoint") - - if not registration_endpoint: - pytest.skip("Keycloak DCR not enabled") - - # Register client - logger.info("Registering OAuth client for idempotency test...") - keycloak_host = keycloak_discovery_url.replace( - "/.well-known/openid-configuration", "" - ) - client_info = await register_client( - nextcloud_url=keycloak_host, - registration_endpoint=registration_endpoint, - client_name="Keycloak DCR Idempotency Test", - redirect_uris=[callback_url], - scopes="openid profile email", - token_type=None, # Keycloak doesn't support token_type field - ) - - logger.info(f"Client registered: {client_info.client_id[:16]}...") - - # First deletion - logger.info("First deletion attempt...") - success = await delete_client( - nextcloud_url=keycloak_host, - client_id=client_info.client_id, - registration_access_token=client_info.registration_access_token, - client_secret=client_info.client_secret, - registration_client_uri=client_info.registration_client_uri, - ) - - assert success, "First deletion should succeed" - logger.info("✓ First deletion succeeded") - - # Second deletion (should fail gracefully) - logger.info("Second deletion attempt (should fail)...") - success = await delete_client( - nextcloud_url=keycloak_host, - client_id=client_info.client_id, - registration_access_token=client_info.registration_access_token, - client_secret=client_info.client_secret, - registration_client_uri=client_info.registration_client_uri, - ) - - assert not success, "Second deletion should fail (client already deleted)" - logger.info("✓ Second deletion correctly failed (client already deleted)") - - -# ============================================================================ -# Documentation Tests -# ============================================================================ - - -async def test_keycloak_dcr_architecture(): - """ - Document the Keycloak DCR architecture for reference. - - This test captures the design and flow for DCR with external IdPs. - """ - architecture = { - "flow": [ - "1. MCP client discovers Keycloak OIDC endpoints via .well-known/openid-configuration", - "2. MCP client registers via Keycloak DCR endpoint (RFC 7591)", - "3. Keycloak returns client_id, client_secret, registration_access_token", - "4. MCP client uses credentials to obtain OAuth token", - "5. MCP client uses token to authenticate with MCP server", - "6. MCP server validates token via Nextcloud user_oidc app", - "7. When done, MCP client deletes registration via RFC 7592", - ], - "components": { - "keycloak_dcr": "Dynamic Client Registration endpoint (RFC 7591)", - "keycloak_oauth": "OAuth/OIDC provider for authentication", - "mcp_server": "MCP server with external IdP config", - "nextcloud": "API server with user_oidc app for token validation", - }, - "advantages": [ - "No manual client pre-configuration required", - "Clients can self-register and self-cleanup", - "Standards-based (RFC 7591, RFC 7592)", - "Works with any compliant OIDC provider", - "Supports dynamic callback URL registration", - ], - "security": [ - "Registration tokens protect client management operations", - "Clients can only delete themselves (not others)", - "Token validation ensures only authorized access", - "Automatic cleanup prevents client sprawl", - ], - } - - logger.info("Keycloak DCR Architecture:") - - logger.info(json.dumps(architecture, indent=2)) - - assert True diff --git a/tests/server/oauth/test_keycloak_external_idp.py b/tests/server/oauth/test_keycloak_external_idp.py deleted file mode 100644 index 99da439a..00000000 --- a/tests/server/oauth/test_keycloak_external_idp.py +++ /dev/null @@ -1,566 +0,0 @@ -"""Keycloak External IdP Integration Tests. - -Tests verify ADR-002 external identity provider integration where: -1. Keycloak acts as external OAuth/OIDC provider -2. MCP server validates tokens via Nextcloud user_oidc app -3. Nextcloud auto-provisions users from Keycloak token claims -4. MCP tools execute successfully with Keycloak tokens - -Architecture: - MCP Client → Keycloak (OAuth) → MCP Server → Nextcloud user_oidc (validates) → APIs - -Tests: -1. Keycloak OAuth token acquisition via Playwright -2. MCP client connection to mcp-keycloak service (port 8002) -3. Token validation through Nextcloud user_oidc app -4. MCP tool execution with Keycloak tokens -5. User auto-provisioning from Keycloak claims -6. Scope-based tool filtering with Keycloak JWT tokens -""" - -import json -import logging - -import pytest - -from nextcloud_mcp_server.client import NextcloudClient - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.keycloak] - - -# ============================================================================ -# OAuth Token Acquisition Tests -# ============================================================================ - - -async def test_keycloak_oauth_token_acquisition(keycloak_oauth_token): - """Test that Playwright can obtain OAuth token from Keycloak. - - Verifies: - - Playwright automation handles Keycloak login page (input#username, input#password) - - Keycloak consent screen is handled correctly - - Authorization code is exchanged for access token - - Token is returned successfully - - This is a foundational test - if this fails, all other Keycloak tests will fail. - """ - assert keycloak_oauth_token is not None - assert isinstance(keycloak_oauth_token, str) - assert len(keycloak_oauth_token) > 100 # Tokens should be substantial length - - logger.info( - f"✓ Keycloak OAuth token acquired (length: {len(keycloak_oauth_token)})" - ) - logger.info(f" Token prefix: {keycloak_oauth_token[:50]}...") - - -async def test_keycloak_oauth_client_credentials_discovery( - keycloak_oauth_client_credentials, -): - """Test Keycloak OIDC discovery and credential loading. - - Verifies: - - OIDC discovery endpoint is accessible - - Token and authorization endpoints are discovered - - Static client credentials are loaded from environment - - Callback server is initialized - """ - ( - client_id, - client_secret, - callback_url, - token_endpoint, - authorization_endpoint, - ) = keycloak_oauth_client_credentials - - assert client_id == "nextcloud-mcp-server" - assert client_secret == "mcp-secret-change-in-production" - assert callback_url.startswith("http://") - # With --hostname-backchannel-dynamic, external clients see localhost:8888 - assert "localhost:8888" in token_endpoint or "keycloak" in token_endpoint - assert ( - "localhost:8888" in authorization_endpoint - or "keycloak" in authorization_endpoint - ) - assert "/realms/nextcloud-mcp/" in token_endpoint - - logger.info("✓ Keycloak OIDC discovery successful") - logger.info(f" Client ID: {client_id}") - logger.info(f" Token endpoint: {token_endpoint}") - logger.info(f" Authorization endpoint: {authorization_endpoint}") - - -# ============================================================================ -# MCP Server Connectivity Tests -# ============================================================================ - - -async def test_mcp_client_connects_to_keycloak_server(nc_mcp_keycloak_client): - """Test MCP client can connect to mcp-keycloak service (port 8002). - - Verifies: - - MCP client session is established - - Server responds to list_tools request - - Tools are available for use - """ - result = await nc_mcp_keycloak_client.list_tools() - - assert result is not None - assert len(result.tools) > 0 - - logger.info( - f"✓ MCP client connected to Keycloak server with {len(result.tools)} tools" - ) - - -async def test_external_idp_server_initialization(nc_mcp_keycloak_client): - """Test that MCP server correctly initializes with external IdP configuration. - - Verifies: - - Server auto-detects external IdP mode (issuer != Nextcloud host) - - Server reports correct provider type - - All expected tools are registered - - The server should log messages like: - - "✓ Detected external IdP mode (issuer: http://keycloak:8080/realms/nextcloud-mcp != Nextcloud: http://app:80)" - """ - result = await nc_mcp_keycloak_client.list_tools() - - # Verify we have a full set of tools (not filtered to specific apps) - tool_names = [tool.name for tool in result.tools] - - # Should have tools from multiple apps - has_notes = any("notes" in name for name in tool_names) - has_calendar = any("calendar" in name for name in tool_names) - has_files = any("webdav" in name for name in tool_names) - - assert has_notes, "Missing Notes tools" - assert has_calendar, "Missing Calendar tools" - assert has_files, "Missing WebDAV/Files tools" - - logger.info("✓ MCP server initialized with external IdP mode") - logger.info(f" Tools from multiple apps detected: {len(result.tools)} total") - - -# ============================================================================ -# Token Validation Tests -# ============================================================================ - - -async def test_external_idp_token_validation(nc_mcp_keycloak_client): - """Test that Keycloak tokens are validated via Nextcloud user_oidc app. - - Token flow: - 1. Keycloak issues OAuth token - 2. MCP client sends token to MCP server - 3. MCP server passes token to Nextcloud user_oidc app - 4. user_oidc validates token with Keycloak (JWKS or introspection) - 5. Nextcloud returns user info to MCP server - 6. MCP server uses token to access Nextcloud APIs - - This test verifies the entire flow works. - """ - # Execute a read operation (requires token validation) - result = await nc_mcp_keycloak_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) - - # Successful response means token was validated and user was authenticated - assert "results" in response_data - assert isinstance(response_data["results"], list) - - logger.info("✓ Keycloak token validated successfully via Nextcloud user_oidc app") - logger.info(f" Tool execution returned {len(response_data['results'])} results") - - -# ============================================================================ -# Tool Execution Tests -# ============================================================================ - - -async def test_tools_work_with_keycloak_token(nc_mcp_keycloak_client): - """Test that MCP tools execute successfully with Keycloak OAuth tokens. - - Verifies end-to-end functionality: - - Read operations work (nc_notes_search_notes) - - Write operations work (nc_notes_create_note) - - Different apps work (Notes, Calendar, Files) - """ - # Test 1: Read operation (Notes) - search_result = await nc_mcp_keycloak_client.call_tool( - "nc_notes_search_notes", arguments={"query": ""} - ) - assert search_result.isError is False - logger.info("✓ Read operation successful (nc_notes_search_notes)") - - # Test 2: Write operation (Notes) - create_result = await nc_mcp_keycloak_client.call_tool( - "nc_notes_create_note", - arguments={ - "title": "Keycloak Test Note", - "content": "Created via external IdP token", - "category": "Test", - }, - ) - assert create_result.isError is False - create_data = json.loads(create_result.content[0].text) - note_id = create_data["id"] - logger.info(f"✓ Write operation successful (created note {note_id})") - - # Test 3: Different app (Calendar) - calendar_result = await nc_mcp_keycloak_client.call_tool( - "nc_calendar_list_calendars", arguments={} - ) - assert calendar_result.isError is False - logger.info("✓ Calendar tool execution successful") - - # Test 4: File operations (WebDAV) - files_result = await nc_mcp_keycloak_client.call_tool( - "nc_webdav_list_directory", arguments={"path": "/"} - ) - assert files_result.isError is False - logger.info("✓ WebDAV tool execution successful") - - # Cleanup: Delete test note - await nc_mcp_keycloak_client.call_tool( - "nc_notes_delete_note", arguments={"note_id": note_id} - ) - logger.info(f"✓ Cleanup: Deleted test note {note_id}") - - -async def test_keycloak_token_persistence(nc_mcp_keycloak_client): - """Test that Keycloak token works across multiple operations. - - Verifies: - - Token is properly cached by MCP server - - Token can be reused for multiple API calls - - No re-authentication is required between calls - """ - # Execute multiple operations with same session - operations = [ - ("nc_notes_search_notes", {"query": ""}), - ("nc_calendar_list_calendars", {}), - ("nc_webdav_list_directory", {"path": "/"}), - ] - - for tool_name, arguments in operations: - result = await nc_mcp_keycloak_client.call_tool(tool_name, arguments=arguments) - assert result.isError is False, f"Failed to execute {tool_name}" - logger.info(f"✓ {tool_name} executed successfully") - - logger.info("✓ Keycloak token persistence verified (3 operations with same token)") - - -# ============================================================================ -# User Provisioning Tests -# ============================================================================ - - -async def test_user_auto_provisioning(nc_client: NextcloudClient, keycloak_oauth_token): - """Test that Nextcloud validates users from Keycloak token claims. - - When a user authenticates with Keycloak, Nextcloud's user_oidc app - validates the token and authenticates the user. In this test setup, - the Keycloak 'admin' user maps to the Nextcloud 'admin' user. - - Verification: - 1. User exists in Nextcloud after OAuth authentication - 2. User can access Nextcloud APIs with Keycloak token - 3. Bearer token validation is working correctly - - Note: With bearer-provisioning enabled, user_oidc would auto-provision - new users from token claims, but since we use 'admin' in both Keycloak - and Nextcloud, they map to the same user. - """ - # Get list of users (returns List[str] of user IDs) - user_ids = await nc_client.users.search_users() - - logger.info(f"Found {len(user_ids)} users in Nextcloud") - logger.info(f"Users: {user_ids}") - - # Verify the admin user exists (used for authentication) - assert "admin" in user_ids, "Expected 'admin' user to exist in Nextcloud" - - # Verify we can access APIs with the Keycloak token (already tested in previous tests) - # The fact that we got this far means bearer token validation is working - - logger.info("✓ User authentication and bearer token validation verified") - logger.info(f" Total users: {len(user_ids)}") - logger.info(" Bearer provisioning is enabled and working correctly") - - -# ============================================================================ -# Scope-Based Authorization Tests -# ============================================================================ - - -async def test_scope_filtering_with_keycloak(nc_mcp_keycloak_client): - """Test that tool filtering works correctly with Keycloak JWT scopes. - - Keycloak tokens should include scopes in JWT payload (if JWT format). - The MCP server should filter tools based on these scopes. - - Expected scopes (from docker-compose.yml): - - openid profile email offline_access - - notes:read notes:write - - calendar:read calendar:write - - contacts:read contacts:write - - etc. - - Tools should be filtered accordingly. - """ - result = await nc_mcp_keycloak_client.list_tools() - tool_names = [tool.name for tool in result.tools] - - # With full scopes, all app tools should be available - expected_tools = [ - "nc_notes_get_note", # notes:read - "nc_notes_create_note", # notes:write - "nc_calendar_list_calendars", # calendar:read - "nc_calendar_create_event", # calendar:write - "nc_webdav_list_directory", # files:read - "nc_webdav_write_file", # files:write - ] - - for tool_name in expected_tools: - assert tool_name in tool_names, f"Expected tool {tool_name} not found" - - logger.info("✓ Scope-based tool filtering working with Keycloak tokens") - logger.info(f" Available tools: {len(tool_names)}") - - -# ============================================================================ -# Error Handling Tests -# ============================================================================ - - -async def test_keycloak_error_handling(nc_mcp_keycloak_client): - """Test error handling with Keycloak tokens. - - Verifies: - - Invalid operations return proper errors - - Token validation errors are handled correctly - - API errors propagate correctly through the chain - """ - # Try to get a non-existent note - result = await nc_mcp_keycloak_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( - "✓ Keycloak OAuth server correctly handles errors for invalid operations" - ) - - -# ============================================================================ -# Documentation Tests -# ============================================================================ - - -async def test_external_idp_architecture(): - """Document the external IdP architecture (ADR-002). - - This test captures the design and flow for reference. - """ - architecture = { - "flow": [ - "1. User authenticates with Keycloak (external IdP)", - "2. Keycloak issues OAuth access token with scopes", - "3. MCP client uses token to authenticate with MCP server", - "4. MCP server receives token and passes to Nextcloud", - "5. Nextcloud user_oidc app validates token with Keycloak", - "6. Nextcloud auto-provisions user from token claims (if first login)", - "7. Nextcloud returns validated user info to MCP server", - "8. MCP server executes tool using validated token", - ], - "components": { - "keycloak": "External OAuth/OIDC provider (port 8888)", - "mcp_server": "MCP server with external IdP config (port 8002)", - "nextcloud": "API server with user_oidc app (port 8080)", - "user_oidc": "Nextcloud app that validates external IdP tokens", - }, - "configuration": { - "keycloak_realm": "nextcloud-mcp", - "keycloak_client": "nextcloud-mcp-server", - "nextcloud_provider": "keycloak (via user_oidc app)", - "token_validation": "Keycloak JWKS or introspection endpoint", - }, - "advantages": [ - "No admin credentials needed in MCP server", - "Centralized identity management", - "Standards-based (RFC 6749, RFC 7662, RFC 9068)", - "Supports enterprise IdPs (Keycloak, Auth0, Okta, etc.)", - "User auto-provisioning from IdP claims", - ], - } - - logger.info("External IdP Architecture (ADR-002):") - logger.info(json.dumps(architecture, indent=2)) - - assert True - - -# ============================================================================ -# Scope-Based Authorization Tests (JWT Token Filtering) -# ============================================================================ - - -async def test_keycloak_read_only_token_filters_write_tools( - nc_mcp_keycloak_client_read_only, -): - """Test that a Keycloak token with only read scopes filters out write tools.""" - # Connect with token that has only read scopes - result = await nc_mcp_keycloak_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"Keycloak read-only token sees {len(tool_names)} tools") - - # Verify read tools are present - expected_read_tools = [ - "nc_notes_get_note", # notes:read - "nc_notes_search_notes", # notes:read - "nc_calendar_list_calendars", # calendar:read - "nc_calendar_get_event", # calendar:read - ] - - 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 (filtered out) - write_tools_should_be_filtered = [ - "nc_notes_create_note", # notes:write - "nc_notes_update_note", # notes:write - "nc_notes_delete_note", # notes:write - "nc_calendar_create_event", # calendar:write - "nc_calendar_update_event", # calendar:write - "nc_calendar_delete_event", # calendar:write - ] - - 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"✅ Keycloak read-only token properly filters tools: {len(tool_names)} read tools visible, " - f"write tools hidden" - ) - - -async def test_keycloak_write_only_token_filters_read_tools( - nc_mcp_keycloak_client_write_only, -): - """Test that a Keycloak token with only write scopes filters out read tools.""" - # Connect with token that has only write scopes - result = await nc_mcp_keycloak_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"Keycloak write-only token sees {len(tool_names)} tools") - - # Verify write tools are present - expected_write_tools = [ - "nc_notes_create_note", # notes:write - "nc_notes_update_note", # notes:write - "nc_notes_delete_note", # notes:write - "nc_calendar_create_event", # calendar:write - "nc_calendar_update_event", # calendar:write - "nc_calendar_delete_event", # calendar:write - ] - - for tool in expected_write_tools: - assert tool in tool_names, f"Expected write tool {tool} not found in tool list" - - # Verify read-only tools are NOT present (write-only scope) - read_tools_should_be_filtered = [ - "nc_notes_get_note", # notes:read - "nc_notes_search_notes", # notes:read - "nc_calendar_list_calendars", # calendar:read - "nc_calendar_get_event", # calendar:read - ] - - 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"✅ Keycloak write-only token properly filters tools: {len(tool_names)} write tools visible, " - f"read tools hidden" - ) - - -async def test_keycloak_full_access_token_shows_all_tools(nc_mcp_keycloak_client): - """Test that a Keycloak token with both read and write scopes sees all tools.""" - # Connect with token that has both read and write scopes - result = await nc_mcp_keycloak_client.list_tools() - assert result is not None - assert len(result.tools) > 0 - - tool_names = [tool.name for tool in result.tools] - logger.info(f"Keycloak full access token sees {len(tool_names)} tools") - - # Verify both read and write tools are present - expected_read_tools = [ - "nc_notes_get_note", # notes:read - "nc_notes_search_notes", # notes:read - "nc_calendar_list_calendars", # calendar:read - ] - - expected_write_tools = [ - "nc_notes_create_note", # notes:write - "nc_calendar_create_event", # calendar:write - ] - - 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"✅ Keycloak full access token sees all tools: {len(tool_names)} total (read + write)" - ) - - -async def test_keycloak_no_custom_scopes_returns_zero_tools( - nc_mcp_keycloak_client_no_custom_scopes, -): - """ - Test that a Keycloak JWT token with only OIDC default scopes returns 0 tools. - - This tests the security behavior when a user declines to grant custom scopes during consent. - Expected: JWT token has scopes=['openid', 'profile', 'email'] but no custom scopes. - All tools require at least one custom scope, so they should all be filtered out. - """ - # Connect with JWT token that has NO custom scopes (only openid, profile, email) - result = await nc_mcp_keycloak_client_no_custom_scopes.list_tools() - assert result is not None - - tool_names = [tool.name for tool in result.tools] - logger.info( - f"Keycloak JWT token with no custom scopes sees {len(tool_names)} tools (should be 0)" - ) - - # All tools require custom scopes, so should be filtered out - assert len(tool_names) == 0, ( - f"Expected 0 tools but got {len(tool_names)}: {tool_names[:10]}" - ) - - logger.info( - "✅ Keycloak JWT token without custom scopes correctly returns 0 tools (all filtered out)" - ) diff --git a/tests/server/oauth/test_login_elicitation.py b/tests/server/oauth/test_login_elicitation.py deleted file mode 100644 index 738b7c86..00000000 --- a/tests/server/oauth/test_login_elicitation.py +++ /dev/null @@ -1,246 +0,0 @@ -"""Integration tests for login elicitation flow (ADR-006 Interim Implementation). - -Tests verify: -1. check_logged_in tool with elicitation for unauthenticated users -2. Elicitation contains login URL in message -3. User can complete login via OAuth -4. After login, check_logged_in returns "yes" -5. Already-authenticated users get immediate "yes" response -6. Elicitation decline/cancel handling -""" - -import logging -import re - -import pytest - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -async def test_check_logged_in_elicitation_flow( - nc_mcp_oauth_client, browser, oauth_callback_server -): - """Test that check_logged_in elicits login for unauthenticated user. - - This test validates the complete elicitation flow: - 1. Call check_logged_in on authenticated client (already has refresh token) - 2. Verify tool returns "yes" without elicitation - 3. Extract and validate the elicitation URL format from response - 4. Verify refresh token exists after successful OAuth flow - - Note: Actual elicitation handling requires MCP protocol support in the test client. - This test validates the response format and token storage. - """ - # Call check_logged_in tool on authenticated client - logger.info("Calling check_logged_in on authenticated client") - result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={}) - - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None - - response_text = result.content[0].text - logger.info(f"check_logged_in response: {response_text}") - - # Since nc_mcp_oauth_client fixture already completes OAuth during setup, - # the user should already be provisioned and we expect "yes" - # For unauthenticated users, the response would contain an elicitation URL - # Note: Test framework may return "elicitation not supported" if MCP elicitation is unavailable - assert ( - "yes" in response_text.lower() - or "http" in response_text.lower() - or "elicitation not supported" in response_text.lower() - ), f"Unexpected response: {response_text}" - - # If response contains a URL (elicitation case), validate its format - if "http" in response_text: - url_pattern = r"https?://[^\s]+" - urls = re.findall(url_pattern, response_text) - assert len(urls) > 0, "Expected elicitation URL in response" - - login_url = urls[0] - logger.info(f"Elicitation URL: {login_url}") - - # Validate URL points to MCP server's Flow 2 endpoint - assert "/oauth/authorize-nextcloud" in login_url, ( - f"Expected URL to point to MCP server Flow 2 endpoint, got: {login_url}" - ) - # Validate URL contains state parameter - assert "state=" in login_url, "Expected state parameter in elicitation URL" - elif "elicitation not supported" in response_text.lower(): - logger.info( - "✓ Test client doesn't support elicitation - this is expected in test environment" - ) - - -async def test_check_logged_in_already_authenticated(nc_mcp_oauth_client): - """Test that check_logged_in returns 'yes' for authenticated user. - - This test verifies that if the user has already completed Flow 2 - (resource provisioning), the tool immediately returns "yes" without - elicitation. - """ - logger.info("Calling check_logged_in on authenticated client") - - # Since we're using the nc_mcp_oauth_client fixture which completes - # OAuth during setup, the user should already be provisioned - result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={}) - - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None - - response_text = result.content[0].text - logger.info(f"Response: {response_text}") - - # Check for valid responses: - # - "yes" (already logged in) - # - "not enabled" (offline access not enabled) - # - "not configured" (MCP_SERVER_CLIENT_ID not set) - # - "elicitation not supported" (test environment limitation) - assert ( - "yes" in response_text.lower() - or "not enabled" in response_text.lower() - or "not configured" in response_text.lower() - or "elicitation not supported" in response_text.lower() - ) - - -async def test_check_logged_in_url_format(nc_mcp_oauth_client): - """Test that login URL (when needed) follows correct OAuth format. - - This test verifies that if the tool needs to provide a login URL, - the URL contains the correct OAuth parameters for Flow 2. - """ - # Call the tool - result = await nc_mcp_oauth_client.call_tool("check_logged_in", arguments={}) - - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None - - response_text = result.content[0].text - logger.info(f"Response: {response_text}") - - # If response contains a URL, validate it - url_pattern = r"https?://[^\s]+" - urls = re.findall(url_pattern, response_text) - - if urls: - login_url = urls[0] - logger.info(f"Found login URL: {login_url}") - - # Validate OAuth parameters - assert "response_type=code" in login_url - assert "client_id=" in login_url - assert "redirect_uri=" in login_url - assert "scope=" in login_url - assert "state=" in login_url - assert "openid" in login_url # Should request openid scope - - # Validate callback URL (unified endpoint without query params) - # Note: redirect_uri should be /oauth/callback (no query params) - # Flow type is determined by session lookup, not URL params - assert ( - "/oauth/callback" in login_url - or "callback-nextcloud" in login_url # Legacy support - or "authorize-nextcloud" in login_url - ) - - -async def test_check_logged_in_with_user_id(nc_mcp_oauth_client): - """Test that check_logged_in accepts optional user_id parameter. - - This verifies the tool can be called with an explicit user_id. - """ - result = await nc_mcp_oauth_client.call_tool( - "check_logged_in", arguments={"user_id": "testuser"} - ) - - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None - - response_text = result.content[0].text - logger.info(f"Response with user_id: {response_text}") - - # Should get some response (either yes or not logged in) - assert len(response_text) > 0 - - -async def test_check_logged_in_tool_metadata(nc_mcp_oauth_client): - """Test that check_logged_in tool has correct metadata.""" - tools = await nc_mcp_oauth_client.list_tools() - assert tools is not None - - # Find the check_logged_in tool - check_logged_in_tool = None - for tool in tools.tools: - if tool.name == "check_logged_in": - check_logged_in_tool = tool - break - - assert check_logged_in_tool is not None, "check_logged_in tool not found" - logger.info(f"Tool: {check_logged_in_tool.name}") - logger.info(f"Description: {check_logged_in_tool.description}") - - # Verify description mentions login - assert "login" in check_logged_in_tool.description.lower() - - # Tool should have openid scope requirement - # (This would need to be verified via tool schema if exposed) - - -async def test_elicitation_url_and_refresh_token_flow(nc_mcp_oauth_client): - """Test that MCP server validates refresh tokens after OAuth completion. - - This test validates the server's refresh token handling through its API: - 1. Call check_provisioning_status to verify server-side token validation - 2. Server responses indicate token state: - - is_provisioned=True: Server has valid refresh token - - is_provisioned=False: No token or invalid token - - Error response: Token validation failed - - The test does NOT directly access refresh token storage - it relies on - the MCP server to validate tokens internally and report status via API. - """ - logger.info("Testing server-side refresh token validation via API") - - # Call check_provisioning_status - the server will internally: - # 1. Check if refresh token exists for the user - # 2. Validate the refresh token is not expired - # 3. Return provisioning status - result = await nc_mcp_oauth_client.call_tool( - "check_provisioning_status", arguments={} - ) - - assert result.isError is False, f"Tool execution failed: {result.content}" - assert result.content is not None - - response_text = result.content[0].text - logger.info(f"Provisioning status response: {response_text}") - - # Parse the response to validate server's token validation - # Expected responses: - # 1. "is_provisioned: true" - server validated token successfully - # 2. "is_provisioned: false" - no token or invalid token - # 3. Error message - token validation failed - - if "is_provisioned" in response_text.lower(): - if "true" in response_text.lower(): - logger.info("✓ Server validated refresh token: is_provisioned=True") - logger.info(" This confirms the server has a valid refresh token stored") - else: - logger.info("Server reports: is_provisioned=False (no valid token)") - elif "error" in response_text.lower(): - logger.warning( - f"Server returned error during token validation: {response_text}" - ) - else: - logger.info(f"Server response: {response_text}") - - # The key validation: Server must return a valid response - # (not an error), proving it can check its own refresh token state - assert ( - "is_provisioned" in response_text.lower() or "offline" in response_text.lower() - ), f"Expected provisioning status response from server, got: {response_text}" - - logger.info("✓ Server successfully validated refresh token state via API") diff --git a/tests/server/oauth/test_nc_php_app_debug.py b/tests/server/oauth/test_nc_php_app_debug.py deleted file mode 100644 index c2bce01f..00000000 --- a/tests/server/oauth/test_nc_php_app_debug.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Debug test to capture what's on the NC PHP app settings page.""" - -import logging -import os - -import pytest - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server): - """Capture what's actually rendered on the personal settings page.""" - # Configure Astrolabe for mcp-oauth server - await configure_astrolabe_for_mcp_server( - mcp_server_internal_url="http://mcp-oauth:8001", - mcp_server_public_url="http://localhost:8001", - ) - - nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080") - username = os.getenv("NEXTCLOUD_USERNAME", "admin") - password = os.getenv("NEXTCLOUD_PASSWORD", "admin") - - context = await browser.new_context() - page = await context.new_page() - - try: - # Login - logger.info(f"Logging in to {nextcloud_host} as {username}...") - await page.goto(f"{nextcloud_host}/login") - await page.fill('input[name="user"]', username) - await page.fill('input[name="password"]', password) - await page.click('button[type="submit"]') - await page.wait_for_url(f"{nextcloud_host}/apps/dashboard/", timeout=10000) - logger.info("✓ Logged in") - - # Navigate to settings - logger.info("Navigating to personal MCP settings...") - await page.goto(f"{nextcloud_host}/settings/user/astrolabe") - await page.wait_for_load_state("networkidle") - - # Capture page content - page_content = await page.content() - - # Save screenshot - screenshot_path = "/tmp/nc-php-app-settings-debug.png" - await page.screenshot(path=screenshot_path, full_page=True) - logger.info(f"Screenshot saved to: {screenshot_path}") - - # Log what we found - logger.info(f"Page URL: {page.url}") - logger.info(f"Page title: {await page.title()}") - - # Check for key strings (Vue 3 UI) - checks = [ - "Enable Semantic Search", # oauth-required.php authorization button - "Service Status", # personal.php when authorized - "Background Sync Access", # personal.php when authorized - "What happens next?", # oauth-required.php steps - "Astrolabe", # Header - ] - - for check in checks: - found = check in page_content - logger.info(f" '{check}': {'FOUND' if found else 'NOT FOUND'}") - - # Print first 500 chars of body - body = await page.locator("body").text_content() - logger.info(f"Body text (first 500 chars): {body[:500] if body else 'NO BODY'}") - - # Try to find links - links = await page.locator("a").all_text_contents() - logger.info(f"Found {len(links)} links on page") - for i, link_text in enumerate(links[:10]): - logger.info(f" Link {i}: {link_text}") - - # Check the Enable Semantic Search button href - try: - btn = page.locator('a:has-text("Enable Semantic Search")') - if await btn.count() > 0: - href = await btn.get_attribute("href") - logger.info(f"Enable Semantic Search button href: {href}") - except Exception as e: - logger.warning(f"Could not get button href: {e}") - - # Check for error messages - if "error" in page_content.lower(): - logger.warning("Page contains 'error' keyword") - - finally: - await context.close() diff --git a/tests/server/oauth/test_nc_php_app_oauth.py b/tests/server/oauth/test_nc_php_app_oauth.py deleted file mode 100644 index b4d91d5d..00000000 --- a/tests/server/oauth/test_nc_php_app_oauth.py +++ /dev/null @@ -1,414 +0,0 @@ -"""Test OAuth authorization flow for Nextcloud PHP app (astrolabe). - -Tests the complete PKCE OAuth flow from the NC PHP app perspective: -1. User navigates to personal settings -2. Clicks "Authorize Access" button -3. Completes OAuth authorization via Nextcloud OIDC app -4. Token is stored encrypted in Nextcloud database -5. App can use token to call MCP management API - -This tests the architecture from ADR-018 where the NC PHP app uses -OAuth PKCE (public client) to obtain tokens from Nextcloud's OIDC app. -""" - -import logging -import os - -import httpx -import pytest - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -@pytest.fixture(scope="module") -def nextcloud_credentials(): - """Get Nextcloud credentials from environment.""" - return { - "host": os.getenv("NEXTCLOUD_HOST", "http://localhost:8080"), - "username": os.getenv("NEXTCLOUD_USERNAME", "admin"), - "password": os.getenv("NEXTCLOUD_PASSWORD", "admin"), - } - - -@pytest.fixture(scope="module") -async def nc_admin_http_client(nextcloud_credentials): - """HTTP client authenticated as admin user for NC API calls.""" - async with httpx.AsyncClient( - base_url=nextcloud_credentials["host"], - auth=(nextcloud_credentials["username"], nextcloud_credentials["password"]), - timeout=30.0, - ) as client: - yield client - - -@pytest.fixture(scope="module") -async def configure_astrolabe_for_tests(configure_astrolabe_for_mcp_server): - """Configure Astrolabe to connect to mcp-oauth server before running tests. - - This module-scoped fixture ensures Astrolabe is properly configured - for the mcp-oauth server (http://localhost:8001) before any tests run. - """ - logger.info("Configuring Astrolabe for mcp-oauth server...") - await configure_astrolabe_for_mcp_server( - mcp_server_internal_url="http://mcp-oauth:8001", - mcp_server_public_url="http://localhost:8001", - ) - logger.info("✓ Astrolabe configured for mcp-oauth server") - - -@pytest.fixture(scope="module") -async def authorized_nc_session( - browser, nextcloud_credentials, configure_astrolabe_for_tests -): - """Module-scoped fixture that logs in and authorizes the NC PHP app once. - - This fixture: - 1. Configures Astrolabe for mcp-oauth server (via configure_astrolabe_for_tests) - 2. Creates a browser context - 3. Logs in to Nextcloud - 4. Authorizes the MCP Server UI app (if not already authorized) - 5. Returns the page for use in all tests - - The authorization is done once and reused for all tests in this module. - """ - host = nextcloud_credentials["host"] - username = nextcloud_credentials["username"] - password = nextcloud_credentials["password"] - - logger.info("Setting up module-scoped authorized NC session...") - - # Create browser context that persists for module duration - context = await browser.new_context() - page = await context.new_page() - - # Enable console message logging - page.on( - "console", lambda msg: logger.debug(f"Browser console [{msg.type}]: {msg.text}") - ) - page.on("pageerror", lambda err: logger.error(f"Browser page error: {err}")) - - try: - # Step 1: Login to Nextcloud - logger.info(f"Logging in to Nextcloud as {username}...") - await page.goto(f"{host}/login") - - # Fill login form - await page.fill('input[name="user"]', username) - await page.fill('input[name="password"]', password) - await page.click('button[type="submit"]') - - # Wait for login to complete (dashboard loads) - await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000) - logger.info("✓ Logged in successfully") - - # Step 2: Navigate to personal MCP settings - logger.info("Navigating to personal MCP settings...") - await page.goto(f"{host}/settings/user/astrolabe") - await page.wait_for_load_state("networkidle") - - page_content = await page.content() - - # Step 3: Check if authorization is needed - # Vue 3 UI shows "Enable Semantic Search" when not authorized - if ( - "Enable Semantic Search" in page_content - or "What happens next?" in page_content - ): - logger.info("User not authorized yet - initiating OAuth flow...") - - # Click "Enable Semantic Search" button (Vue 3 template text) - authorize_selectors = [ - 'a:has-text("Enable Semantic Search")', - 'button:has-text("Enable Semantic Search")', - 'a:has-text("Sign In Again")', - "a.button.primary", - '[href*="oauth/login"]', - ] - - clicked = False - for selector in authorize_selectors: - try: - await page.click(selector, timeout=2000) - clicked = True - logger.info(f"✓ Clicked authorize button (selector: {selector})") - break - except Exception: - continue - - if not clicked: - screenshot_path = "/tmp/nc-php-app-settings.png" - await page.screenshot(path=screenshot_path) - pytest.fail( - f"Could not find authorize button. Screenshot: {screenshot_path}" - ) - - # Wait for page to load after clicking - await page.wait_for_load_state("networkidle", timeout=10000) - current_url = page.url - logger.info(f"After clicking authorize, current URL: {current_url}") - - # Take screenshot for debugging - await page.screenshot(path="/tmp/nc-php-app-after-authorize-click.png") - logger.info("Screenshot saved to /tmp/nc-php-app-after-authorize-click.png") - - # Handle OAuth consent if needed - if ( - "/apps/oidc/authorize" in current_url - or "/apps/oidc/consent" in current_url - ): - logger.info("On OIDC authorization page - granting consent...") - - consent_selectors = [ - 'button:has-text("Allow")', - 'button:has-text("Authorize")', - 'input[type="submit"][value="Allow"]', - 'button[type="submit"]', - ] - - for selector in consent_selectors: - try: - await page.click(selector, timeout=2000) - logger.info(f"✓ Clicked consent button (selector: {selector})") - break - except Exception: - continue - - # Wait for redirect back to settings - await page.wait_for_url(f"{host}/settings/user/astrolabe", timeout=15000) - await page.wait_for_load_state("networkidle") - logger.info("✓ OAuth authorization completed") - - else: - logger.info("User already authorized") - - # Return the page and context info for tests - yield { - "page": page, - "context": context, - "host": host, - "username": username, - } - - finally: - # Cleanup at module end - logger.info("Closing authorized NC session...") - await context.close() - - -class TestNcPhpAppOAuth: - """Test suite for NC PHP app OAuth integration.""" - - async def test_authorization_completed(self, authorized_nc_session): - """Verify OAuth authorization was successful. - - This test verifies the settings page shows the user is connected - after the module-scoped authorization fixture runs. - """ - page = authorized_nc_session["page"] - host = authorized_nc_session["host"] - - # Navigate to settings (may already be there) - await page.goto(f"{host}/settings/user/astrolabe") - await page.wait_for_load_state("networkidle") - - page_content = await page.content() - - # Look for indicators that authorization succeeded (Vue 3 personal.php template) - # These must be unique to the authorized state (not found in oauth-required.php) - success_indicators = [ - "Service Status", - "Background Sync Access", - "Manage Connection", - "Revoke Access", - "Service URL", - ] - - found_indicators = [ind for ind in success_indicators if ind in page_content] - has_success_indicator = len(found_indicators) > 0 - - # Always take screenshot for debugging - screenshot_path = "/tmp/nc-php-app-auth-check.png" - await page.screenshot(path=screenshot_path) - logger.info(f"Authorization check screenshot: {screenshot_path}") - logger.info(f"Found success indicators: {found_indicators}") - - if not has_success_indicator: - logger.error("Authorization check failed.") - - assert has_success_indicator, "Settings page should show user is authorized" - logger.info("✓ Authorization verification passed") - - async def test_token_storage_and_retrieval(self, authorized_nc_session): - """Test that tokens are properly stored and can be retrieved. - - Verifies the settings page displays session information, - indicating the token was stored and retrieved successfully. - """ - page = authorized_nc_session["page"] - host = authorized_nc_session["host"] - - await page.goto(f"{host}/settings/user/astrolabe") - await page.wait_for_load_state("networkidle") - - page_content = await page.content() - - # Debug: take screenshot and log content excerpt - screenshot_path = "/tmp/nc-php-app-token-test.png" - await page.screenshot(path=screenshot_path) - logger.info(f"Screenshot saved: {screenshot_path}") - logger.info(f"Page content excerpt: {page_content[:1000]}") - - # Verify session information is visible (Vue 3 personal.php template) - session_indicators = [ - "Service Status", - "Service URL", - "Version", - "Background Sync Access", - ] - - found_indicators = [ind for ind in session_indicators if ind in page_content] - assert len(found_indicators) >= 2, ( - f"Expected session info on page. Found: {found_indicators}. Check {screenshot_path}" - ) - - logger.info(f"✓ Token retrieval verified - found: {found_indicators}") - - async def test_management_api_access( - self, authorized_nc_session, nc_admin_http_client - ): - """Test that the NC PHP app can access MCP server management API. - - Verifies the settings page successfully fetched data from the - MCP server's management API endpoints. - """ - page = authorized_nc_session["page"] - host = authorized_nc_session["host"] - - # Check personal settings page shows server status - await page.goto(f"{host}/settings/user/astrolabe") - await page.wait_for_load_state("networkidle") - - page_content = await page.content() - - # Look for data that comes from management API or template structure (Vue 3) - api_indicators = [ - "Service Status", # Section header - "Service URL", # Server info from API - "Version", # Server version from management API - "Semantic Search", # Vector sync status - ] - - found_api_data = [ind for ind in api_indicators if ind in page_content] - assert len(found_api_data) >= 1, ( - f"Expected management API data on page. Found: {found_api_data}" - ) - - logger.info(f"✓ Management API access verified - found: {found_api_data}") - - async def test_admin_settings_page(self, authorized_nc_session): - """Test that admin settings page loads and displays server info. - - The admin page should show server status from the management API. - """ - page = authorized_nc_session["page"] - host = authorized_nc_session["host"] - - await page.goto(f"{host}/settings/admin/astrolabe") - await page.wait_for_load_state("networkidle") - - page_content = await page.content() - - # Admin page should show server status (Vue 3 AdminSettings.vue) - admin_indicators = [ - "Astrolabe", - "Service Status", - "Version", - "Semantic Search", - ] - - found_indicators = [ind for ind in admin_indicators if ind in page_content] - - # Admin page should at least show the Astrolabe header or Service Status - assert "Astrolabe" in page_content or "Service Status" in page_content, ( - "Admin settings page should show Astrolabe section" - ) - - logger.info(f"✓ Admin settings page verified - found: {found_indicators}") - - -class TestNcPhpAppDisconnect: - """Test suite for NC PHP app disconnect functionality. - - Note: These tests are run separately and may modify the authorization state. - They should run after the main OAuth tests. - """ - - @pytest.mark.skip(reason="Disconnect test modifies state - run manually if needed") - async def test_disconnect_flow(self, browser, nextcloud_credentials): - """Test that users can disconnect (revoke) their authorization. - - This test: - 1. Logs in fresh (separate from authorized_nc_session) - 2. Verifies user is authorized - 3. Clicks "Disconnect" button - 4. Verifies user is no longer authorized - - Skipped by default as it modifies authorization state. - """ - host = nextcloud_credentials["host"] - username = nextcloud_credentials["username"] - password = nextcloud_credentials["password"] - - context = await browser.new_context() - page = await context.new_page() - - try: - # Login - await page.goto(f"{host}/login") - await page.fill('input[name="user"]', username) - await page.fill('input[name="password"]', password) - await page.click('button[type="submit"]') - await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000) - - # Navigate to personal settings - await page.goto(f"{host}/settings/user/astrolabe") - await page.wait_for_load_state("networkidle") - - page_content = await page.content() - - # Check if user is authorized (Vue 3 personal.php shows Disconnect/Revoke when authorized) - if "Disconnect" not in page_content and "Revoke Access" not in page_content: - pytest.skip("User not authorized - cannot test disconnect") - - # Click disconnect button - disconnect_selectors = [ - 'button:has-text("Disconnect")', - 'form[action*="disconnect"] button', - "#mcp-disconnect-button", - ] - - for selector in disconnect_selectors: - try: - # Handle confirmation dialog - page.on("dialog", lambda dialog: dialog.accept()) - await page.click(selector, timeout=2000) - logger.info(f"✓ Clicked disconnect button (selector: {selector})") - break - except Exception: - continue - - # Wait for page reload - await page.wait_for_load_state("networkidle") - - # Verify we're back to "Enable Semantic Search" state (Vue 3 oauth-required.php) - page_content = await page.content() - assert "Enable Semantic Search" in page_content, ( - "Settings page should show 'Enable Semantic Search' after disconnect" - ) - - logger.info("✓ Disconnect flow test passed") - - finally: - await context.close() diff --git a/tests/server/oauth/test_oauth_core.py b/tests/server/oauth/test_oauth_core.py deleted file mode 100644 index 7364cc7c..00000000 --- a/tests/server/oauth/test_oauth_core.py +++ /dev/null @@ -1,262 +0,0 @@ -"""Core OAuth integration tests. - -Consolidated from: -- test_mcp_oauth.py: Basic OAuth connectivity -- test_mcp_oauth_jwt.py: JWT-specific operations -- test_jwt_tokens.py: JWT token structure validation - -Tests verify: -1. OAuth server connectivity and tool listing -2. Tool execution with OAuth tokens -3. JWT token structure and claims -4. Multiple operations with same token (persistence) -5. Error handling with OAuth -""" - -import base64 -import json -import logging - -import pytest - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -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, - } - - -# ============================================================================ -# Basic OAuth Connectivity Tests -# ============================================================================ - - -async def test_mcp_oauth_server_connection(nc_mcp_oauth_client): - """Test connection to OAuth-enabled MCP server.""" - result = await nc_mcp_oauth_client.list_tools() - assert result is not None - assert len(result.tools) > 0 - - logger.info(f"OAuth MCP server has {len(result.tools)} tools available") - - -async def test_mcp_oauth_tool_execution(nc_mcp_oauth_client): - """Test executing a tool on the OAuth-enabled MCP server.""" - # Example: Execute the 'nc_notes_search_notes' tool - result = await nc_mcp_oauth_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) - - # The search response should have a 'results' field containing the list - assert "results" in response_data - assert isinstance(response_data["results"], list) - - logger.info( - f"Successfully executed 'nc_notes_search_notes' tool on OAuth MCP server and got {len(response_data['results'])} notes." - ) - - -async def test_mcp_oauth_client_with_playwright(nc_mcp_oauth_client): - """Test that MCP OAuth client via Playwright can execute tools.""" - # Test: Execute the 'nc_notes_search_notes' tool - result = await nc_mcp_oauth_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) - - # The search response should have a 'results' field containing the list - assert "results" in response_data - assert isinstance(response_data["results"], list) - - logger.info( - f"Successfully executed 'nc_notes_search_notes' tool on Playwright OAuth MCP server and got {len(response_data['results'])} notes." - ) - - -# ============================================================================ -# JWT-Specific Tests -# ============================================================================ - - -async def test_jwt_tool_list_operations(nc_mcp_oauth_jwt_client): - """Test that list_tools works with JWT authentication and returns expected tools. - - This test verifies that tools are properly filtered based on per-app scopes: - - notes:read/write → Notes app tools - - calendar:read/write → Calendar app tools - - files:read/write → WebDAV/Files app tools - - etc. - """ - result = await nc_mcp_oauth_jwt_client.list_tools() - - # Verify we have tools - assert len(result.tools) > 0 - - # Verify expected tools exist based on configured scopes - tool_names = [tool.name for tool in result.tools] - - # Notes tools (require notes:read and notes:write) - assert "nc_notes_get_note" in tool_names, "Missing nc_notes_get_note (notes:read)" - assert "nc_notes_create_note" in tool_names, ( - "Missing nc_notes_create_note (notes:write)" - ) - - # Calendar tools (require calendar:read and calendar:write) - assert "nc_calendar_list_calendars" in tool_names, ( - "Missing nc_calendar_list_calendars (calendar:read)" - ) - assert "nc_calendar_create_event" in tool_names, ( - "Missing nc_calendar_create_event (calendar:write)" - ) - - # Verify we have a reasonable number of tools for the configured scopes - # With notes + calendar scopes, expect ~20-30 tools - assert len(tool_names) >= 20, ( - f"Expected at least 20 tools with notes+calendar scopes, got {len(tool_names)}" - ) - - logger.info( - f"JWT OAuth server provides {len(result.tools)} tools with configured per-app scopes" - ) - - -async def test_jwt_multiple_operations(nc_mcp_oauth_jwt_client): - """Test multiple operations with same JWT token to verify token persistence. - - JWT tokens should work across multiple tool calls without re-authentication, - demonstrating that the token is properly cached and reused. - """ - # 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 3 different operations with same JWT token (token persistence verified)" - ) - - -async def test_jwt_error_handling(nc_mcp_oauth_jwt_client): - """Test error handling with JWT authentication. - - Verifies that invalid operations return proper errors even with valid JWT tokens. - """ - # 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 OAuth server correctly handles errors for invalid operations") - - -# ============================================================================ -# JWT Token Structure Tests -# ============================================================================ - - -async def test_jwt_tokens_embed_scopes_in_payload(): - """Document that JWT tokens embed scopes in the payload (RFC 9068). - - This test documents expected JWT structure based on manual testing. - """ - expected_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 (e.g., 'notes:read notes:write')", - "client_id": "client identifier", - "jti": "JWT ID", - }, - "scope_claim": { - "format": "space-separated string", - "example": "openid profile email notes:read notes:write", - "extraction": "payload['scope'].split()", - }, - } - - logger.info("JWT token structure (RFC 9068):") - logger.info(json.dumps(expected_structure, indent=2)) - - # This test documents expected behavior - assert True - - -async def test_opaque_token_vs_jwt_comparison(): - """Document differences between opaque tokens and JWT tokens. - - This test captures our findings about the two token types. - """ - findings = { - "jwt_advantages": [ - "Scopes embedded in payload - no introspection needed", - "Self-contained - can validate with JWKS", - "Standard approach (RFC 9068)", - ], - "jwt_disadvantages": [ - "10-15x larger than opaque tokens (~800-1200 chars vs 72)", - "Cannot be easily revoked (until expiration)", - ], - "token_sizes": { - "opaque": "72 characters", - "jwt": "~800-1200 characters", - }, - "recommendation": "Use JWT for MCP server (scopes available without introspection)", - } - - logger.info("JWT vs Opaque token comparison:") - logger.info(json.dumps(findings, indent=2)) - - assert True diff --git a/tests/server/oauth/test_oauth_deck_permissions.py b/tests/server/oauth/test_oauth_deck_permissions.py deleted file mode 100644 index ee444aaf..00000000 --- a/tests/server/oauth/test_oauth_deck_permissions.py +++ /dev/null @@ -1,350 +0,0 @@ -""" -Multi-user OAuth tests for Nextcloud Deck board permissions. - -Tests verify that the MCP server respects Nextcloud Deck board ACL permissions -when accessed via OAuth authentication with different users. -""" - -import json -import logging - -import pytest - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -async def add_board_acl(nc_client, board_id: int, user: str, permission_type: int = 0): - """ - Helper to add ACL entry to a Deck board. - - Args: - nc_client: Admin NextcloudClient - board_id: Board ID - user: Username to grant access - permission_type: 0=view, 1=edit, 2=manage - - Returns: - ACL entry ID - """ - acl = await nc_client.deck.add_acl_rule( - board_id=board_id, - type=0, # 0 = user, 1 = group - participant=user, - permission_edit=permission_type >= 1, - permission_share=permission_type >= 2, - permission_manage=permission_type >= 2, - ) - logger.info(f"Added ACL for board {board_id}: {user} (type={permission_type})") - return acl.id - - -async def delete_board_acl(nc_client, board_id: int, acl_id: int): - """Helper to delete a board ACL entry.""" - await nc_client.deck.delete_acl_rule(board_id, acl_id) - logger.info(f"Deleted ACL {acl_id} from board {board_id}") - - -async def test_deck_board_view_permissions( - nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client -): - """ - Test that Deck boards respect view permissions. - - Scenario: - 1. Admin creates a board as alice - 2. Admin adds bob to board with view-only permissions - 3. Bob can view the board via MCP tools - 4. Diana cannot access the board (no ACL entry) - """ - # Create a board as alice - logger.info("Creating Deck board as alice...") - board = await nc_client.deck.create_board( - "Alice's Shared Board - View Test", "FF0000" - ) - board_id = board.id - - bob_acl_id = None - - try: - # Add bob to board with view-only permission - logger.info("Adding bob to board with view permission...") - bob_acl_id = await add_board_acl(nc_client, board_id, "bob", permission_type=0) - - # Test: Bob can view the board via MCP - logger.info("Bob attempting to list boards via MCP...") - result = await bob_mcp_client.call_tool("deck_get_boards", arguments={}) - - if not result.isError: - response_data = json.loads(result.content[0].text) - # Response is a ListBoardsResponse with a "boards" field - board_list = response_data.get("boards", []) - board_ids = [b["id"] for b in board_list] - logger.info(f"Bob can see {len(board_list)} boards: {board_ids}") - - # Bob should see the shared board - if board_id in board_ids: - logger.info(f"Bob can see shared board {board_id}") - else: - logger.warning(f"Bob cannot see shared board {board_id}") - else: - logger.warning(f"Bob could not list boards: {result.content}") - - # Test: Diana cannot see the board - logger.info("Diana attempting to list boards via MCP...") - result = await diana_mcp_client.call_tool("deck_get_boards", arguments={}) - - if not result.isError: - response_data = json.loads(result.content[0].text) - # Response is a ListBoardsResponse with a "boards" field - board_list = response_data.get("boards", []) - board_ids = [b["id"] for b in board_list] - logger.info(f"Diana can see {len(board_list)} boards") - - # Diana should NOT see the board - assert board_id not in board_ids, "Diana should not see board without ACL" - logger.info("Diana correctly cannot see board without ACL") - else: - logger.warning(f"Diana could not list boards: {result.content}") - - finally: - # Cleanup - if bob_acl_id: - await delete_board_acl(nc_client, board_id, bob_acl_id) - logger.info(f"Deleting board {board_id}") - await nc_client.deck.delete_board(board_id) - - -async def test_deck_board_edit_permissions( - nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client -): - """ - Test that Deck boards respect edit permissions. - - Scenario: - 1. Admin creates a board as alice with a stack - 2. Admin adds charlie with edit permission - 3. Admin adds bob with view-only permission - 4. Charlie can create cards via MCP tools - 5. Bob cannot create cards - """ - # Create a board as alice - logger.info("Creating Deck board as alice...") - board = await nc_client.deck.create_board( - "Alice's Shared Board - Edit Test", "00FF00" - ) - board_id = board.id - - # Create a stack in the board - logger.info("Creating stack in board...") - stack = await nc_client.deck.create_stack(board_id, "Test Stack", 1) - stack_id = stack.id - - charlie_acl_id = None - bob_acl_id = None - - try: - # Add charlie with edit permission - logger.info("Adding charlie to board with edit permission...") - charlie_acl_id = await add_board_acl( - nc_client, board_id, "charlie", permission_type=1 - ) - - # Add bob with view-only permission - logger.info("Adding bob to board with view permission...") - bob_acl_id = await add_board_acl(nc_client, board_id, "bob", permission_type=0) - - # Test: Charlie can create a card - logger.info("Charlie attempting to create card via MCP...") - result = await charlie_mcp_client.call_tool( - "deck_create_card", - arguments={ - "board_id": board_id, - "stack_id": stack_id, - "title": "Charlie's Card", - "description": "Created by Charlie with edit permission", - }, - ) - - if not result.isError: - response_data = json.loads(result.content[0].text) - card_id = response_data.get("id") - logger.info(f"Charlie successfully created card {card_id}") - - # Cleanup the card - await nc_client.deck.delete_card(board_id, stack_id, card_id) - else: - logger.warning(f"Charlie could not create card: {result.content}") - - # Test: Bob attempts to create a card (should fail) - logger.info("Bob attempting to create card via MCP...") - result = await bob_mcp_client.call_tool( - "deck_create_card", - arguments={ - "board_id": board_id, - "stack_id": stack_id, - "title": "Bob's Card", - "description": "Bob trying to create a card", - }, - ) - - if result.isError: - logger.info("Bob correctly denied card creation (view-only)") - else: - logger.warning("Bob unexpectedly succeeded in creating card") - # Cleanup if bob somehow created a card - response_data = json.loads(result.content[0].text) - if "id" in response_data: - await nc_client.deck.delete_card( - board_id, stack_id, response_data["id"] - ) - - finally: - # Cleanup - if charlie_acl_id: - await delete_board_acl(nc_client, board_id, charlie_acl_id) - if bob_acl_id: - await delete_board_acl(nc_client, board_id, bob_acl_id) - logger.info(f"Deleting board {board_id}") - await nc_client.deck.delete_board(board_id) - - -async def test_deck_board_manage_permissions( - nc_client, alice_mcp_client, charlie_mcp_client -): - """ - Test that Deck boards respect manage permissions. - - Scenario: - 1. Admin creates a board as alice - 2. Admin adds charlie with manage permission - 3. Charlie can create stacks and modify board settings - """ - # Create a board as alice - logger.info("Creating Deck board as alice...") - board = await nc_client.deck.create_board( - "Alice's Shared Board - Manage Test", "0000FF" - ) - board_id = board.id - - charlie_acl_id = None - - try: - # Add charlie with manage permission - logger.info("Adding charlie to board with manage permission...") - charlie_acl_id = await add_board_acl( - nc_client, board_id, "charlie", permission_type=2 - ) - - # Test: Charlie can create a stack - logger.info("Charlie attempting to create stack via MCP...") - result = await charlie_mcp_client.call_tool( - "deck_create_stack", - arguments={"board_id": board_id, "title": "Charlie's Stack", "order": 1}, - ) - - if not result.isError: - response_data = json.loads(result.content[0].text) - stack_id = response_data.get("id") - logger.info(f"Charlie successfully created stack {stack_id}") - - # Cleanup the stack - await nc_client.deck.delete_stack(board_id, stack_id) - else: - logger.warning(f"Charlie could not create stack: {result.content}") - - # Test: Charlie can delete a stack (manage permission) - logger.info("Charlie attempting to delete stack via MCP...") - # First create a temporary stack to delete - temp_stack = await nc_client.deck.create_stack( - board_id, "Temp Stack for Deletion", 99 - ) - - result = await charlie_mcp_client.call_tool( - "deck_delete_stack", - arguments={"board_id": board_id, "stack_id": temp_stack.id}, - ) - - if not result.isError: - logger.info("Charlie successfully deleted stack") - else: - logger.warning(f"Charlie could not delete stack: {result.content}") - # Cleanup if deletion via MCP failed - try: - await nc_client.deck.delete_stack(board_id, temp_stack.id) - except Exception: - pass - - finally: - # Cleanup - if charlie_acl_id: - await delete_board_acl(nc_client, board_id, charlie_acl_id) - logger.info(f"Deleting board {board_id}") - await nc_client.deck.delete_board(board_id) - - -async def test_deck_user_isolation(nc_client, alice_mcp_client, bob_mcp_client): - """ - Test that users can only see their own boards when not shared. - - Scenario: - 1. Admin creates a board as alice (not shared) - 2. Admin creates a board as bob (not shared) - 3. Alice can only see her own board - 4. Bob can only see his own board - """ - # Create alice's board - logger.info("Creating alice's private board...") - alice_board = await nc_client.deck.create_board("Alice's Private Board", "FF00FF") - alice_board_id = alice_board.id - - # Create bob's board - logger.info("Creating bob's private board...") - bob_board = await nc_client.deck.create_board("Bob's Private Board", "00FFFF") - bob_board_id = bob_board.id - - try: - # Test: Alice lists boards - logger.info("Alice listing boards via MCP...") - result = await alice_mcp_client.call_tool("deck_get_boards", arguments={}) - - if not result.isError: - response_data = json.loads(result.content[0].text) - # Response is a ListBoardsResponse with a "boards" field - board_list = response_data.get("boards", []) - board_ids = [b["id"] for b in board_list] - logger.info(f"Alice can see boards: {board_ids}") - - # Alice should NOT see Bob's board - assert bob_board_id not in board_ids, ( - "Alice should not see Bob's private board" - ) - else: - logger.warning(f"Alice could not list boards: {result.content}") - - # Test: Bob lists boards - logger.info("Bob listing boards via MCP...") - result = await bob_mcp_client.call_tool("deck_get_boards", arguments={}) - - if not result.isError: - response_data = json.loads(result.content[0].text) - # Response is a ListBoardsResponse with a "boards" field - board_list = response_data.get("boards", []) - board_ids = [b["id"] for b in board_list] - logger.info(f"Bob can see boards: {board_ids}") - - # Bob should NOT see Alice's board - assert alice_board_id not in board_ids, ( - "Bob should not see Alice's private board" - ) - else: - logger.warning(f"Bob could not list boards: {result.content}") - - logger.info("User isolation test passed: users can only see their own boards") - - finally: - # Cleanup - logger.info("Cleaning up test boards...") - await nc_client.deck.delete_board(alice_board_id) - await nc_client.deck.delete_board(bob_board_id) diff --git a/tests/server/oauth/test_oauth_file_permissions.py b/tests/server/oauth/test_oauth_file_permissions.py deleted file mode 100644 index 1254ee1d..00000000 --- a/tests/server/oauth/test_oauth_file_permissions.py +++ /dev/null @@ -1,421 +0,0 @@ -""" -Multi-user OAuth tests for Nextcloud WebDAV file permissions. - -Tests verify that the MCP server respects Nextcloud file sharing permissions -when accessed via OAuth authentication with different users. - -All operations (file creation, sharing, access) are performed through MCP tools -to ensure the MCP server properly supports multi-user scenarios. -""" - -import json -import logging - -import pytest - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -async def test_file_share_read_permissions( - alice_mcp_client, bob_mcp_client, diana_mcp_client -): - """ - Test that shared files respect read permissions. - - Scenario: - 1. Alice creates a file via MCP - 2. Alice shares the file with Bob (read-only) via MCP - 3. Bob can read the file via MCP tools - 4. Diana cannot access the file (no share) - """ - file_path = "/alice_shared_file_read.txt" - file_content = "This file is shared with Bob for reading only." - - # Alice creates a file - logger.info(f"Alice creating file: {file_path}") - result = await alice_mcp_client.call_tool( - "nc_webdav_write_file", - arguments={"path": file_path, "content": file_content}, - ) - assert not result.isError, f"Alice failed to create file: {result.content}" - - share_id = None - - try: - # Alice shares the file with bob (read-only, permissions=1) - logger.info("Alice sharing file with bob (read-only)...") - result = await alice_mcp_client.call_tool( - "nc_share_create", - arguments={ - "path": file_path, - "share_with": "bob", - "share_type": 0, - "permissions": 1, - }, - ) - assert not result.isError, f"Alice failed to create share: {result.content}" - share_data = json.loads(result.content[0].text) - share_id = share_data["id"] - logger.info(f"Created share {share_id}") - - # Test: Bob reads the file via MCP - logger.info("Bob attempting to read file via MCP...") - result = await bob_mcp_client.call_tool( - "nc_webdav_read_file", arguments={"path": file_path} - ) - - # Bob should be able to read the shared file - if not result.isError: - response_data = json.loads(result.content[0].text) - logger.info( - f"Bob successfully read file: {response_data.get('content', '')[:50]}..." - ) - assert "content" in response_data - assert file_content in response_data["content"] - else: - logger.warning(f"Bob could not read file: {result.content}") - # This might fail if the share path is different for bob - - # Test: Diana attempts to read the file - logger.info("Diana attempting to read file via MCP...") - result = await diana_mcp_client.call_tool( - "nc_webdav_read_file", arguments={"path": file_path} - ) - - # Diana should NOT be able to read (no share) - if result.isError: - logger.info("Diana correctly denied access to unshared file") - else: - logger.warning("Diana unexpectedly could read unshared file") - - finally: - # Cleanup - Alice deletes the share and file - if share_id: - logger.info(f"Alice deleting share {share_id}") - await alice_mcp_client.call_tool( - "nc_share_delete", arguments={"share_id": share_id} - ) - logger.info(f"Alice deleting file {file_path}") - await alice_mcp_client.call_tool( - "nc_webdav_delete_resource", arguments={"path": file_path} - ) - - -async def test_file_share_write_permissions( - alice_mcp_client, charlie_mcp_client, bob_mcp_client -): - """ - Test that shared files respect write permissions. - - Scenario: - 1. Alice creates a file via MCP - 2. Alice shares the file with Charlie (edit permission) via MCP - 3. Alice shares the file with Bob (read-only) via MCP - 4. Charlie can edit the file via MCP tools - 5. Bob cannot edit the file - """ - file_path = "/alice_shared_file_write.txt" - file_content = "This file is shared with Charlie for editing." - - logger.info(f"Alice creating file: {file_path}") - result = await alice_mcp_client.call_tool( - "nc_webdav_write_file", - arguments={"path": file_path, "content": file_content}, - ) - assert not result.isError, f"Alice failed to create file: {result.content}" - - charlie_share_id = None - bob_share_id = None - - try: - # Alice shares with Charlie (read+write, permissions=3) - logger.info("Alice sharing file with Charlie (edit permission)...") - result = await alice_mcp_client.call_tool( - "nc_share_create", - arguments={ - "path": file_path, - "share_with": "charlie", - "share_type": 0, - "permissions": 3, - }, - ) - assert not result.isError, ( - f"Alice failed to share with Charlie: {result.content}" - ) - charlie_share_data = json.loads(result.content[0].text) - charlie_share_id = charlie_share_data["id"] - logger.info(f"Created share {charlie_share_id} for Charlie") - - # Alice shares with Bob (read-only, permissions=1) - logger.info("Alice sharing file with Bob (read-only)...") - result = await alice_mcp_client.call_tool( - "nc_share_create", - arguments={ - "path": file_path, - "share_with": "bob", - "share_type": 0, - "permissions": 1, - }, - ) - assert not result.isError, f"Alice failed to share with Bob: {result.content}" - bob_share_data = json.loads(result.content[0].text) - bob_share_id = bob_share_data["id"] - logger.info(f"Created share {bob_share_id} for Bob") - - # Test: Charlie can write to the file - logger.info("Charlie attempting to write to file via MCP...") - updated_content = f"{file_content}\nCharlie added this line." - result = await charlie_mcp_client.call_tool( - "nc_webdav_write_file", - arguments={"path": file_path, "content": updated_content}, - ) - - if not result.isError: - logger.info("Charlie successfully wrote to file") - else: - logger.warning(f"Charlie could not write to file: {result.content}") - - # Test: Bob attempts to write (should fail) - logger.info("Bob attempting to write to file via MCP...") - result = await bob_mcp_client.call_tool( - "nc_webdav_write_file", - arguments={"path": file_path, "content": "Bob tries to overwrite this."}, - ) - - # Bob should be denied - if result.isError: - logger.info("Bob correctly denied write access") - else: - logger.warning("Bob unexpectedly succeeded in writing (permissions issue?)") - - finally: - # Cleanup - Alice deletes shares and file - if charlie_share_id: - logger.info(f"Alice deleting Charlie's share {charlie_share_id}") - await alice_mcp_client.call_tool( - "nc_share_delete", arguments={"share_id": charlie_share_id} - ) - if bob_share_id: - logger.info(f"Alice deleting Bob's share {bob_share_id}") - await alice_mcp_client.call_tool( - "nc_share_delete", arguments={"share_id": bob_share_id} - ) - logger.info(f"Alice deleting file {file_path}") - await alice_mcp_client.call_tool( - "nc_webdav_delete_resource", arguments={"path": file_path} - ) - - -async def test_file_list_permissions(alice_mcp_client, bob_mcp_client): - """ - Test that file listing respects share permissions. - - Scenario: - 1. Alice creates her private file via MCP - 2. Bob creates his private file via MCP - 3. Alice creates a file and shares it with Bob via MCP - 4. Alice can list her own files + shared files - 5. Bob can list his own files + shared files from Alice - """ - alice_file = "/alice_private_file.txt" - bob_file = "/bob_private_file.txt" - shared_file = "/alice_shared_with_bob.txt" - - # Alice creates her private file - logger.info(f"Alice creating private file: {alice_file}") - result = await alice_mcp_client.call_tool( - "nc_webdav_write_file", - arguments={"path": alice_file, "content": "Alice's private file"}, - ) - assert not result.isError, f"Alice failed to create file: {result.content}" - - # Bob creates his private file - logger.info(f"Bob creating private file: {bob_file}") - result = await bob_mcp_client.call_tool( - "nc_webdav_write_file", - arguments={"path": bob_file, "content": "Bob's private file"}, - ) - assert not result.isError, f"Bob failed to create file: {result.content}" - - # Alice creates a shared file - logger.info(f"Alice creating shared file: {shared_file}") - result = await alice_mcp_client.call_tool( - "nc_webdav_write_file", - arguments={"path": shared_file, "content": "Shared file content"}, - ) - assert not result.isError, f"Alice failed to create shared file: {result.content}" - - share_id = None - - try: - # Alice shares the file with Bob - logger.info("Alice sharing file with Bob...") - result = await alice_mcp_client.call_tool( - "nc_share_create", - arguments={ - "path": shared_file, - "share_with": "bob", - "share_type": 0, - "permissions": 1, - }, - ) - assert not result.isError, f"Alice failed to create share: {result.content}" - share_data = json.loads(result.content[0].text) - share_id = share_data["id"] - - # Test: Alice lists files in root - logger.info("Alice listing files via MCP...") - result = await alice_mcp_client.call_tool( - "nc_webdav_list_directory", arguments={"path": "/"} - ) - - if not result.isError: - response_data = json.loads(result.content[0].text) - # Extract files from DirectoryListing response - files = response_data.get("files", []) - file_names = [f["name"] for f in files] - logger.info(f"Alice can see files: {file_names}") - - # Alice should see her own files - # Note: Exact assertions depend on test isolation - else: - logger.warning(f"Alice could not list files: {result.content}") - - # Test: Bob lists files in root - logger.info("Bob listing files via MCP...") - result = await bob_mcp_client.call_tool( - "nc_webdav_list_directory", arguments={"path": "/"} - ) - - if not result.isError: - response_data = json.loads(result.content[0].text) - # Extract files from DirectoryListing response - files = response_data.get("files", []) - file_names = [f["name"] for f in files] - logger.info(f"Bob can see files: {file_names}") - - # Bob should see his own file, but not Alice's private file - # Bob may see shared files in his shared folder or via different path - else: - logger.warning(f"Bob could not list files: {result.content}") - - finally: - # Cleanup - if share_id: - logger.info(f"Alice deleting share {share_id}") - await alice_mcp_client.call_tool( - "nc_share_delete", arguments={"share_id": share_id} - ) - - logger.info("Cleaning up Alice's files...") - await alice_mcp_client.call_tool( - "nc_webdav_delete_resource", arguments={"path": alice_file} - ) - await alice_mcp_client.call_tool( - "nc_webdav_delete_resource", arguments={"path": shared_file} - ) - - logger.info("Cleaning up Bob's files...") - await bob_mcp_client.call_tool( - "nc_webdav_delete_resource", arguments={"path": bob_file} - ) - - -async def test_folder_share_permissions(alice_mcp_client, bob_mcp_client): - """ - Test that folder sharing works correctly. - - Scenario: - 1. Alice creates a folder via MCP - 2. Alice creates files in the folder via MCP - 3. Alice shares the folder with Bob via MCP - 4. Bob can access files in the shared folder via MCP - """ - folder_path = "/alice_shared_folder" - file_in_folder = f"{folder_path}/document.txt" - file_content = "This is a document in Alice's shared folder" - - # Alice creates folder - logger.info(f"Alice creating folder: {folder_path}") - result = await alice_mcp_client.call_tool( - "nc_webdav_create_directory", arguments={"path": folder_path} - ) - assert not result.isError, f"Alice failed to create folder: {result.content}" - - # Alice creates file in folder - logger.info(f"Alice creating file in folder: {file_in_folder}") - result = await alice_mcp_client.call_tool( - "nc_webdav_write_file", - arguments={"path": file_in_folder, "content": file_content}, - ) - assert not result.isError, f"Alice failed to create file: {result.content}" - - share_id = None - - try: - # Alice shares the folder with Bob - logger.info("Alice sharing folder with Bob...") - result = await alice_mcp_client.call_tool( - "nc_share_create", - arguments={ - "path": folder_path, - "share_with": "bob", - "share_type": 0, - "permissions": 1, - }, - ) - assert not result.isError, f"Alice failed to create share: {result.content}" - share_data = json.loads(result.content[0].text) - share_id = share_data["id"] - logger.info(f"Created folder share {share_id}") - - # Test: Bob lists the shared folder - logger.info("Bob attempting to list shared folder via MCP...") - result = await bob_mcp_client.call_tool( - "nc_webdav_list_directory", arguments={"path": folder_path} - ) - - if not result.isError: - response_data = json.loads(result.content[0].text) - # Extract files from DirectoryListing response - files = response_data.get("files", []) - logger.info(f"Bob can see {len(files)} files in shared folder") - - # Bob should see the file in the shared folder - file_names = [f["name"] for f in files] - assert "document.txt" in file_names, ( - "Bob should see the file in shared folder" - ) - else: - logger.warning(f"Bob could not list shared folder: {result.content}") - - # Test: Bob reads the file in the shared folder - logger.info("Bob attempting to read file in shared folder via MCP...") - result = await bob_mcp_client.call_tool( - "nc_webdav_read_file", arguments={"path": file_in_folder} - ) - - if not result.isError: - response_data = json.loads(result.content[0].text) - logger.info("Bob successfully read file in shared folder") - assert "content" in response_data - assert file_content in response_data["content"] - else: - logger.warning( - f"Bob could not read file in shared folder: {result.content}" - ) - - finally: - # Cleanup - Alice deletes the share and folder - if share_id: - logger.info(f"Alice deleting share {share_id}") - await alice_mcp_client.call_tool( - "nc_share_delete", arguments={"share_id": share_id} - ) - - logger.info("Alice cleaning up test folder...") - await alice_mcp_client.call_tool( - "nc_webdav_delete_resource", arguments={"path": folder_path} - ) diff --git a/tests/server/oauth/test_oauth_notes_permissions.py b/tests/server/oauth/test_oauth_notes_permissions.py deleted file mode 100644 index 524a5d2a..00000000 --- a/tests/server/oauth/test_oauth_notes_permissions.py +++ /dev/null @@ -1,256 +0,0 @@ -""" -Multi-user OAuth tests for Nextcloud Notes permissions. - -Tests verify that the MCP server respects Nextcloud Notes sharing permissions -when accessed via OAuth authentication with different users. -""" - -import json -import logging - -import pytest - -logger = logging.getLogger(__name__) - -pytestmark = [pytest.mark.integration, pytest.mark.oauth] - - -async def test_notes_share_read_permissions( - nc_client, alice_mcp_client, bob_mcp_client, diana_mcp_client -): - """ - Test that shared notes respect read permissions. - - Scenario: - 1. Admin creates a note as alice - 2. Admin shares the note with bob (read-only) - 3. Bob can read the note via MCP tools - 4. Diana cannot access the note (no share) - """ - # Create a note as alice (using admin client to set up data) - note_title = "Alice's Shared Note - Read Test" - note_content = "This note is shared with Bob for reading only." - note_category = "SharedNotes" - - logger.info("Creating note as alice...") - created_note = await nc_client.notes.create_note( - title=note_title, content=note_content, category=note_category - ) - note_id = created_note.get("id") - - try: - # TODO: Share the note with bob (read-only) - # Note: Nextcloud Notes API doesn't have direct sharing endpoints - # Sharing is typically done at the folder level via WebDAV - # For now, this test documents the expected behavior - - # Test: Bob searches for notes via MCP - logger.info("Bob searching for notes via MCP...") - result = await bob_mcp_client.call_tool( - "nc_notes_search_notes", arguments={"query": "Alice's Shared"} - ) - - assert result.isError is False, f"Bob's search failed: {result.content}" - response_data = json.loads(result.content[0].text) - - # Bob should see the shared note in search results - # (assuming proper share setup) - assert "results" in response_data - logger.info(f"Bob found {len(response_data['results'])} notes") - - # Test: Diana searches for the same note - logger.info("Diana searching for notes via MCP...") - result = await diana_mcp_client.call_tool( - "nc_notes_search_notes", arguments={"query": "Alice's Shared"} - ) - - assert result.isError is False - response_data = json.loads(result.content[0].text) - - # Diana should NOT see the note (no share) - assert "results" in response_data - shared_note_ids = [ - n["id"] for n in response_data["results"] if n["id"] == note_id - ] - assert len(shared_note_ids) == 0, "Diana should not see unshared note" - logger.info("Diana correctly cannot see unshared note") - - finally: - # Cleanup - logger.info(f"Cleaning up note {note_id}") - await nc_client.notes.delete_note(note_id) - - -async def test_notes_share_write_permissions( - nc_client, alice_mcp_client, charlie_mcp_client, bob_mcp_client -): - """ - Test that shared notes respect write permissions. - - Scenario: - 1. Admin creates a note as alice - 2. Admin shares the note with charlie (edit permission) - 3. Admin shares the note with bob (read-only) - 4. Charlie can edit the note via MCP tools - 5. Bob cannot edit the note - """ - # Create a note as alice - note_title = "Alice's Shared Note - Write Test" - note_content = "This note is shared with Charlie for editing." - note_category = "SharedNotes" - - logger.info("Creating note as alice...") - created_note = await nc_client.notes.create_note( - title=note_title, content=note_content, category=note_category - ) - note_id = created_note.get("id") - - try: - # TODO: Share the note with charlie (edit permission) and bob (read-only) - # Note: Nextcloud Notes sharing is folder-based - - # Test: Charlie can append content to the note - logger.info("Charlie attempting to append content via MCP...") - result = await charlie_mcp_client.call_tool( - "nc_notes_append_content", - arguments={ - "note_id": note_id, - "content": "\n\nCharlie added this content.", - }, - ) - - # If sharing is properly configured, Charlie should succeed - # Without proper sharing setup, this will fail - logger.info(f"Charlie's append result: isError={result.isError}") - if not result.isError: - logger.info("Charlie successfully appended content (shares configured)") - else: - logger.warning("Charlie could not append (shares not yet configured)") - - # Test: Bob attempts to append content (should fail) - logger.info("Bob attempting to append content via MCP...") - result = await bob_mcp_client.call_tool( - "nc_notes_append_content", - arguments={"note_id": note_id, "content": "\n\nBob tried to add this."}, - ) - - # Bob should fail (read-only access) - logger.info(f"Bob's append result: isError={result.isError}") - if result.isError: - logger.info("Bob correctly denied write access") - else: - logger.warning("Bob unexpectedly succeeded (permissions issue?)") - - finally: - # Cleanup - logger.info(f"Cleaning up note {note_id}") - await nc_client.notes.delete_note(note_id) - - -async def test_user_isolation_notes(nc_client, alice_mcp_client, bob_mcp_client): - """ - Test that users can only see their own notes when not shared. - - Scenario: - 1. Admin creates a note as alice (not shared) - 2. Admin creates a note as bob (not shared) - 3. Alice can only see her own note - 4. Bob can only see his own note - """ - # Create alice's note - logger.info("Creating alice's private note...") - alice_note = await nc_client.notes.create_note( - title="Alice's Private Note", - content="This is Alice's private content.", - category="AlicePrivate", - ) - alice_note_id = alice_note.get("id") - - # Create bob's note - logger.info("Creating bob's private note...") - bob_note = await nc_client.notes.create_note( - title="Bob's Private Note", - content="This is Bob's private content.", - category="BobPrivate", - ) - bob_note_id = bob_note.get("id") - - try: - # Test: Alice searches all notes - logger.info("Alice searching all notes via MCP...") - result = await alice_mcp_client.call_tool( - "nc_notes_search_notes", arguments={"query": ""} - ) - - assert result.isError is False - response_data = json.loads(result.content[0].text) - alice_notes = response_data.get("results", []) - alice_note_ids = [n["id"] for n in alice_notes] - - logger.info(f"Alice can see {len(alice_notes)} notes") - # Alice should NOT see Bob's note - assert bob_note_id not in alice_note_ids, ( - "Alice should not see Bob's private note" - ) - - # Test: Bob searches all notes - logger.info("Bob searching all notes via MCP...") - result = await bob_mcp_client.call_tool( - "nc_notes_search_notes", arguments={"query": ""} - ) - - assert result.isError is False - response_data = json.loads(result.content[0].text) - bob_notes = response_data.get("results", []) - bob_note_ids = [n["id"] for n in bob_notes] - - logger.info(f"Bob can see {len(bob_notes)} notes") - # Bob should NOT see Alice's note - assert alice_note_id not in bob_note_ids, ( - "Bob should not see Alice's private note" - ) - - logger.info("User isolation test passed: users can only see their own notes") - - finally: - # Cleanup - logger.info("Cleaning up test notes...") - await nc_client.notes.delete_note(alice_note_id) - await nc_client.notes.delete_note(bob_note_id) - - -async def test_oauth_mcp_clients_initialized( - alice_mcp_client, bob_mcp_client, charlie_mcp_client, diana_mcp_client -): - """ - Smoke test to verify all OAuth MCP clients are properly initialized. - """ - logger.info("Testing alice_mcp_client initialization...") - result = await alice_mcp_client.call_tool( - "nc_notes_search_notes", arguments={"query": ""} - ) - assert result.isError is False, f"Alice MCP client failed: {result.content}" - logger.info("Alice MCP client working") - - logger.info("Testing bob_mcp_client initialization...") - result = await bob_mcp_client.call_tool( - "nc_notes_search_notes", arguments={"query": ""} - ) - assert result.isError is False, f"Bob MCP client failed: {result.content}" - logger.info("Bob MCP client working") - - logger.info("Testing charlie_mcp_client initialization...") - result = await charlie_mcp_client.call_tool( - "nc_notes_search_notes", arguments={"query": ""} - ) - assert result.isError is False, f"Charlie MCP client failed: {result.content}" - logger.info("Charlie MCP client working") - - logger.info("Testing diana_mcp_client initialization...") - result = await diana_mcp_client.call_tool( - "nc_notes_search_notes", arguments={"query": ""} - ) - assert result.isError is False, f"Diana MCP client failed: {result.content}" - logger.info("Diana MCP client working") - - logger.info("All OAuth MCP clients successfully initialized!") diff --git a/tests/server/oauth/test_token_exchange.py b/tests/server/oauth/test_token_exchange.py deleted file mode 100644 index 8f1704e5..00000000 --- a/tests/server/oauth/test_token_exchange.py +++ /dev/null @@ -1,436 +0,0 @@ -"""Unit tests for RFC 8693 Token Exchange (ADR-004). - -Tests the critical token exchange pattern that separates: -- Session tokens (ephemeral, on-demand) -- Background tokens (stored refresh tokens) -""" - -import os -import tempfile -from unittest.mock import AsyncMock, MagicMock, patch - -import jwt -import pytest -from cryptography.fernet import Fernet - -from nextcloud_mcp_server.auth.storage import RefreshTokenStorage -from nextcloud_mcp_server.auth.token_broker import TokenBrokerService -from nextcloud_mcp_server.auth.token_exchange import TokenExchangeService - -pytestmark = pytest.mark.unit - - -@pytest.fixture -async def token_storage(): - """Create test token storage.""" - - # Generate valid Fernet key - encryption_key = Fernet.generate_key() - - # Create temporary database file - with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: - db_path = tmp.name - - storage = RefreshTokenStorage(db_path=db_path, encryption_key=encryption_key) - await storage.initialize() - - # Expose encryption key for tests that need to manually encrypt/decrypt - storage._test_encryption_key = encryption_key - - yield storage - - # Cleanup - if os.path.exists(db_path): - os.unlink(db_path) - - -@pytest.fixture -async def token_exchange_service(token_storage): - """Create test token exchange service.""" - service = TokenExchangeService( - oidc_discovery_url="http://test-idp/.well-known/openid-configuration", - client_id="test-client", - client_secret="test-secret", - nextcloud_host="http://test-nextcloud", - ) - service.storage = token_storage - yield service - await service.http_client.aclose() - - -@pytest.fixture -async def token_broker(token_storage): - """Create test token broker service.""" - broker = TokenBrokerService( - storage=token_storage, - oidc_discovery_url="http://test-idp/.well-known/openid-configuration", - nextcloud_host="http://test-nextcloud", - client_id="test-client", - client_secret="test-secret", - cache_ttl=300, - cache_early_refresh=30, - ) - yield broker - await broker.close() - - -def create_test_jwt( - user_id: str = "testuser", audience: str = "mcp-server", expires_in: int = 3600 -) -> str: - """Create a test JWT token.""" - import time - - payload = { - "sub": user_id, - "aud": audience, - "exp": int(time.time()) + expires_in, - "iat": int(time.time()), - "iss": "http://test-idp", - } - - # For testing, we don't sign the token (uses 'none' algorithm) - # In production, tokens would be properly signed - return jwt.encode(payload, "", algorithm="none") - - -class TestTokenExchange: - """Test RFC 8693 token exchange implementation.""" - - async def test_validate_flow1_token_success(self, token_exchange_service): - """Test validation of Flow 1 token with correct audience.""" - # Create token with correct audience - flow1_token = create_test_jwt(audience="mcp-server") - - # Should not raise an exception - await token_exchange_service._validate_flow1_token(flow1_token) - - async def test_validate_flow1_token_wrong_audience(self, token_exchange_service): - """Test validation fails with wrong audience.""" - # Create token with wrong audience - flow1_token = create_test_jwt(audience="nextcloud") - - with pytest.raises(ValueError, match="Invalid token audience"): - await token_exchange_service._validate_flow1_token(flow1_token) - - async def test_validate_flow1_token_expired(self, token_exchange_service): - """Test validation fails with expired token.""" - # Create expired token - flow1_token = create_test_jwt(audience="mcp-server", expires_in=-3600) - - with pytest.raises(ValueError, match="Token has expired"): - await token_exchange_service._validate_flow1_token(flow1_token) - - async def test_extract_user_id(self, token_exchange_service): - """Test extraction of user ID from token.""" - flow1_token = create_test_jwt(user_id="alice") - - user_id = token_exchange_service._extract_user_id(flow1_token) - assert user_id == "alice" - - async def test_check_provisioning_not_provisioned(self, token_exchange_service): - """Test provisioning check when user not provisioned.""" - result = await token_exchange_service._check_provisioning("unknown_user") - assert result is False - - async def test_check_provisioning_is_provisioned( - self, token_exchange_service, token_storage - ): - """Test provisioning check when user is provisioned.""" - # Store a refresh token for user - await token_storage.store_refresh_token( - user_id="alice", refresh_token="encrypted_refresh_token", flow_type="flow2" - ) - - result = await token_exchange_service._check_provisioning("alice") - assert result is True - - async def test_exchange_token_not_provisioned(self, token_exchange_service): - """Test token exchange fails when user not provisioned.""" - flow1_token = create_test_jwt(user_id="unprovisioneduser") - - with pytest.raises(RuntimeError, match="Nextcloud access not provisioned"): - await token_exchange_service.exchange_token_for_delegation( - flow1_token=flow1_token, - requested_scopes=["notes:read"], - requested_audience="nextcloud", - ) - - async def test_exchange_token_with_fallback( - self, token_exchange_service, token_storage - ): - """Test token exchange with refresh grant fallback.""" - # Store a refresh token for user - await token_storage.store_refresh_token( - user_id="alice", refresh_token="test_refresh_token", flow_type="flow2" - ) - - # Create Flow 1 token - flow1_token = create_test_jwt(user_id="alice", audience="mcp-server") - - # Mock HTTP client for token endpoint - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "access_token": "delegated_token_12345", - "token_type": "Bearer", - "expires_in": 300, # 5 minutes - } - - with patch.object( - token_exchange_service.http_client, "post", return_value=mock_response - ): - # Mock discovery endpoint - with patch.object( - token_exchange_service, - "_discover_endpoints", - return_value={"token_endpoint": "http://test-idp/token"}, - ): - # Perform exchange - ( - token, - expires_in, - ) = await token_exchange_service.exchange_token_for_delegation( - flow1_token=flow1_token, - requested_scopes=["notes:read"], - requested_audience="nextcloud", - ) - - assert token == "delegated_token_12345" - assert expires_in == 300 - - -class TestTokenBroker: - """Test Token Broker session/background separation.""" - - async def test_get_session_token(self, token_broker, token_storage): - """Test getting ephemeral session token via exchange.""" - # Store refresh token for user - await token_storage.store_refresh_token( - user_id="alice", refresh_token="test_refresh_token", flow_type="flow2" - ) - - # Create Flow 1 token - flow1_token = create_test_jwt(user_id="alice", audience="mcp-server") - - # Mock token exchange - with patch( - "nextcloud_mcp_server.auth.token_broker.exchange_token_for_delegation", - return_value=("ephemeral_token_xyz", 300), - ): - token = await token_broker.get_session_token( - flow1_token=flow1_token, - required_scopes=["notes:read"], - requested_audience="nextcloud", - ) - - assert token == "ephemeral_token_xyz" - - # Verify token is NOT cached (ephemeral) - cached = await token_broker.cache.get("alice") - assert cached is None # Should not be in cache - - async def test_get_background_token(self, token_broker, token_storage): - """Test getting background token with stored refresh.""" - # Store encrypted refresh token for user - from cryptography.fernet import Fernet - - # Use the same encryption key as token_storage/token_broker - fernet = Fernet(token_storage._test_encryption_key) - encrypted_token = fernet.encrypt(b"background_refresh_token").decode() - - await token_storage.store_refresh_token( - user_id="alice", refresh_token=encrypted_token, flow_type="flow2" - ) - - # Mock OIDC config and token response - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "access_token": "background_token_abc", - "token_type": "Bearer", - "expires_in": 3600, # 1 hour - } - - with patch.object( - token_broker, - "_get_oidc_config", - return_value={"token_endpoint": "http://test/token"}, - ): - with patch.object(token_broker, "_get_http_client") as mock_client: - mock_client.return_value.post = AsyncMock(return_value=mock_response) - - # Mock audience validation - with patch.object( - token_broker, "_validate_token_audience", return_value=None - ): - token = await token_broker.get_background_token( - user_id="alice", required_scopes=["notes:sync", "files:sync"] - ) - - assert token == "background_token_abc" - - # Verify token IS cached (background tokens can be cached) - cache_key = "alice:background:files:sync,notes:sync" - cached = await token_broker.cache.get(cache_key) - assert cached == "background_token_abc" - - async def test_session_background_separation(self, token_broker, token_storage): - """Test that session and background tokens are kept separate.""" - # Store refresh token - from cryptography.fernet import Fernet - - # Use the same encryption key as token_storage/token_broker - fernet = Fernet(token_storage._test_encryption_key) - encrypted_token = fernet.encrypt(b"master_refresh_token").decode() - - await token_storage.store_refresh_token( - user_id="alice", refresh_token=encrypted_token, flow_type="flow2" - ) - - flow1_token = create_test_jwt(user_id="alice", audience="mcp-server") - - # Mock different tokens for session vs background - session_token = "ephemeral_session_123" - background_token = "cached_background_456" - - # Get session token - with patch( - "nextcloud_mcp_server.auth.token_broker.exchange_token_for_delegation", - return_value=(session_token, 300), - ): - session_result = await token_broker.get_session_token( - flow1_token=flow1_token, required_scopes=["notes:read"] - ) - assert session_result == session_token - - # Get background token - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "access_token": background_token, - "expires_in": 3600, - } - - with patch.object( - token_broker, - "_get_oidc_config", - return_value={"token_endpoint": "http://test/token"}, - ): - with patch.object(token_broker, "_get_http_client") as mock_client: - mock_client.return_value.post = AsyncMock(return_value=mock_response) - with patch.object( - token_broker, "_validate_token_audience", return_value=None - ): - background_result = await token_broker.get_background_token( - user_id="alice", required_scopes=["notes:sync"] - ) - assert background_result == background_token - - # Verify they are different tokens - assert session_result != background_result - - # Verify session token not cached - assert await token_broker.cache.get("alice") is None - - # Verify background token IS cached - cache_key = "alice:background:notes:sync" - assert await token_broker.cache.get(cache_key) == background_token - - -class TestScopeDownscoping: - """Test that tokens request only necessary scopes.""" - - async def test_session_token_minimal_scopes( - self, token_exchange_service, token_storage - ): - """Test session tokens request minimal scopes.""" - # Store refresh token - await token_storage.store_refresh_token( - user_id="alice", refresh_token="test_refresh_token", flow_type="flow2" - ) - - flow1_token = create_test_jwt(user_id="alice", audience="mcp-server") - - # Track what scopes are requested - requested_scopes = None - - async def mock_post(url, data, headers=None): - nonlocal requested_scopes - requested_scopes = data.get("scope", "").split() - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "access_token": "scoped_token", - "expires_in": 300, - } - return mock_response - - with patch.object( - token_exchange_service.http_client, "post", side_effect=mock_post - ): - with patch.object( - token_exchange_service, - "_discover_endpoints", - return_value={"token_endpoint": "http://test/token"}, - ): - await token_exchange_service.exchange_token_for_delegation( - flow1_token=flow1_token, - requested_scopes=["notes:read"], # Only read scope - requested_audience="nextcloud", - ) - - # Verify only requested scope was included - assert "notes:read" in requested_scopes - assert "notes:write" not in requested_scopes - assert "calendar:write" not in requested_scopes - - async def test_background_token_different_scopes(self, token_broker, token_storage): - """Test background tokens can request different scopes than session.""" - from cryptography.fernet import Fernet - - # Use the same encryption key as token_storage/token_broker - fernet = Fernet(token_storage._test_encryption_key) - encrypted_token = fernet.encrypt(b"refresh_token").decode() - - await token_storage.store_refresh_token( - user_id="alice", refresh_token=encrypted_token, flow_type="flow2" - ) - - # Track requested scopes - requested_scopes = None - - async def mock_post(url, data, headers=None): - nonlocal requested_scopes - requested_scopes = data.get("scope", "").split() - - mock_response = MagicMock() - mock_response.status_code = 200 - mock_response.json.return_value = { - "access_token": "background_sync_token", - "expires_in": 3600, - } - return mock_response - - with patch.object( - token_broker, - "_get_oidc_config", - return_value={"token_endpoint": "http://test/token"}, - ): - with patch.object(token_broker, "_get_http_client") as mock_client: - mock_client.return_value.post = mock_post - with patch.object( - token_broker, "_validate_token_audience", return_value=None - ): - await token_broker.get_background_token( - user_id="alice", - required_scopes=["notes:sync", "files:sync", "calendar:sync"], - ) - - # Verify sync scopes were requested - assert "notes:sync" in requested_scopes - assert "files:sync" in requested_scopes - assert "calendar:sync" in requested_scopes - # Basic OIDC scopes should also be included - assert "openid" in requested_scopes - assert "profile" in requested_scopes diff --git a/tests/smoke/test_smoke.py b/tests/smoke/test_smoke.py index 62f28533..3a118cb2 100644 --- a/tests/smoke/test_smoke.py +++ b/tests/smoke/test_smoke.py @@ -102,19 +102,3 @@ async def test_webdav_basic_smoke(nc_mcp_client): data = json.loads(result.content[0].text) assert "files" in data assert isinstance(data["files"], list) - - -@pytest.mark.oauth -async def test_oauth_connectivity_smoke(nc_mcp_oauth_client): - """Smoke test: Verify OAuth authentication works.""" - # List tools with OAuth - result = await nc_mcp_oauth_client.list_tools() - assert result is not None - assert len(result.tools) > 0 - - # Execute a simple tool - search_result = await nc_mcp_oauth_client.call_tool( - "nc_notes_search_notes", - arguments={"query": ""}, - ) - assert search_result.isError is False diff --git a/tests/unit/test_config_validators.py b/tests/unit/test_config_validators.py index a31bda70..ad242c14 100644 --- a/tests/unit/test_config_validators.py +++ b/tests/unit/test_config_validators.py @@ -22,16 +22,6 @@ from nextcloud_mcp_server.config_validators import ( class TestModeDetection: """Test auth mode detection from configuration.""" - def test_token_exchange_mode_detection(self): - """Test token exchange mode is detected.""" - settings = Settings( - nextcloud_host="http://localhost", - enable_token_exchange=True, - ) - - mode = detect_auth_mode(settings) - assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE - def test_multi_user_basic_mode_detection(self): """Test multi-user BasicAuth mode is detected.""" settings = Settings( @@ -62,18 +52,6 @@ class TestModeDetection: mode = detect_auth_mode(settings) assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE - def test_mode_priority_token_exchange_over_basic(self): - """Test token exchange has priority over BasicAuth.""" - settings = Settings( - nextcloud_host="http://localhost", - nextcloud_username="admin", - nextcloud_password="password", - enable_token_exchange=True, - ) - - mode = detect_auth_mode(settings) - assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE - class TestSingleUserBasicValidation: """Test validation for single-user BasicAuth mode.""" @@ -165,21 +143,6 @@ class TestSingleUserBasicValidation: # It will fail multi-user validation because username/password are forbidden assert len(errors) > 0 - def test_forbidden_token_exchange(self): - """Test error when ENABLE_TOKEN_EXCHANGE is set.""" - settings = Settings( - nextcloud_host="http://localhost", - nextcloud_username="admin", - nextcloud_password="password", - enable_token_exchange=True, - ) - - # Note: This will detect as OAUTH_TOKEN_EXCHANGE due to priority - mode, errors = validate_configuration(settings) - - assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE - # It will fail OAuth validation - def test_vector_sync_without_embedding_provider_uses_fallback(self): """Test that vector sync works with Simple provider fallback (no config needed).""" settings = Settings( @@ -419,51 +382,6 @@ class TestOAuthSingleAudienceValidation: assert settings.enable_offline_access is True -class TestOAuthTokenExchangeValidation: - """Test validation for OAuth token exchange mode.""" - - def test_valid_minimal_config(self): - """Test valid minimal OAuth token exchange config.""" - settings = Settings( - nextcloud_host="http://localhost", - enable_token_exchange=True, - ) - - mode, errors = validate_configuration(settings) - - assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE - assert len(errors) == 0 - - def test_valid_with_credentials(self): - """Test valid config with OAuth credentials.""" - settings = Settings( - nextcloud_host="http://localhost", - enable_token_exchange=True, - oidc_client_id="test-client", - oidc_client_secret="test-secret", - ) - - mode, errors = validate_configuration(settings) - - assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE - assert len(errors) == 0 - - def test_forbidden_username_password(self): - """Test error when username/password are set.""" - settings = Settings( - nextcloud_host="http://localhost", - enable_token_exchange=True, - nextcloud_username="admin", - nextcloud_password="password", - ) - - mode, errors = validate_configuration(settings) - - assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE - assert any("nextcloud_username" in err.lower() for err in errors) - assert any("nextcloud_password" in err.lower() for err in errors) - - class TestModeSummary: """Test mode summary generation.""" @@ -477,14 +395,6 @@ class TestModeSummary: assert "NEXTCLOUD_PASSWORD" in summary assert "VECTOR_SYNC_ENABLED" in summary - def test_oauth_token_exchange_summary(self): - """Test summary for OAuth token exchange mode.""" - summary = get_mode_summary(AuthMode.OAUTH_TOKEN_EXCHANGE) - - assert "oauth_exchange" in summary - assert "ENABLE_TOKEN_EXCHANGE" in summary - assert "RFC 8693" in summary - class TestEdgeCases: """Test edge cases and boundary conditions.""" @@ -800,23 +710,6 @@ class TestExplicitModeSelection: assert mode == AuthMode.OAUTH_SINGLE_AUDIENCE - def test_explicit_oauth_token_exchange_mode(self): - """Test explicit oauth_token_exchange mode selection.""" - with patch.dict( - os.environ, - { - "NEXTCLOUD_HOST": "http://localhost:8080", - "MCP_DEPLOYMENT_MODE": "oauth_token_exchange", - }, - clear=True, - ): - from nextcloud_mcp_server.config import get_settings - - settings = get_settings() - mode = detect_auth_mode(settings) - - assert mode == AuthMode.OAUTH_TOKEN_EXCHANGE - def test_invalid_deployment_mode_raises_error(self): """Test invalid MCP_DEPLOYMENT_MODE raises ValueError.""" with patch.dict( diff --git a/tests/unit/test_management_status_endpoint.py b/tests/unit/test_management_status_endpoint.py index ac6ad1a6..f6ea5f40 100644 --- a/tests/unit/test_management_status_endpoint.py +++ b/tests/unit/test_management_status_endpoint.py @@ -37,7 +37,6 @@ def create_mock_settings( oidc_issuer: str | None = None, vector_sync_enabled: bool = False, nextcloud_url: str = "http://localhost", - enable_token_exchange: bool = False, mcp_client_id: str | None = None, mcp_client_secret: str | None = None, ): @@ -49,7 +48,6 @@ def create_mock_settings( settings.oidc_issuer = oidc_issuer settings.vector_sync_enabled = vector_sync_enabled settings.nextcloud_url = nextcloud_url - settings.enable_token_exchange = enable_token_exchange settings.mcp_client_id = mcp_client_id settings.mcp_client_secret = mcp_client_secret return settings diff --git a/tests/unit/test_unified_verifier.py b/tests/unit/test_unified_verifier.py index 491ee0f2..7747bbde 100644 --- a/tests/unit/test_unified_verifier.py +++ b/tests/unit/test_unified_verifier.py @@ -29,18 +29,9 @@ def base_settings(): nextcloud_resource_uri="http://localhost:8080", jwks_uri="https://idp.example.com/jwks", introspection_uri="https://idp.example.com/introspect", - enable_token_exchange=False, # Multi-audience mode - token_exchange_cache_ttl=300, ) -@pytest.fixture -def exchange_settings(base_settings): - """Create settings for token exchange mode.""" - base_settings.enable_token_exchange = True - return base_settings - - class TestUnifiedTokenVerifierInit: """Test UnifiedTokenVerifier initialization.""" @@ -50,11 +41,11 @@ class TestUnifiedTokenVerifierInit: assert verifier.mode == "multi-audience" assert verifier.settings == base_settings - def test_init_exchange_mode(self, exchange_settings): - """Test verifier initialization in token exchange mode.""" - verifier = UnifiedTokenVerifier(exchange_settings) - assert verifier.mode == "exchange" - assert verifier.settings == exchange_settings + def test_init_always_multi_audience(self, base_settings): + """Test verifier always initializes in multi-audience mode.""" + verifier = UnifiedTokenVerifier(base_settings) + assert verifier.mode == "multi-audience" + assert verifier.settings == base_settings class TestAudienceValidation: @@ -117,9 +108,9 @@ class TestAudienceValidation: # Should pass - we only validate MCP audience per RFC 7519 assert verifier._has_mcp_audience(payload) is True - def test_has_mcp_audience_with_client_id(self, exchange_settings): + def test_has_mcp_audience_with_client_id(self, base_settings): """Test MCP audience validation with client ID.""" - verifier = UnifiedTokenVerifier(exchange_settings) + verifier = UnifiedTokenVerifier(base_settings) payload = { "aud": ["test-client-id"], "sub": "testuser", @@ -128,9 +119,9 @@ class TestAudienceValidation: assert verifier._has_mcp_audience(payload) is True - def test_has_mcp_audience_with_server_url(self, exchange_settings): + def test_has_mcp_audience_with_server_url(self, base_settings): """Test MCP audience validation with server URL.""" - verifier = UnifiedTokenVerifier(exchange_settings) + verifier = UnifiedTokenVerifier(base_settings) payload = { "aud": ["http://localhost:8000"], "sub": "testuser", @@ -139,9 +130,9 @@ class TestAudienceValidation: assert verifier._has_mcp_audience(payload) is True - def test_has_mcp_audience_missing(self, exchange_settings): + def test_has_mcp_audience_missing(self, base_settings): """Test MCP audience validation fails without MCP audience.""" - verifier = UnifiedTokenVerifier(exchange_settings) + verifier = UnifiedTokenVerifier(base_settings) payload = { "aud": ["http://localhost:8080"], # Wrong audience "sub": "testuser", @@ -292,12 +283,12 @@ class TestMultiAudienceVerification: assert result.resource == "testuser" -class TestExchangeModeVerification: - """Test token exchange mode verification.""" +class TestMcpAudienceVerification: + """Test MCP audience verification.""" - async def test_verify_mcp_audience_only_success(self, exchange_settings): + async def test_verify_mcp_audience_only_success(self, base_settings): """Test MCP-only audience verification succeeds with MCP audience.""" - verifier = UnifiedTokenVerifier(exchange_settings) + verifier = UnifiedTokenVerifier(base_settings) # Mock introspection response with MCP audience only introspection_response = { @@ -318,9 +309,9 @@ class TestExchangeModeVerification: assert result is not None assert result.resource == "testuser" - async def test_verify_mcp_audience_only_fails_without_mcp(self, exchange_settings): + async def test_verify_mcp_audience_only_fails_without_mcp(self, base_settings): """Test MCP audience verification fails without MCP audience.""" - verifier = UnifiedTokenVerifier(exchange_settings) + verifier = UnifiedTokenVerifier(base_settings) # Mock introspection response without MCP audience introspection_response = { @@ -503,9 +494,9 @@ class TestVerifyTokenFlow: assert result is not None assert result.resource == "testuser" - async def test_verify_token_exchange_mode(self, exchange_settings): - """Test verify_token in exchange mode.""" - verifier = UnifiedTokenVerifier(exchange_settings) + async def test_verify_token_mcp_audience_only(self, base_settings): + """Test verify_token with MCP audience only.""" + verifier = UnifiedTokenVerifier(base_settings) introspection_response = { "active": True,