diff --git a/app-hooks/post-installation/00-setup-trusted-domains.sh b/app-hooks/post-installation/00-setup-trusted-domains.sh index b31b499..be6ba2c 100755 --- a/app-hooks/post-installation/00-setup-trusted-domains.sh +++ b/app-hooks/post-installation/00-setup-trusted-domains.sh @@ -4,7 +4,8 @@ set -euox pipefail php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal -# Do NOT set overwritehost/overwrite.cli.url - let Nextcloud use the request's Host header -# This allows: -# - Browser requests to localhost:8080 → returns localhost:8080 URLs -# - Container requests to app:80 → returns app:80 URLs (for DCR, token exchange, etc.) +# Set overwrite.cli.url to the external URL for OIDC discovery +# This ensures OAuth flows redirect to the correct external URL +# Important: The Astrolabe OAuth controller makes internal HTTP requests to /.well-known/openid-configuration +# which needs to return URLs reachable by external browsers (localhost:8080, not localhost:80) +php /var/www/html/occ config:system:set overwrite.cli.url --value="http://localhost:8080" diff --git a/nextcloud_mcp_server/api/management.py b/nextcloud_mcp_server/api/management.py index efbb8c0..05bcfe6 100644 --- a/nextcloud_mcp_server/api/management.py +++ b/nextcloud_mcp_server/api/management.py @@ -54,6 +54,10 @@ async def validate_token_and_get_user( ) -> tuple[str, dict[str, Any]]: """Validate OAuth bearer token and extract user ID. + Uses verify_token_for_management_api which accepts any valid Nextcloud OIDC + token (not just MCP-audience tokens). This is needed because Astrolabe + (NC PHP app) uses its own OAuth client, separate from MCP server's client. + Args: request: Starlette request with Authorization header @@ -71,9 +75,10 @@ async def validate_token_and_get_user( # Note: This is set in app.py starlette_lifespan for OAuth mode token_verifier = request.app.state.oauth_context["token_verifier"] - # Validate token (handles both JWT and opaque tokens) - # verify_token returns AccessToken object or None - access_token = await token_verifier.verify_token(token) + # Validate token for management API (handles both JWT and opaque tokens) + # Uses verify_token_for_management_api which accepts any valid Nextcloud token + # without requiring MCP audience - needed for Astrolabe integration (ADR-018) + access_token = await token_verifier.verify_token_for_management_api(token) if not access_token: raise ValueError("Token validation failed") diff --git a/nextcloud_mcp_server/auth/unified_verifier.py b/nextcloud_mcp_server/auth/unified_verifier.py index 2803e7a..c75e4a5 100644 --- a/nextcloud_mcp_server/auth/unified_verifier.py +++ b/nextcloud_mcp_server/auth/unified_verifier.py @@ -117,6 +117,51 @@ class UnifiedTokenVerifier(TokenVerifier): # Both modes do the same validation (MCP audience only) return await self._verify_mcp_audience(token) + async def verify_token_for_management_api(self, token: str) -> AccessToken | None: + """ + Verify token for management API access (ADR-018 NC PHP app integration). + + This is a more lenient verification that accepts ANY valid Nextcloud OIDC + token, not just tokens with MCP server audience. This is needed because: + - Astrolabe (NC PHP app) uses its own OAuth client with Nextcloud OIDC + - Tokens from Astrolabe have Astrolabe's client_id as audience + - MCP server's management API should accept these tokens + + Security model: + - Authentication: Token is valid (issued by Nextcloud, not expired) + - Authorization: Token's user == requested resource (checked by management API) + + Args: + token: Bearer token to verify + + Returns: + AccessToken if valid (regardless of audience), None otherwise + """ + # Check cache first (using separate cache key to avoid mixing with MCP tokens) + cache_key = f"mgmt:{hashlib.sha256(token.encode()).hexdigest()}" + if cache_key in self._token_cache: + userinfo, expiry = self._token_cache[cache_key] + if time.time() < expiry: + logger.debug("Management API token found in cache") + oauth_token_cache_hits_total.labels(hit="true").inc() + username = userinfo.get("sub") or userinfo.get("preferred_username") + scope_string = userinfo.get("scope", "") + scopes = scope_string.split() if scope_string else [] + return AccessToken( + token=token, + client_id=userinfo.get("client_id", ""), + scopes=scopes, + expires_at=int(expiry), + resource=username, + ) + else: + del self._token_cache[cache_key] + + oauth_token_cache_hits_total.labels(hit="false").inc() + + # Verify token without audience check + return await self._verify_without_audience_check(token, cache_key) + async def _verify_mcp_audience(self, token: str) -> AccessToken | None: """ Validate token has MCP audience. @@ -186,6 +231,71 @@ class UnifiedTokenVerifier(TokenVerifier): record_oauth_token_validation(validation_method, "error") return None + async def _verify_without_audience_check( + self, token: str, cache_key: str + ) -> AccessToken | None: + """ + Verify token validity without checking MCP audience or issuer. + + Used for management API where tokens from Astrolabe (NC PHP app) need to + be accepted. These tokens are issued by Nextcloud OIDC to Astrolabe's + OAuth client, not MCP server's client. + + Security model: + - We skip audience check (token may have Astrolabe's audience, not MCP's) + - We skip issuer check (token may have internal Nextcloud URL as issuer) + - We still verify signature (token is authentically from Nextcloud OIDC) + - We still verify expiration (token is not expired) + - Authorization is checked by management API (user == requested resource) + + Args: + token: Bearer token to verify + cache_key: Cache key for storing validation result + + Returns: + AccessToken if valid, None otherwise + """ + validation_method = "unknown" + try: + # Attempt JWT verification first + # Skip issuer check for management API tokens (may have internal URL) + if self._is_jwt_format(token) and self.jwks_client: + validation_method = "jwt" + payload = await self._verify_jwt_signature( + token, skip_issuer_check=True + ) + if payload: + record_oauth_token_validation("jwt", "valid") + else: + record_oauth_token_validation("jwt", "invalid") + return None + else: + # Fall back to introspection for opaque tokens + validation_method = "introspect" + payload = await self._introspect_token(token) + if payload: + record_oauth_token_validation("introspect", "valid") + else: + record_oauth_token_validation("introspect", "invalid") + return None + + # Check payload is valid + if not payload: + return None + + # Skip audience validation - any valid Nextcloud token is accepted + logger.debug( + f"Management API token validated (no audience check) for user: {payload.get('sub')}" + ) + + # Cache and return the token + return self._create_access_token_with_cache_key(token, payload, cache_key) + + except Exception as e: + logger.error(f"Management API token verification failed: {e}") + record_oauth_token_validation(validation_method, "error") + return None + def _has_mcp_audience(self, payload: dict[str, Any]) -> bool: """ Check if token has MCP audience. @@ -230,12 +340,15 @@ class UnifiedTokenVerifier(TokenVerifier): """ return "." in token and token.count(".") == 2 - async def _verify_jwt_signature(self, token: str) -> dict[str, Any] | None: + async def _verify_jwt_signature( + self, token: str, skip_issuer_check: bool = False + ) -> dict[str, Any] | None: """ Verify JWT token with signature validation using JWKS. Args: token: JWT token to verify + skip_issuer_check: If True, skip issuer validation (for management API tokens) Returns: Decoded payload if valid, None if invalid @@ -248,25 +361,22 @@ class UnifiedTokenVerifier(TokenVerifier): # Verify and decode JWT # Note: We don't validate audience here - that's done separately based on mode + # Issuer validation can be skipped for management API tokens (from Astrolabe) + should_verify_issuer = ( + not skip_issuer_check + and hasattr(self.settings, "oidc_issuer") + and self.settings.oidc_issuer + ) payload = jwt.decode( token, signing_key.key, algorithms=["RS256"], - issuer=( - self.settings.oidc_issuer - if hasattr(self.settings, "oidc_issuer") - else None - ), + issuer=(self.settings.oidc_issuer if should_verify_issuer else None), options={ "verify_signature": True, "verify_exp": True, "verify_iat": True, - "verify_iss": ( - True - if hasattr(self.settings, "oidc_issuer") - and self.settings.oidc_issuer - else False - ), + "verify_iss": should_verify_issuer, "verify_aud": False, # We handle audience validation separately }, ) @@ -358,6 +468,24 @@ class UnifiedTokenVerifier(TokenVerifier): token: The bearer token payload: Validated token payload + Returns: + AccessToken object or None if required fields missing + """ + # Use default cache key (hash of token) + cache_key = hashlib.sha256(token.encode()).hexdigest() + return self._create_access_token_with_cache_key(token, payload, cache_key) + + def _create_access_token_with_cache_key( + self, token: str, payload: dict[str, Any], cache_key: str + ) -> AccessToken | None: + """ + Create AccessToken object from validated token payload with custom cache key. + + Args: + token: The bearer token + payload: Validated token payload + cache_key: Key to use for caching (allows separate caches for MCP vs management API) + Returns: AccessToken object or None if required fields missing """ @@ -382,14 +510,13 @@ class UnifiedTokenVerifier(TokenVerifier): logger.warning("No 'exp' claim in token, using default TTL") exp = int(time.time() + self.cache_ttl) - # Cache the result - token_hash = hashlib.sha256(token.encode()).hexdigest() + # Cache the result with the provided key userinfo = { "sub": username, "scope": scope_string, **{k: v for k, v in payload.items() if k not in ["sub", "scope"]}, } - self._token_cache[token_hash] = (userinfo, exp) + self._token_cache[cache_key] = (userinfo, exp) return AccessToken( token=token, diff --git a/tests/server/oauth/test_nc_php_app_debug.py b/tests/server/oauth/test_nc_php_app_debug.py index 766564b..c2bce01 100644 --- a/tests/server/oauth/test_nc_php_app_debug.py +++ b/tests/server/oauth/test_nc_php_app_debug.py @@ -37,7 +37,7 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server # Navigate to settings logger.info("Navigating to personal MCP settings...") - await page.goto(f"{nextcloud_host}/settings/user/mcp") + await page.goto(f"{nextcloud_host}/settings/user/astrolabe") await page.wait_for_load_state("networkidle") # Capture page content @@ -52,13 +52,13 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server logger.info(f"Page URL: {page.url}") logger.info(f"Page title: {await page.title()}") - # Check for key strings + # Check for key strings (Vue 3 UI) checks = [ - "Authorize Access", - "Authorization Required", - "MCP Server", - "Sign In Again", - "astrolabe", + "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: @@ -75,6 +75,15 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server 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") diff --git a/tests/server/oauth/test_nc_php_app_oauth.py b/tests/server/oauth/test_nc_php_app_oauth.py index ef2a2cf..b4d91d5 100644 --- a/tests/server/oauth/test_nc_php_app_oauth.py +++ b/tests/server/oauth/test_nc_php_app_oauth.py @@ -105,21 +105,26 @@ async def authorized_nc_session( # Step 2: Navigate to personal MCP settings logger.info("Navigating to personal MCP settings...") - await page.goto(f"{host}/settings/user/mcp") + 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 - if "Authorize Access" in page_content or "authorize" in page_content.lower(): + # 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 "Authorize Access" button + # Click "Enable Semantic Search" button (Vue 3 template text) authorize_selectors = [ - 'button:has-text("Authorize")', - 'a:has-text("Authorize")', - '[href*="oauth/authorize"]', - 'button:has-text("Connect")', + '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 @@ -142,9 +147,17 @@ async def authorized_nc_session( # 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: + if ( + "/apps/oidc/authorize" in current_url + or "/apps/oidc/consent" in current_url + ): logger.info("On OIDC authorization page - granting consent...") consent_selectors = [ @@ -163,7 +176,7 @@ async def authorized_nc_session( continue # Wait for redirect back to settings - await page.wait_for_url(f"{host}/settings/user/mcp", timeout=15000) + await page.wait_for_url(f"{host}/settings/user/astrolabe", timeout=15000) await page.wait_for_load_state("networkidle") logger.info("✓ OAuth authorization completed") @@ -197,28 +210,32 @@ class TestNcPhpAppOAuth: host = authorized_nc_session["host"] # Navigate to settings (may already be there) - await page.goto(f"{host}/settings/user/mcp") + 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 + # 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 = [ - "Connected", - "Disconnect", - "Server Connection", - "Session Information", - "MCP Server", + "Service Status", + "Background Sync Access", + "Manage Connection", + "Revoke Access", + "Service URL", ] - has_success_indicator = any( - indicator in page_content for indicator in success_indicators - ) + 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: - screenshot_path = "/tmp/nc-php-app-auth-check.png" - await page.screenshot(path=screenshot_path) - logger.error(f"Authorization check failed. Screenshot: {screenshot_path}") + logger.error("Authorization check failed.") assert has_success_indicator, "Settings page should show user is authorized" logger.info("✓ Authorization verification passed") @@ -232,7 +249,7 @@ class TestNcPhpAppOAuth: page = authorized_nc_session["page"] host = authorized_nc_session["host"] - await page.goto(f"{host}/settings/user/mcp") + await page.goto(f"{host}/settings/user/astrolabe") await page.wait_for_load_state("networkidle") page_content = await page.content() @@ -243,12 +260,12 @@ class TestNcPhpAppOAuth: logger.info(f"Screenshot saved: {screenshot_path}") logger.info(f"Page content excerpt: {page_content[:1000]}") - # Verify session information is visible - these are the actual labels from template + # Verify session information is visible (Vue 3 personal.php template) session_indicators = [ - "Server Connection", - "Session Information", - "Connection Management", - "MCP Server", + "Service Status", + "Service URL", + "Version", + "Background Sync Access", ] found_indicators = [ind for ind in session_indicators if ind in page_content] @@ -270,17 +287,17 @@ class TestNcPhpAppOAuth: host = authorized_nc_session["host"] # Check personal settings page shows server status - await page.goto(f"{host}/settings/user/mcp") + 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 + # Look for data that comes from management API or template structure (Vue 3) api_indicators = [ - "Server Connection", # Section header - "Server URL", # Server info - "Connection Management", # Connection section - "Vector Visualization", # Vector sync section + "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] @@ -298,23 +315,24 @@ class TestNcPhpAppOAuth: page = authorized_nc_session["page"] host = authorized_nc_session["host"] - await page.goto(f"{host}/settings/admin/mcp") + 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 + # Admin page should show server status (Vue 3 AdminSettings.vue) admin_indicators = [ - "MCP Server", - "Server Status", + "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 MCP Server header - assert "MCP Server" in page_content or "mcp" in page_content.lower(), ( - "Admin settings page should show MCP Server section" + # 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}") @@ -355,13 +373,13 @@ class TestNcPhpAppDisconnect: await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000) # Navigate to personal settings - await page.goto(f"{host}/settings/user/mcp") + 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 - if "Disconnect" not in 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 @@ -384,10 +402,10 @@ class TestNcPhpAppDisconnect: # Wait for page reload await page.wait_for_load_state("networkidle") - # Verify we're back to "Authorize Access" state + # Verify we're back to "Enable Semantic Search" state (Vue 3 oauth-required.php) page_content = await page.content() - assert "Authorize" in page_content, ( - "Settings page should show 'Authorize Access' after disconnect" + assert "Enable Semantic Search" in page_content, ( + "Settings page should show 'Enable Semantic Search' after disconnect" ) logger.info("✓ Disconnect flow test passed") diff --git a/third_party/astrolabe/lib/Controller/OAuthController.php b/third_party/astrolabe/lib/Controller/OAuthController.php index 23709d5..b6698b2 100644 --- a/third_party/astrolabe/lib/Controller/OAuthController.php +++ b/third_party/astrolabe/lib/Controller/OAuthController.php @@ -335,9 +335,10 @@ class OAuthController extends Controller { ]); } else { // Fall back to Nextcloud's OIDC app - // Use internal localhost URL for HTTP request (always accessible from inside container) - // The OIDC discovery response will contain proper external URLs based on overwrite.cli.url + // Use internal localhost URL for HTTP request (accessible from inside container) + // We'll transform the returned URLs to external format after discovery $discoveryUrl = 'http://localhost/.well-known/openid-configuration'; + $internalBaseUrl = 'http://localhost'; $this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [ 'discovery_url' => $discoveryUrl, @@ -368,6 +369,16 @@ class OAuthController extends Controller { } $authEndpoint = $discovery['authorization_endpoint']; + + // Transform internal URL to external URL if using Nextcloud OIDC app + // The discovery was done via internal http://localhost but browsers need + // the external URL (e.g., http://localhost:8080) + if (isset($internalBaseUrl)) { + $externalBaseUrl = $this->urlGenerator->getAbsoluteURL('/'); + $externalBaseUrl = rtrim($externalBaseUrl, '/'); + $authEndpoint = str_replace($internalBaseUrl, $externalBaseUrl, $authEndpoint); + } + $this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [ 'auth_endpoint' => $authEndpoint, 'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',