diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0cf3b788..22d6ca6e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -120,12 +120,14 @@ jobs: npm ci npm run build - - name: Build OIDC app - run: | - cd third_party/oidc - composer install --no-dev --optimize-autoloader - npm ci - npm run build + # OIDC app is now installed from the Nextcloud app store via app-hook + # (third_party/oidc fork no longer mounted — upstream v1.16.3 fixes PR #631) + # - name: Build OIDC app + # run: | + # cd third_party/oidc + # composer install --no-dev --optimize-autoloader + # npm ci + # npm run build # Start services with the appropriate profile - name: Run docker compose diff --git a/app-hooks/post-installation/26-configure-astrolabe-oauth.sh b/app-hooks/post-installation/26-configure-astrolabe-oauth.sh new file mode 100755 index 00000000..3bfb30f5 --- /dev/null +++ b/app-hooks/post-installation/26-configure-astrolabe-oauth.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Configure Astrolabe OAuth client for MCP server integration +# Creates an OIDC client in Nextcloud and stores credentials in config.php +# so the "Authorize via OAuth" button in Astrolabe settings works. + +set -e + +# Check MCP_SERVER_URL env var, fall back to config.php value +MCP_SERVER_URL="${MCP_SERVER_URL:-$(php occ config:system:get mcp_server_url 2>/dev/null || true)}" + +if [ -z "$MCP_SERVER_URL" ]; then + echo "MCP_SERVER_URL not set and mcp_server_url not in config.php, skipping Astrolabe OAuth setup" + exit 0 +fi + +# Skip if client already configured +EXISTING_CLIENT_ID=$(php occ config:system:get astrolabe_client_id 2>/dev/null || true) +if [ -n "$EXISTING_CLIENT_ID" ]; then + echo "Astrolabe OAuth client already configured: $EXISTING_CLIENT_ID" + exit 0 +fi + +# Check if OIDC app is enabled (required for oidc:create) +if ! php occ app:list --output=json 2>/dev/null | php -r 'exit(isset(json_decode(file_get_contents("php://stdin"),true)["enabled"]["oidc"]) ? 0 : 1);'; then + echo "OIDC app not enabled, skipping Astrolabe OAuth setup" + exit 0 +fi + +echo "Creating Astrolabe OAuth client..." + +# Determine public MCP server URL (for token audience / resource indicator) +MCP_PUBLIC_URL="${MCP_SERVER_PUBLIC_URL:-$MCP_SERVER_URL}" + +# Get Nextcloud external URL for redirect URI +NC_EXTERNAL_URL=$(php occ config:system:get overwrite.cli.url 2>/dev/null || echo "http://localhost:8080") +NC_EXTERNAL_URL="${NC_EXTERNAL_URL%/}" + +# Client ID must be 32-64 chars, A-Za-z0-9 +CLIENT_ID="astrolabeMcpClientOAuth00000000000" +REDIRECT_URI="${NC_EXTERNAL_URL}/apps/astrolabe/oauth/callback" + +# All scopes the MCP server supports (must match DCR scopes in app.py) +ALLOWED_SCOPES="openid profile email offline_access notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write news:read news:write collectives:read collectives:write semantic:read" + +# Create OAuth client +CLIENT_JSON=$(php occ oidc:create "Astrolabe" \ + "$REDIRECT_URI" \ + --client_id "$CLIENT_ID" \ + --type confidential \ + --flow code \ + --token_type jwt \ + --resource_url "$MCP_PUBLIC_URL" \ + --allowed_scopes "$ALLOWED_SCOPES") + +# Extract client_secret from JSON output +CLIENT_SECRET=$(echo "$CLIENT_JSON" | php -r '$d=json_decode(file_get_contents("php://stdin")); echo $d->client_secret ?? "";') + +if [ -z "$CLIENT_SECRET" ]; then + echo "ERROR: Failed to extract client_secret from oidc:create output" + echo "Output was: $CLIENT_JSON" + exit 1 +fi + +# Store credentials in config.php +php occ config:system:set astrolabe_client_id --value="$CLIENT_ID" +php occ config:system:set astrolabe_client_secret --value="$CLIENT_SECRET" +php occ config:system:set mcp_server_public_url --value="$MCP_PUBLIC_URL" + +echo "Astrolabe OAuth client configured successfully" +echo " Client ID: $CLIENT_ID" +echo " Redirect URI: $REDIRECT_URI" +echo " MCP Server Public URL: $MCP_PUBLIC_URL" diff --git a/docker-compose.yml b/docker-compose.yml index 7c8c73da..256bb1c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,7 @@ services: # The post-installation hook will register /opt/apps as an additional app directory #- ./third_party:/opt/apps:ro - ./third_party/astrolabe:/opt/apps/astrolabe:ro - - ./third_party/oidc:/opt/apps/oidc:ro + #- ./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 e7f2f0c3..4573c7c0 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -456,12 +456,24 @@ async def load_oauth_client_credentials( # and the authorization server will limit them to these allowed scopes. # # The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators. - dcr_scopes = "openid profile email notes:read notes:write calendar:read calendar:write todo:read todo:write contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write tables:read tables:write files:read files:write sharing:read sharing:write news:read news:write" + # These must stay in sync — any scope a tool uses via @require_scopes must be listed here. + dcr_scopes = ( + "openid profile email " + "notes:read notes:write calendar:read calendar:write todo:read todo:write " + "contacts:read contacts:write cookbook:read cookbook:write deck:read deck:write " + "tables:read tables:write files:read files:write sharing:read sharing:write " + "news:read news:write collectives:read collectives:write" + ) - # Add offline_access scope if refresh tokens are enabled - # Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new) - # and ENABLE_OFFLINE_ACCESS (deprecated) environment variables + # Add conditional scopes based on server configuration dcr_settings = get_settings() + + # semantic:read gates MCP-server-level semantic search tools + if dcr_settings.vector_sync_enabled: + dcr_scopes = f"{dcr_scopes} semantic:read" + logger.info("✓ semantic:read scope enabled for semantic search tools") + + # offline_access enables refresh tokens for background operations enable_offline_access = dcr_settings.enable_offline_access if enable_offline_access: dcr_scopes = f"{dcr_scopes} offline_access" diff --git a/nextcloud_mcp_server/auth/oauth_routes.py b/nextcloud_mcp_server/auth/oauth_routes.py index 2bfc81d5..e1e49e27 100644 --- a/nextcloud_mcp_server/auth/oauth_routes.py +++ b/nextcloud_mcp_server/auth/oauth_routes.py @@ -24,10 +24,10 @@ import logging import os import secrets import time -from base64 import urlsafe_b64encode +from base64 import b64decode, urlsafe_b64encode from dataclasses import dataclass, field from typing import Any -from urllib.parse import urlencode +from urllib.parse import unquote, urlencode from urllib.parse import urlparse as parse_url import jwt @@ -905,6 +905,19 @@ async def _oauth_callback_as_proxy( return RedirectResponse(redirect_url, status_code=302) +def _extract_basic_auth(request: Request) -> tuple[str | None, str | None]: + """Extract client_id and client_secret from HTTP Basic Auth header (RFC 6749 §2.3.1).""" + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Basic "): + return None, None + try: + decoded = b64decode(auth_header[6:]).decode("utf-8") + client_id, _, client_secret = decoded.partition(":") + return unquote(client_id), unquote(client_secret) if client_secret else None + except Exception: + return None, None + + def _verify_pkce_s256(code_verifier: str, code_challenge: str) -> bool: """Verify PKCE S256 code_verifier against stored code_challenge. @@ -958,6 +971,10 @@ async def _token_authorization_code(request: Request, form) -> JSONResponse: code_verifier = form.get("code_verifier") client_id = form.get("client_id") + # RFC 6749 §2.3.1: clients may authenticate via HTTP Basic Auth + if not client_id: + client_id, _ = _extract_basic_auth(request) + logger.debug( "AS proxy token: received code=%s client_id=%s redirect_uri=%s " "code_verifier=%s", diff --git a/nextcloud_mcp_server/auth/unified_verifier.py b/nextcloud_mcp_server/auth/unified_verifier.py index 7a197224..41147f7a 100644 --- a/nextcloud_mcp_server/auth/unified_verifier.py +++ b/nextcloud_mcp_server/auth/unified_verifier.py @@ -82,6 +82,17 @@ class UnifiedTokenVerifier(TokenVerifier): self.introspection_uri = settings.introspection_uri logger.info(f"Token introspection enabled: {self.introspection_uri}") + # Build list of valid issuers (internal + public may differ in Docker) + # AS proxy obtains tokens via internal URL (e.g. http://app:80), while + # NEXTCLOUD_PUBLIC_ISSUER_URL is the browser-facing URL (e.g. http://localhost:8080) + self.valid_issuers: list[str] = [] + if hasattr(settings, "oidc_issuer") and settings.oidc_issuer: + self.valid_issuers.append(settings.oidc_issuer) + if hasattr(settings, "nextcloud_host") and settings.nextcloud_host: + host = settings.nextcloud_host.rstrip("/") + if host not in self.valid_issuers: + self.valid_issuers.append(host) + # Token cache: token_hash -> (userinfo, expiry_timestamp) self._token_cache: dict[str, tuple[dict[str, Any], float]] = {} self.cache_ttl = 3600 # 1 hour default @@ -89,7 +100,8 @@ class UnifiedTokenVerifier(TokenVerifier): logger.info( f"UnifiedTokenVerifier initialized in {self.mode} mode. " f"MCP audience: {settings.oidc_client_id} or {settings.nextcloud_mcp_server_url}, " - f"Nextcloud resource URI: {settings.nextcloud_resource_uri}" + f"Nextcloud resource URI: {settings.nextcloud_resource_uri}, " + f"Valid issuers: {self.valid_issuers}" ) async def verify_token(self, token: str) -> AccessToken | None: @@ -208,6 +220,14 @@ class UnifiedTokenVerifier(TokenVerifier): record_oauth_token_validation("jwt", "valid") else: record_oauth_token_validation("jwt", "invalid") + # Fall back to introspection if JWT verification failed + if self.introspection_uri: + validation_method = "introspect" + payload = await self._introspect_token(token) + if payload: + record_oauth_token_validation("introspect", "valid") + else: + record_oauth_token_validation("introspect", "invalid") else: # Fall back to introspection for opaque tokens validation_method = "introspect" @@ -390,26 +410,30 @@ 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 - ) + # Issuer is checked manually below to support multiple valid issuers + # (internal Docker URL vs public URL in AS proxy deployments) payload = jwt.decode( token, signing_key.key, algorithms=["RS256"], - issuer=(self.settings.oidc_issuer if should_verify_issuer else None), options={ "verify_signature": True, "verify_exp": True, "verify_iat": True, - "verify_iss": should_verify_issuer, - "verify_aud": False, # We handle audience validation separately + "verify_iss": False, # Checked manually below + "verify_aud": False, # Handled separately based on mode }, ) + # Manual issuer validation against multiple valid issuers + if not skip_issuer_check and self.valid_issuers: + token_issuer = payload.get("iss") + if token_issuer not in self.valid_issuers: + raise jwt.InvalidIssuerError( + f"Invalid issuer '{token_issuer}', " + f"expected one of: {self.valid_issuers}" + ) + logger.debug(f"JWT signature verified for user: {payload.get('sub')}") return payload diff --git a/third_party/astrolabe b/third_party/astrolabe index d245ded7..829db07a 160000 --- a/third_party/astrolabe +++ b/third_party/astrolabe @@ -1 +1 @@ -Subproject commit d245ded7f8dc57f9add06f5d7d5ee90c19d38bc3 +Subproject commit 829db07a8c9cb750cdcb20e1f5238e24594b03b3