fix(auth): Skip issuer validation for management API tokens
Fixes NC PHP app (Astrolabe) OAuth integration by making token validation more lenient for management API access. Problem: - Astrolabe calls Nextcloud OIDC token endpoint via internal URL (http://localhost) - Tokens are issued with iss: http://localhost (internal) - MCP server expects iss: http://localhost:8080 (external) - Token validation failed with "Invalid issuer" Solution: - Add skip_issuer_check parameter to _verify_jwt_signature() - verify_token_for_management_api() now skips both audience and issuer checks - Security maintained: signature still verified, authorization checked by API Also includes related fixes from previous session: - Update test selectors for Vue 3 UI ("Enable Semantic Search") - Fix OIDC discovery URL transformation in OAuthController.php - Add overwrite.cli.url to setup hook for proper external URLs 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.5
parent
5e2ef5f35b
commit
804480836e
@@ -4,7 +4,8 @@ set -euox pipefail
|
|||||||
|
|
||||||
php /var/www/html/occ config:system:set trusted_domains 2 --value=host.docker.internal
|
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
|
# Set overwrite.cli.url to the external URL for OIDC discovery
|
||||||
# This allows:
|
# This ensures OAuth flows redirect to the correct external URL
|
||||||
# - Browser requests to localhost:8080 → returns localhost:8080 URLs
|
# Important: The Astrolabe OAuth controller makes internal HTTP requests to /.well-known/openid-configuration
|
||||||
# - Container requests to app:80 → returns app:80 URLs (for DCR, token exchange, etc.)
|
# 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"
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ async def validate_token_and_get_user(
|
|||||||
) -> tuple[str, dict[str, Any]]:
|
) -> tuple[str, dict[str, Any]]:
|
||||||
"""Validate OAuth bearer token and extract user ID.
|
"""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:
|
Args:
|
||||||
request: Starlette request with Authorization header
|
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
|
# Note: This is set in app.py starlette_lifespan for OAuth mode
|
||||||
token_verifier = request.app.state.oauth_context["token_verifier"]
|
token_verifier = request.app.state.oauth_context["token_verifier"]
|
||||||
|
|
||||||
# Validate token (handles both JWT and opaque tokens)
|
# Validate token for management API (handles both JWT and opaque tokens)
|
||||||
# verify_token returns AccessToken object or None
|
# Uses verify_token_for_management_api which accepts any valid Nextcloud token
|
||||||
access_token = await token_verifier.verify_token(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:
|
if not access_token:
|
||||||
raise ValueError("Token validation failed")
|
raise ValueError("Token validation failed")
|
||||||
|
|||||||
@@ -117,6 +117,51 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
# Both modes do the same validation (MCP audience only)
|
# Both modes do the same validation (MCP audience only)
|
||||||
return await self._verify_mcp_audience(token)
|
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:
|
async def _verify_mcp_audience(self, token: str) -> AccessToken | None:
|
||||||
"""
|
"""
|
||||||
Validate token has MCP audience.
|
Validate token has MCP audience.
|
||||||
@@ -186,6 +231,71 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
record_oauth_token_validation(validation_method, "error")
|
record_oauth_token_validation(validation_method, "error")
|
||||||
return None
|
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:
|
def _has_mcp_audience(self, payload: dict[str, Any]) -> bool:
|
||||||
"""
|
"""
|
||||||
Check if token has MCP audience.
|
Check if token has MCP audience.
|
||||||
@@ -230,12 +340,15 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
"""
|
"""
|
||||||
return "." in token and token.count(".") == 2
|
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.
|
Verify JWT token with signature validation using JWKS.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
token: JWT token to verify
|
token: JWT token to verify
|
||||||
|
skip_issuer_check: If True, skip issuer validation (for management API tokens)
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Decoded payload if valid, None if invalid
|
Decoded payload if valid, None if invalid
|
||||||
@@ -248,25 +361,22 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
|
|
||||||
# Verify and decode JWT
|
# Verify and decode JWT
|
||||||
# Note: We don't validate audience here - that's done separately based on mode
|
# 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(
|
payload = jwt.decode(
|
||||||
token,
|
token,
|
||||||
signing_key.key,
|
signing_key.key,
|
||||||
algorithms=["RS256"],
|
algorithms=["RS256"],
|
||||||
issuer=(
|
issuer=(self.settings.oidc_issuer if should_verify_issuer else None),
|
||||||
self.settings.oidc_issuer
|
|
||||||
if hasattr(self.settings, "oidc_issuer")
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
options={
|
options={
|
||||||
"verify_signature": True,
|
"verify_signature": True,
|
||||||
"verify_exp": True,
|
"verify_exp": True,
|
||||||
"verify_iat": True,
|
"verify_iat": True,
|
||||||
"verify_iss": (
|
"verify_iss": should_verify_issuer,
|
||||||
True
|
|
||||||
if hasattr(self.settings, "oidc_issuer")
|
|
||||||
and self.settings.oidc_issuer
|
|
||||||
else False
|
|
||||||
),
|
|
||||||
"verify_aud": False, # We handle audience validation separately
|
"verify_aud": False, # We handle audience validation separately
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -358,6 +468,24 @@ class UnifiedTokenVerifier(TokenVerifier):
|
|||||||
token: The bearer token
|
token: The bearer token
|
||||||
payload: Validated token payload
|
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:
|
Returns:
|
||||||
AccessToken object or None if required fields missing
|
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")
|
logger.warning("No 'exp' claim in token, using default TTL")
|
||||||
exp = int(time.time() + self.cache_ttl)
|
exp = int(time.time() + self.cache_ttl)
|
||||||
|
|
||||||
# Cache the result
|
# Cache the result with the provided key
|
||||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
|
||||||
userinfo = {
|
userinfo = {
|
||||||
"sub": username,
|
"sub": username,
|
||||||
"scope": scope_string,
|
"scope": scope_string,
|
||||||
**{k: v for k, v in payload.items() if k not in ["sub", "scope"]},
|
**{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(
|
return AccessToken(
|
||||||
token=token,
|
token=token,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ async def test_capture_settings_page(browser, configure_astrolabe_for_mcp_server
|
|||||||
|
|
||||||
# Navigate to settings
|
# Navigate to settings
|
||||||
logger.info("Navigating to personal MCP 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")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
# Capture page content
|
# 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 URL: {page.url}")
|
||||||
logger.info(f"Page title: {await page.title()}")
|
logger.info(f"Page title: {await page.title()}")
|
||||||
|
|
||||||
# Check for key strings
|
# Check for key strings (Vue 3 UI)
|
||||||
checks = [
|
checks = [
|
||||||
"Authorize Access",
|
"Enable Semantic Search", # oauth-required.php authorization button
|
||||||
"Authorization Required",
|
"Service Status", # personal.php when authorized
|
||||||
"MCP Server",
|
"Background Sync Access", # personal.php when authorized
|
||||||
"Sign In Again",
|
"What happens next?", # oauth-required.php steps
|
||||||
"astrolabe",
|
"Astrolabe", # Header
|
||||||
]
|
]
|
||||||
|
|
||||||
for check in checks:
|
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]):
|
for i, link_text in enumerate(links[:10]):
|
||||||
logger.info(f" Link {i}: {link_text}")
|
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
|
# Check for error messages
|
||||||
if "error" in page_content.lower():
|
if "error" in page_content.lower():
|
||||||
logger.warning("Page contains 'error' keyword")
|
logger.warning("Page contains 'error' keyword")
|
||||||
|
|||||||
@@ -105,21 +105,26 @@ async def authorized_nc_session(
|
|||||||
|
|
||||||
# Step 2: Navigate to personal MCP settings
|
# Step 2: Navigate to personal MCP settings
|
||||||
logger.info("Navigating 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")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
|
|
||||||
# Step 3: Check if authorization is needed
|
# 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...")
|
logger.info("User not authorized yet - initiating OAuth flow...")
|
||||||
|
|
||||||
# Click "Authorize Access" button
|
# Click "Enable Semantic Search" button (Vue 3 template text)
|
||||||
authorize_selectors = [
|
authorize_selectors = [
|
||||||
'button:has-text("Authorize")',
|
'a:has-text("Enable Semantic Search")',
|
||||||
'a:has-text("Authorize")',
|
'button:has-text("Enable Semantic Search")',
|
||||||
'[href*="oauth/authorize"]',
|
'a:has-text("Sign In Again")',
|
||||||
'button:has-text("Connect")',
|
"a.button.primary",
|
||||||
|
'[href*="oauth/login"]',
|
||||||
]
|
]
|
||||||
|
|
||||||
clicked = False
|
clicked = False
|
||||||
@@ -142,9 +147,17 @@ async def authorized_nc_session(
|
|||||||
# Wait for page to load after clicking
|
# Wait for page to load after clicking
|
||||||
await page.wait_for_load_state("networkidle", timeout=10000)
|
await page.wait_for_load_state("networkidle", timeout=10000)
|
||||||
current_url = page.url
|
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
|
# 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...")
|
logger.info("On OIDC authorization page - granting consent...")
|
||||||
|
|
||||||
consent_selectors = [
|
consent_selectors = [
|
||||||
@@ -163,7 +176,7 @@ async def authorized_nc_session(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
# Wait for redirect back to settings
|
# 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")
|
await page.wait_for_load_state("networkidle")
|
||||||
logger.info("✓ OAuth authorization completed")
|
logger.info("✓ OAuth authorization completed")
|
||||||
|
|
||||||
@@ -197,28 +210,32 @@ class TestNcPhpAppOAuth:
|
|||||||
host = authorized_nc_session["host"]
|
host = authorized_nc_session["host"]
|
||||||
|
|
||||||
# Navigate to settings (may already be there)
|
# 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")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
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 = [
|
success_indicators = [
|
||||||
"Connected",
|
"Service Status",
|
||||||
"Disconnect",
|
"Background Sync Access",
|
||||||
"Server Connection",
|
"Manage Connection",
|
||||||
"Session Information",
|
"Revoke Access",
|
||||||
"MCP Server",
|
"Service URL",
|
||||||
]
|
]
|
||||||
|
|
||||||
has_success_indicator = any(
|
found_indicators = [ind for ind in success_indicators if ind in page_content]
|
||||||
indicator in page_content for indicator in success_indicators
|
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:
|
if not has_success_indicator:
|
||||||
screenshot_path = "/tmp/nc-php-app-auth-check.png"
|
logger.error("Authorization check failed.")
|
||||||
await page.screenshot(path=screenshot_path)
|
|
||||||
logger.error(f"Authorization check failed. Screenshot: {screenshot_path}")
|
|
||||||
|
|
||||||
assert has_success_indicator, "Settings page should show user is authorized"
|
assert has_success_indicator, "Settings page should show user is authorized"
|
||||||
logger.info("✓ Authorization verification passed")
|
logger.info("✓ Authorization verification passed")
|
||||||
@@ -232,7 +249,7 @@ class TestNcPhpAppOAuth:
|
|||||||
page = authorized_nc_session["page"]
|
page = authorized_nc_session["page"]
|
||||||
host = authorized_nc_session["host"]
|
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")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
@@ -243,12 +260,12 @@ class TestNcPhpAppOAuth:
|
|||||||
logger.info(f"Screenshot saved: {screenshot_path}")
|
logger.info(f"Screenshot saved: {screenshot_path}")
|
||||||
logger.info(f"Page content excerpt: {page_content[:1000]}")
|
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 = [
|
session_indicators = [
|
||||||
"Server Connection",
|
"Service Status",
|
||||||
"Session Information",
|
"Service URL",
|
||||||
"Connection Management",
|
"Version",
|
||||||
"MCP Server",
|
"Background Sync Access",
|
||||||
]
|
]
|
||||||
|
|
||||||
found_indicators = [ind for ind in session_indicators if ind in page_content]
|
found_indicators = [ind for ind in session_indicators if ind in page_content]
|
||||||
@@ -270,17 +287,17 @@ class TestNcPhpAppOAuth:
|
|||||||
host = authorized_nc_session["host"]
|
host = authorized_nc_session["host"]
|
||||||
|
|
||||||
# Check personal settings page shows server status
|
# 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")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
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 = [
|
api_indicators = [
|
||||||
"Server Connection", # Section header
|
"Service Status", # Section header
|
||||||
"Server URL", # Server info
|
"Service URL", # Server info from API
|
||||||
"Connection Management", # Connection section
|
"Version", # Server version from management API
|
||||||
"Vector Visualization", # Vector sync section
|
"Semantic Search", # Vector sync status
|
||||||
]
|
]
|
||||||
|
|
||||||
found_api_data = [ind for ind in api_indicators if ind in page_content]
|
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"]
|
page = authorized_nc_session["page"]
|
||||||
host = authorized_nc_session["host"]
|
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")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
|
|
||||||
# Admin page should show server status
|
# Admin page should show server status (Vue 3 AdminSettings.vue)
|
||||||
admin_indicators = [
|
admin_indicators = [
|
||||||
"MCP Server",
|
"Astrolabe",
|
||||||
"Server Status",
|
"Service Status",
|
||||||
"Version",
|
"Version",
|
||||||
|
"Semantic Search",
|
||||||
]
|
]
|
||||||
|
|
||||||
found_indicators = [ind for ind in admin_indicators if ind in page_content]
|
found_indicators = [ind for ind in admin_indicators if ind in page_content]
|
||||||
|
|
||||||
# Admin page should at least show the MCP Server header
|
# Admin page should at least show the Astrolabe header or Service Status
|
||||||
assert "MCP Server" in page_content or "mcp" in page_content.lower(), (
|
assert "Astrolabe" in page_content or "Service Status" in page_content, (
|
||||||
"Admin settings page should show MCP Server section"
|
"Admin settings page should show Astrolabe section"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info(f"✓ Admin settings page verified - found: {found_indicators}")
|
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)
|
await page.wait_for_url(f"{host}/apps/dashboard/", timeout=10000)
|
||||||
|
|
||||||
# Navigate to personal settings
|
# 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")
|
await page.wait_for_load_state("networkidle")
|
||||||
|
|
||||||
page_content = await page.content()
|
page_content = await page.content()
|
||||||
|
|
||||||
# Check if user is authorized
|
# Check if user is authorized (Vue 3 personal.php shows Disconnect/Revoke when authorized)
|
||||||
if "Disconnect" not in page_content:
|
if "Disconnect" not in page_content and "Revoke Access" not in page_content:
|
||||||
pytest.skip("User not authorized - cannot test disconnect")
|
pytest.skip("User not authorized - cannot test disconnect")
|
||||||
|
|
||||||
# Click disconnect button
|
# Click disconnect button
|
||||||
@@ -384,10 +402,10 @@ class TestNcPhpAppDisconnect:
|
|||||||
# Wait for page reload
|
# Wait for page reload
|
||||||
await page.wait_for_load_state("networkidle")
|
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()
|
page_content = await page.content()
|
||||||
assert "Authorize" in page_content, (
|
assert "Enable Semantic Search" in page_content, (
|
||||||
"Settings page should show 'Authorize Access' after disconnect"
|
"Settings page should show 'Enable Semantic Search' after disconnect"
|
||||||
)
|
)
|
||||||
|
|
||||||
logger.info("✓ Disconnect flow test passed")
|
logger.info("✓ Disconnect flow test passed")
|
||||||
|
|||||||
+13
-2
@@ -335,9 +335,10 @@ class OAuthController extends Controller {
|
|||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
// Fall back to Nextcloud's OIDC app
|
// Fall back to Nextcloud's OIDC app
|
||||||
// Use internal localhost URL for HTTP request (always accessible from inside container)
|
// Use internal localhost URL for HTTP request (accessible from inside container)
|
||||||
// The OIDC discovery response will contain proper external URLs based on overwrite.cli.url
|
// We'll transform the returned URLs to external format after discovery
|
||||||
$discoveryUrl = 'http://localhost/.well-known/openid-configuration';
|
$discoveryUrl = 'http://localhost/.well-known/openid-configuration';
|
||||||
|
$internalBaseUrl = 'http://localhost';
|
||||||
|
|
||||||
$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
|
$this->logger->info('Using Nextcloud OIDC app as IdP (internal request)', [
|
||||||
'discovery_url' => $discoveryUrl,
|
'discovery_url' => $discoveryUrl,
|
||||||
@@ -368,6 +369,16 @@ class OAuthController extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$authEndpoint = $discovery['authorization_endpoint'];
|
$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', [
|
$this->logger->info('buildAuthorizationUrl: OIDC discovery succeeded', [
|
||||||
'auth_endpoint' => $authEndpoint,
|
'auth_endpoint' => $authEndpoint,
|
||||||
'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',
|
'token_endpoint' => $discovery['token_endpoint'] ?? 'not_set',
|
||||||
|
|||||||
Reference in New Issue
Block a user