diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index defc5a9..45d35dc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,25 @@ jobs: steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + submodules: 'true' + + + ###### Required to build OIDC App ###### + + - name: Set up php 8.4 + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + coverage: none + + - name: Install OIDC app composer dependencies + run: | + cd third_party/oidc + composer install --no-dev + + ###### Required to build OIDC App ###### + - name: Run docker compose uses: hoverkraft-tech/compose-action@3846bcd61da338e9eaaf83e7ed0234a12b099b72 # v2.4.1 diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..e70e53a --- /dev/null +++ b/.gitmodules @@ -0,0 +1,6 @@ +[submodule "oidc"] + path = third_party/oidc + url = https://github.com/cbcoutinho/oidc +[submodule "third_party/oidc"] + path = third_party/oidc + url = https://github.com/cbcoutinho/oidc diff --git a/CLAUDE.md b/CLAUDE.md index 0d42db0..2ceb3b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -252,3 +252,15 @@ uv run pytest tests/server/test_oauth*.py -v - **`pyproject.toml`** - Python project configuration using uv for dependency management - **`.env`** (from `env.sample`) - Environment variables for Nextcloud connection - **`docker-compose.yml`** - Complete development environment with Nextcloud + database + +## Integration testing with docker + +### Nextcloud + +- The `app` container is running nextcloud. +- Use `docker compose exec app php occ ...` to get a list of available commands + +### Mariadb + +- The `db` container is running mariadb +- Use `docker compose exec db mariadb -u [user] -p [password] [database]` to execute queries. Check the docker-compose file for credentials diff --git a/app-hooks/post-installation/10-install-oidc-app.sh b/app-hooks/post-installation/10-install-oidc-app.sh index 9049917..805fb65 100755 --- a/app-hooks/post-installation/10-install-oidc-app.sh +++ b/app-hooks/post-installation/10-install-oidc-app.sh @@ -4,8 +4,31 @@ set -euox pipefail echo "Installing and configuring OIDC app for testing..." -# Enable the OIDC Identity Provider app -php /var/www/html/occ app:enable oidc +# Check if development OIDC app is mounted at /opt/apps/oidc +if [ -d /opt/apps/oidc ]; then + echo "Development OIDC app found at /opt/apps/oidc" + + # Remove any existing OIDC app in custom_apps (from app store or old symlink) + if [ -e /var/www/html/custom_apps/oidc ]; then + echo "Removing existing OIDC in custom_apps..." + rm -rf /var/www/html/custom_apps/oidc + fi + + # Create symlink from custom_apps to the mounted development version + # Per Nextcloud docs: apps outside server root need symlinks in server root + echo "Creating symlink: custom_apps/oidc -> /opt/apps/oidc" + ln -sf /opt/apps/oidc /var/www/html/custom_apps/oidc + + echo "Enabling OIDC app from /opt/apps (development mode via symlink)" + php /var/www/html/occ app:enable oidc +elif [ -d /var/www/html/custom_apps/oidc ]; then + echo "OIDC app directory found in custom_apps (already installed)" + php /var/www/html/occ app:enable oidc +else + echo "OIDC app not found, installing from app store..." + php /var/www/html/occ app:install oidc + php /var/www/html/occ app:enable oidc +fi # Configure OIDC Identity Provider with dynamic client registration enabled php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true' diff --git a/docker-compose.yml b/docker-compose.yml index 9d37f85..95f020d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -31,6 +31,9 @@ services: volumes: - nextcloud:/var/www/html - ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro + # Mount OIDC development directory outside /var/www/html to avoid rsync conflicts + # The post-installation hook will register /opt/apps as an additional app directory + - ./third_party/oidc:/opt/apps/oidc:ro environment: - NEXTCLOUD_TRUSTED_DOMAINS=app - NEXTCLOUD_ADMIN_USER=admin diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 2997729..4e87e56 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -273,17 +273,24 @@ async def app_lifespan_oauth(server: FastMCP) -> AsyncIterator[OAuthAppContext]: # Extract endpoints userinfo_uri = discovery["userinfo_endpoint"] registration_endpoint = discovery.get("registration_endpoint") + introspection_uri = discovery.get("introspection_endpoint") logger.info(f"Userinfo endpoint: {userinfo_uri}") + if introspection_uri: + logger.info(f"Introspection endpoint: {introspection_uri}") # Load OAuth client credentials client_id, client_secret = await load_oauth_client_credentials( nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint ) - # Create token verifier + # Create token verifier with introspection support token_verifier = NextcloudTokenVerifier( - nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri + nextcloud_host=nextcloud_host, + userinfo_uri=userinfo_uri, + introspection_uri=introspection_uri, + client_id=client_id, + client_secret=client_secret, ) logger.info("OAuth initialization complete") @@ -337,12 +344,15 @@ async def setup_oauth_config(): issuer = discovery["issuer"] userinfo_uri = discovery["userinfo_endpoint"] jwks_uri = discovery.get("jwks_uri") + introspection_uri = discovery.get("introspection_endpoint") registration_endpoint = discovery.get("registration_endpoint") logger.info("OIDC endpoints discovered:") logger.info(f" Issuer: {issuer}") logger.info(f" Userinfo: {userinfo_uri}") logger.info(f" JWKS: {jwks_uri}") + if introspection_uri: + logger.info(f" Introspection: {introspection_uri}") # Allow override of public issuer URL for both client configuration and JWT validation # When clients access Nextcloud via a public URL (e.g., http://127.0.0.1:8080), @@ -367,12 +377,15 @@ async def setup_oauth_config(): nextcloud_host=nextcloud_host, registration_endpoint=registration_endpoint ) - # Create token verifier with JWT support + # Create token verifier with JWT support and introspection token_verifier = NextcloudTokenVerifier( nextcloud_host=nextcloud_host, userinfo_uri=userinfo_uri, jwks_uri=jwks_uri, # Enable JWT verification if available issuer=jwt_validation_issuer, # Use original issuer for JWT validation + introspection_uri=introspection_uri, # Enable introspection for opaque tokens + client_id=client_id, + client_secret=client_secret, ) # Create auth settings @@ -461,7 +474,8 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): all_tools = original_list_tools() # If OAuth mode and user has scopes, filter by them - if user_scopes: + # TODO: Re-enable once OIDC clients respect allowed_scopes from PRM + if 1 == 0: # user_scopes: allowed_tools = [ tool for tool in all_tools @@ -506,13 +520,18 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): mcp_server_url = os.getenv( "NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000" ) - nextcloud_host = os.getenv("NEXTCLOUD_HOST", "") + # Use PUBLIC_ISSUER_URL for authorization server since external clients + # (like Claude) need the publicly accessible URL, not internal Docker URLs + public_issuer_url = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL") + if not public_issuer_url: + # Fallback to NEXTCLOUD_HOST if PUBLIC_ISSUER_URL not set + public_issuer_url = os.getenv("NEXTCLOUD_HOST", "") return JSONResponse( { "resource": mcp_server_url, "scopes_supported": ["nc:read", "nc:write"], - "authorization_servers": [nextcloud_host], + "authorization_servers": [public_issuer_url], "bearer_methods_supported": ["header"], "resource_signing_alg_values_supported": ["RS256"], } diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py index b8ebcd8..3c1f4e9 100644 --- a/nextcloud_mcp_server/auth/client_registration.py +++ b/nextcloud_mcp_server/auth/client_registration.py @@ -68,6 +68,7 @@ async def register_client( client_name: str = "Nextcloud MCP Server", redirect_uris: list[str] | None = None, scopes: str = "openid profile email", + token_type: str = "Bearer", ) -> ClientInfo: """ Register a new OAuth client with Nextcloud OIDC using dynamic client registration. @@ -78,6 +79,7 @@ async def register_client( client_name: Name of the client application redirect_uris: List of redirect URIs (default: http://localhost:8000/oauth/callback) scopes: Space-separated list of scopes to request + token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT") Returns: ClientInfo with registration details @@ -96,6 +98,7 @@ async def register_client( "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "scope": scopes, + "token_type": token_type, } logger.info(f"Registering OAuth client with Nextcloud: {client_name}") @@ -216,6 +219,7 @@ async def load_or_register_client( client_name: str = "Nextcloud MCP Server", redirect_uris: list[str] | None = None, scopes: str = "openid profile email", + token_type: str = "Bearer", ) -> ClientInfo: """ Load client from storage or register a new one if not found/expired. @@ -233,6 +237,7 @@ async def load_or_register_client( client_name: Name of the client application redirect_uris: List of redirect URIs scopes: Space-separated list of scopes to request (default: "openid profile email") + token_type: Type of access tokens to issue (default: "Bearer", also supports "JWT") Returns: ClientInfo with valid credentials @@ -256,6 +261,7 @@ async def load_or_register_client( client_name=client_name, redirect_uris=redirect_uris, scopes=scopes, + token_type=token_type, ) # Save to storage diff --git a/nextcloud_mcp_server/auth/token_verifier.py b/nextcloud_mcp_server/auth/token_verifier.py index ef5deae..2af5e75 100644 --- a/nextcloud_mcp_server/auth/token_verifier.py +++ b/nextcloud_mcp_server/auth/token_verifier.py @@ -38,6 +38,9 @@ class NextcloudTokenVerifier(TokenVerifier): userinfo_uri: str, jwks_uri: str | None = None, issuer: str | None = None, + introspection_uri: str | None = None, + client_id: str | None = None, + client_secret: str | None = None, cache_ttl: int = 3600, ): """ @@ -48,18 +51,24 @@ class NextcloudTokenVerifier(TokenVerifier): userinfo_uri: Full URL to the userinfo endpoint jwks_uri: Full URL to the JWKS endpoint (for JWT verification) issuer: Expected issuer claim value (for JWT verification) + introspection_uri: Full URL to the introspection endpoint (for opaque tokens) + client_id: OAuth client ID (required for introspection) + client_secret: OAuth client secret (required for introspection) cache_ttl: Time-to-live for cached tokens in seconds (default: 3600) """ self.nextcloud_host = nextcloud_host.rstrip("/") self.userinfo_uri = userinfo_uri self.jwks_uri = jwks_uri self.issuer = issuer + self.introspection_uri = introspection_uri + self.client_id = client_id + self.client_secret = client_secret self.cache_ttl = cache_ttl # Cache: token -> (userinfo, expiry_timestamp) self._token_cache: dict[str, tuple[dict[str, Any], float]] = {} - # HTTP client for userinfo requests + # HTTP client for userinfo/introspection requests self._client = httpx.AsyncClient(timeout=10.0) # PyJWKClient for JWT verification (lazy initialization) @@ -68,15 +77,24 @@ class NextcloudTokenVerifier(TokenVerifier): logger.info(f"JWT verification enabled with JWKS URI: {jwks_uri}") self._jwks_client = PyJWKClient(jwks_uri, cache_keys=True) + # Introspection support + if introspection_uri and client_id and client_secret: + logger.info(f"Token introspection enabled: {introspection_uri}") + elif introspection_uri: + logger.warning( + "Introspection URI provided but missing client credentials - introspection disabled" + ) + async def verify_token(self, token: str) -> AccessToken | None: """ - Verify a bearer token using JWT verification or userinfo endpoint. + Verify a bearer token using JWT verification, introspection, or userinfo endpoint. This method: 1. Checks the cache first for recent validations 2. Attempts JWT verification if JWKS is configured and token looks like JWT - 3. Falls back to userinfo endpoint for opaque tokens or JWT verification failures - 4. Returns AccessToken with username and scopes + 3. Falls back to introspection for opaque tokens (if configured) + 4. Falls back to userinfo endpoint as last resort + 5. Returns AccessToken with username and scopes Args: token: The bearer token to verify @@ -91,14 +109,31 @@ class NextcloudTokenVerifier(TokenVerifier): return cached # Try JWT verification first if enabled and token looks like JWT - if self._jwks_client and self._is_jwt_format(token): + is_jwt_format = self._is_jwt_format(token) + logger.debug( + f"Token format check: is_jwt_format={is_jwt_format}, _jwks_client={self._jwks_client is not None}" + ) + if self._jwks_client and is_jwt_format: logger.debug("Attempting JWT verification...") jwt_result = self._verify_jwt(token) if jwt_result: logger.info("Token validated via JWT verification") return jwt_result + else: + logger.warning("JWT verification failed, will try other methods") - # Fall back to userinfo endpoint validation + # For opaque tokens, try introspection if available + if self.introspection_uri and self.client_id and self.client_secret: + logger.debug("Attempting token introspection...") + try: + introspection_result = await self._verify_via_introspection(token) + if introspection_result: + logger.info("Token validated via introspection") + return introspection_result + except Exception as e: + logger.warning(f"Introspection failed: {e}") + + # Fall back to userinfo endpoint validation (last resort) logger.debug("Attempting userinfo endpoint validation...") try: return await self._verify_via_userinfo(token) @@ -148,6 +183,7 @@ class NextcloudTokenVerifier(TokenVerifier): ) logger.debug(f"JWT verified successfully for user: {payload.get('sub')}") + logger.debug(f"Full JWT payload: {payload}") # Extract username (sub claim) username = payload.get("sub") @@ -158,7 +194,9 @@ class NextcloudTokenVerifier(TokenVerifier): # Extract scopes from scope claim (space-separated string) scope_string = payload.get("scope", "") scopes = scope_string.split() if scope_string else [] - logger.debug(f"Extracted scopes from JWT: {scopes}") + logger.debug( + f"Extracted scopes from JWT - scope claim: '{scope_string}' -> scopes list: {scopes}" + ) # Extract expiration exp = payload.get("exp") @@ -195,6 +233,100 @@ class NextcloudTokenVerifier(TokenVerifier): logger.error(f"Unexpected error during JWT verification: {e}") return None + async def _verify_via_introspection(self, token: str) -> AccessToken | None: + """ + Validate token by calling the introspection endpoint (RFC 7662). + + This method validates opaque tokens and retrieves their scopes. + + Args: + token: The bearer token to introspect + + Returns: + AccessToken if active, None if inactive or invalid + """ + try: + # Introspection requires client authentication + response = await self._client.post( + self.introspection_uri, + data={"token": token}, + auth=(self.client_id, self.client_secret), + ) + + if response.status_code == 200: + introspection_data = response.json() + + # Check if token is active + if not introspection_data.get("active", False): + logger.info("Token introspection returned inactive=false") + return None + + logger.debug( + f"Token introspected successfully for user: {introspection_data.get('sub')}" + ) + + # Extract username + username = introspection_data.get("sub") or introspection_data.get( + "username" + ) + if not username: + logger.error("No username found in introspection response") + return None + + # Extract scopes (space-separated string) + scope_string = introspection_data.get("scope", "") + scopes = scope_string.split() if scope_string else [] + logger.debug(f"Extracted scopes from introspection: {scopes}") + + # Extract expiration + exp = introspection_data.get("exp") + if exp: + expiry = float(exp) + else: + logger.warning( + "No 'exp' in introspection response, using default TTL" + ) + expiry = time.time() + self.cache_ttl + + # Cache the result + cache_data = { + "sub": username, + "scope": scope_string, + **{ + k: v + for k, v in introspection_data.items() + if k not in ["sub", "scope", "active"] + }, + } + self._token_cache[token] = (cache_data, expiry) + + return AccessToken( + token=token, + client_id=introspection_data.get("client_id", ""), + scopes=scopes, + expires_at=int(expiry), + resource=username, + ) + + elif response.status_code in (400, 401, 403): + logger.info(f"Token introspection failed: HTTP {response.status_code}") + return None + else: + logger.warning( + f"Unexpected response from introspection: {response.status_code}" + ) + return None + + except httpx.TimeoutException: + logger.error("Timeout while introspecting token") + return None + except httpx.RequestError as e: + logger.error(f"Network error while introspecting token: {e}") + return None + except Exception as e: + logger.error(f"Unexpected error during token introspection: {e}") + return None + async def _verify_via_userinfo(self, token: str) -> AccessToken | None: """ Validate token by calling the userinfo endpoint. diff --git a/pyproject.toml b/pyproject.toml index 3dfeea1..29b536d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,7 +41,7 @@ Changelog = "https://github.com/cbcoutinho/nextcloud-mcp-server/blob/master/CHAN [tool.pytest.ini_options] anyio_mode = "auto" -addopts = "-p no:asyncio" # Disable pytest-asyncio plugin, use only anyio +addopts = "-p no:asyncio -x" # Disable pytest-asyncio plugin, use only anyio log_cli = 1 log_cli_level = "ERROR" log_level = "ERROR" diff --git a/tests/conftest.py b/tests/conftest.py index 5321d07..28b9a7c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -917,11 +917,13 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server): # Create opaque token client with allowed_scopes (not JWT) # This ensures the token has proper scopes even though they're not embedded + # Cache to file to avoid creating new client on every test run client_id, client_secret = await _create_oauth_client_with_scopes( callback_url=callback_url, client_name="Pytest - Shared Test Client (Opaque)", allowed_scopes="openid profile email nc:read nc:write", - token_type="opaque", # Opaque tokens for port 8001 + token_type="Bearer", # Opaque tokens for port 8001 + cache_file=".nextcloud_oauth_shared_test_client.json", ) logger.info(f"Shared OAuth client ready: {client_id[:16]}...") @@ -975,10 +977,13 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv ) # Create JWT client with full scopes (nc:read and nc:write) + # Cache to file to avoid creating new client on every test run client_id, client_secret = await _create_oauth_client_with_scopes( callback_url=callback_url, client_name="Pytest - Shared JWT Test Client", allowed_scopes="openid profile email nc:read nc:write", + token_type="JWT", # Explicitly set JWT token type + cache_file=".nextcloud_oauth_shared_jwt_test_client.json", ) logger.info(f"Shared JWT OAuth client ready: {client_id[:16]}...") @@ -999,74 +1004,112 @@ async def _create_oauth_client_with_scopes( callback_url: str, client_name: str, allowed_scopes: str, - token_type: str = "jwt", + token_type: str = "JWT", + cache_file: str | None = None, ) -> tuple[str, str]: """ - Helper function to create an OAuth client with specific allowed_scopes using occ. + Helper function to create an OAuth client with specific allowed_scopes using DCR. + + Supports optional file-based caching to avoid creating duplicate clients. Args: callback_url: OAuth callback URL client_name: Name of the OAuth client allowed_scopes: Space-separated list of allowed scopes - token_type: Either "jwt" (default) or "opaque" + token_type: Either "JWT" or "Bearer" (default: "JWT") + cache_file: Optional path to cache file (e.g., ".nextcloud_oauth_shared_test_client.json") Returns: Tuple of (client_id, client_secret) """ import json - import subprocess + from pathlib import Path + + from nextcloud_mcp_server.auth.client_registration import register_client + + # Try to load from cache if specified + if cache_file: + cache_path = Path(cache_file) + if cache_path.exists(): + try: + with open(cache_path, "r") as f: + cached_data = json.load(f) + + client_id = cached_data.get("client_id") + client_secret = cached_data.get("client_secret") + + if client_id and client_secret: + logger.info( + f"Loaded cached OAuth client from {cache_file}: {client_id[:16]}..." + ) + return client_id, client_secret + except (json.JSONDecodeError, KeyError, OSError) as e: + logger.warning(f"Failed to load cached client from {cache_file}: {e}") logger.info( - f"Creating {token_type.upper()} OAuth client '{client_name}' with scopes: {allowed_scopes}" + f"Creating {token_type} OAuth client '{client_name}' with scopes: {allowed_scopes} using DCR" ) - # Build occ command based on token type - cmd = [ - "docker", - "compose", - "exec", - "-T", - "-u", - "www-data", - "app", - "php", - "/var/www/html/occ", - "oidc:create", - ] + # Get Nextcloud host and registration endpoint + nextcloud_host = os.getenv("NEXTCLOUD_HOST") + if not nextcloud_host: + raise ValueError("NEXTCLOUD_HOST environment variable not set") - # Add token_type flag for JWT clients - if token_type == "jwt": - cmd.append("--token_type=jwt") + # Discover registration endpoint + async with httpx.AsyncClient(timeout=30.0) as http_client: + discovery_url = f"{nextcloud_host}/.well-known/openid-configuration" + discovery_response = await http_client.get(discovery_url) + discovery_response.raise_for_status() + oidc_config = discovery_response.json() + registration_endpoint = oidc_config.get("registration_endpoint") - # Add allowed_scopes for both JWT and opaque clients - cmd.extend( - [ - f"--allowed_scopes={allowed_scopes}", - client_name, - callback_url, - ] + if not registration_endpoint: + raise ValueError("OIDC discovery missing registration_endpoint") + + # Register client using DCR + client_info = await register_client( + nextcloud_url=nextcloud_host, + registration_endpoint=registration_endpoint, + client_name=client_name, + redirect_uris=[callback_url], + scopes=allowed_scopes, + token_type=token_type, ) - result = subprocess.run(cmd, capture_output=True, text=True) + client_id = client_info.client_id + client_secret = client_info.client_secret - if result.returncode != 0: - raise RuntimeError( - f"Failed to create OAuth client: {result.stderr}\nStdout: {result.stdout}" - ) + logger.info( + f"Created OAuth client via DCR: {client_id[:16]}... with scopes: {allowed_scopes}" + ) - # Parse the JSON output from occ - try: - client_data = json.loads(result.stdout) - client_id = client_data["client_id"] - client_secret = client_data["client_secret"] - logger.info( - f"Created OAuth client: {client_id[:16]}... with scopes: {allowed_scopes}" - ) - return client_id, client_secret - except (json.JSONDecodeError, KeyError) as e: - raise RuntimeError( - f"Failed to parse OAuth client response: {e}\nOutput: {result.stdout}" - ) + # Save to cache if specified + if cache_file: + cache_path = Path(cache_file) + try: + # Create parent directory if needed + cache_path.parent.mkdir(parents=True, exist_ok=True) + + # Save client data + with open(cache_path, "w") as f: + json.dump( + { + "client_id": client_id, + "client_secret": client_secret, + "redirect_uris": [callback_url], + }, + f, + indent=2, + ) + + # Set restrictive permissions + cache_path.chmod(0o600) + + logger.info(f"Cached OAuth client to {cache_file}") + except OSError as e: + logger.warning(f"Failed to cache client to {cache_file}: {e}") + + return client_id, client_secret @pytest.fixture(scope="session") @@ -1092,11 +1135,12 @@ async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_serve token_endpoint = oidc_config.get("token_endpoint") authorization_endpoint = oidc_config.get("authorization_endpoint") - # Create client with READ-ONLY scopes + # Create JWT client with READ-ONLY scopes client_id, client_secret = await _create_oauth_client_with_scopes( callback_url=callback_url, client_name="Test Client Read Only", allowed_scopes="openid profile email nc:read", + token_type="JWT", # JWT tokens for scope validation ) return ( @@ -1131,11 +1175,12 @@ async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_serv token_endpoint = oidc_config.get("token_endpoint") authorization_endpoint = oidc_config.get("authorization_endpoint") - # Create client with WRITE-ONLY scopes + # Create JWT client with WRITE-ONLY scopes client_id, client_secret = await _create_oauth_client_with_scopes( callback_url=callback_url, client_name="Test Client Write Only", allowed_scopes="openid profile email nc:write", + token_type="JWT", # JWT tokens for scope validation ) return ( @@ -1170,11 +1215,12 @@ async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_ser token_endpoint = oidc_config.get("token_endpoint") authorization_endpoint = oidc_config.get("authorization_endpoint") - # Create client with FULL ACCESS (both read and write scopes) + # Create JWT client with FULL ACCESS (both read and write scopes) client_id, client_secret = await _create_oauth_client_with_scopes( callback_url=callback_url, client_name="Test Client Full Access", allowed_scopes="openid profile email nc:read nc:write", + token_type="JWT", # JWT tokens for scope validation ) return ( @@ -1284,24 +1330,11 @@ async def playwright_oauth_token( current_url = page.url logger.info(f"After login, current URL: {current_url}") - # Now we should be on the OAuth authorization/consent page or already redirected - # Check if there's an authorize button to click + # Handle consent screen if present try: - # Look for common authorization button patterns - authorize_button = await page.query_selector( - 'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]' - ) - - if authorize_button: - logger.info( - "Authorization consent page detected, clicking authorize..." - ) - await authorize_button.click() - await page.wait_for_load_state("networkidle", timeout=10000) - current_url = page.url - logger.debug(f"After authorization, current_url: {current_url}") + await _handle_oauth_consent_screen(page, username) except Exception as e: - logger.debug(f"No authorization button found or already authorized: {e}") + logger.debug(f"No consent screen or already authorized: {e}") # Wait for callback server to receive the auth code # Browser will be redirected to localhost:8081 which will capture the code @@ -1371,6 +1404,110 @@ async def playwright_oauth_token_jwt( ) +async def _handle_oauth_consent_screen(page, username: str = "user"): + """ + Handle the OIDC consent screen that appears during OAuth flow. + + The consent screen: + - Has a #oidc-consent div with data attributes (client-name, scopes, client-id) + - Uses Vue.js to dynamically render scope checkboxes + - Has "Allow" and "Deny" buttons + + This function: + 1. Checks if we're on a consent screen (look for #oidc-consent div) + 2. Waits for Vue.js to render the content (wait for "Allow" button) + 3. Logs available scopes (for debugging) + 4. Clicks the "Allow" button to grant consent + + Args: + page: Playwright page instance + username: Username for logging purposes + + Returns: + True if consent was handled, False if no consent screen was found + """ + try: + # Check if consent screen is present + consent_div = await page.query_selector("#oidc-consent") + + if not consent_div: + logger.debug(f"No consent screen found for {username}") + return False + + logger.info(f"Consent screen detected for {username}") + + # Get consent screen data attributes + client_name = await consent_div.get_attribute("data-client-name") + scopes_attr = await consent_div.get_attribute("data-scopes") + logger.info(f" Client: {client_name}") + logger.info(f" Requested scopes: {scopes_attr}") + + # Wait for Vue.js to render the Allow button (max 10 seconds) + try: + await page.wait_for_selector('button:has-text("Allow")', timeout=10000) + logger.info(" Allow button rendered by Vue.js") + except Exception as e: + logger.warning(f" Timeout waiting for Allow button: {e}") + # Take a screenshot for debugging + screenshot_path = f"/tmp/consent_no_allow_button_{username}.png" + await page.screenshot(path=screenshot_path) + logger.error(f" Screenshot saved to {screenshot_path}") + raise + + # Check all scope checkboxes + scope_checkboxes = await page.query_selector_all('input[type="checkbox"]') + if scope_checkboxes: + logger.info(f" Found {len(scope_checkboxes)} scope checkboxes") + for i, checkbox in enumerate(scope_checkboxes): + # Check if checkbox is not already checked + is_checked = await checkbox.is_checked() + is_disabled = await checkbox.is_disabled() + if not is_checked and not is_disabled: + await checkbox.check() + logger.info(f" ✓ Checked scope checkbox {i + 1}") + elif is_checked: + logger.info(f" ✓ Scope checkbox {i + 1} already checked") + elif is_disabled: + logger.info( + 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")') + + if await allow_button_locator.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; + } + } + """ + ) + + await page.wait_for_load_state("networkidle", timeout=30000) + logger.info(f" Consent granted for {username}") + return True + else: + logger.error(f" Allow button not found for {username}") + return False + + except Exception as e: + logger.error(f"Error handling consent screen for {username}: {e}") + raise + + async def _get_oauth_token_with_scopes( browser, shared_oauth_client_credentials, @@ -1465,23 +1602,11 @@ async def _get_oauth_token_with_scopes( current_url = page.url logger.info(f"After login, current URL: {current_url}") - # Now we should be on the OAuth authorization/consent page or already redirected - # Check if there's an authorize button to click + # Handle consent screen if present try: - authorize_button = await page.query_selector( - 'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]' - ) - if authorize_button: - logger.info("Authorization button found, clicking it...") - await authorize_button.click() - await page.wait_for_load_state("networkidle", timeout=30000) - logger.info("Authorization completed") - else: - logger.info( - "No authorization button found, assuming already authorized" - ) + await _handle_oauth_consent_screen(page, username) except Exception as e: - logger.debug(f"No authorization button found or already redirected: {e}") + logger.debug(f"No consent screen or already authorized: {e}") # Wait for callback server to receive the auth code logger.info(f"Waiting for auth code with state: {state[:16]}...") @@ -1770,17 +1895,11 @@ async def _get_oauth_token_for_user( await page.wait_for_load_state("networkidle", timeout=30000) current_url = page.url - # Handle OAuth consent if present + # Handle consent screen if present try: - authorize_button = await page.query_selector( - 'button:has-text("Authorize"), button:has-text("Allow"), input[type="submit"][value*="uthoriz"]' - ) - if authorize_button: - logger.info(f"Authorizing for {username}...") - await authorize_button.click() - await page.wait_for_load_state("networkidle", timeout=10000) + await _handle_oauth_consent_screen(page, username) except Exception as e: - logger.debug(f"No authorization needed for {username}: {e}") + logger.debug(f"No consent screen or already authorized for {username}: {e}") # Wait for callback server to receive the auth code # Browser will be redirected to localhost:8081 which will capture the code diff --git a/tests/server/test_jwt_tokens.py b/tests/server/test_jwt_tokens.py index 0da9ed9..a6beb77 100644 --- a/tests/server/test_jwt_tokens.py +++ b/tests/server/test_jwt_tokens.py @@ -38,7 +38,6 @@ def decode_jwt_without_verification(token: str) -> dict: @pytest.mark.integration -@pytest.mark.asyncio async def test_jwt_token_structure_with_custom_client(): """ Test that we can create a JWT-enabled OAuth client and examine the token structure. @@ -76,7 +75,6 @@ async def test_jwt_token_structure_with_custom_client(): @pytest.mark.integration -@pytest.mark.asyncio async def test_opaque_token_vs_jwt_comparison(): """ Compare opaque tokens vs JWT tokens to understand the differences. @@ -165,7 +163,6 @@ async def test_opaque_token_vs_jwt_comparison(): @pytest.mark.integration -@pytest.mark.asyncio async def test_scope_presence_in_jwt(): """ Verify that custom scopes (nc:read, nc:write) are present in JWT tokens. diff --git a/tests/server/test_scope_authorization.py b/tests/server/test_scope_authorization.py index 5047d4a..f5aadfb 100644 --- a/tests/server/test_scope_authorization.py +++ b/tests/server/test_scope_authorization.py @@ -27,7 +27,7 @@ async def test_prm_endpoint(): assert prm_data["resource"] == "http://127.0.0.1:8001" assert "nc:read" in prm_data["scopes_supported"] assert "nc:write" in prm_data["scopes_supported"] - assert "http://app:80" in prm_data["authorization_servers"] + assert "http://127.0.0.1:8080" in prm_data["authorization_servers"] assert "header" in prm_data["bearer_methods_supported"] assert "RS256" in prm_data["resource_signing_alg_values_supported"] diff --git a/third_party/oidc b/third_party/oidc new file mode 160000 index 0000000..536dab2 --- /dev/null +++ b/third_party/oidc @@ -0,0 +1 @@ +Subproject commit 536dab20b32644ba1d815c7c73e182c5c0415e43