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:
Chris Coutinho
2025-12-24 17:25:48 -06:00
parent 5e2ef5f35b
commit 804480836e
6 changed files with 249 additions and 78 deletions
@@ -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"
+8 -3
View File
@@ -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")
+142 -15
View File
@@ -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,
+16 -7
View File
@@ -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")
+65 -47
View File
@@ -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")
+13 -2
View File
@@ -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',