From fe8799a13386fa07fff52a58b15d59cc704da951 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 29 Mar 2026 15:05:26 +0200 Subject: [PATCH 1/3] fix: resolve OAuth compatibility issues for login-flow deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drop OIDC fork: comment out third_party/oidc mount, use upstream v1.16.3 from app store (fixes consent redirect race, PR #631) - Support client_secret_basic auth: add _extract_basic_auth() helper so TS MCP SDK can authenticate at token endpoint (RFC 6749 §2.3.1) - Multi-issuer JWT validation: accept tokens with internal Docker issuer (http://app:80) or public URL (NEXTCLOUD_PUBLIC_ISSUER_URL) since AS proxy obtains tokens server-to-server - Introspection fallback: try token introspection when JWT verification fails, supporting both JWT and opaque token types - Register all tool scopes in DCR: add semantic:read, collectives:read, collectives:write to OIDC client allowed_scopes so tokens include them and semantic search tools are visible to authenticated clients - Auto-create Astrolabe OAuth client: new app-hook creates OIDC client and stores credentials in config.php so the "Authorize via OAuth" button works without manual setup Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/test.yml | 14 ++-- .../26-configure-astrolabe-oauth.sh | 72 +++++++++++++++++++ docker-compose.yml | 2 +- nextcloud_mcp_server/app.py | 20 ++++-- nextcloud_mcp_server/auth/oauth_routes.py | 21 +++++- nextcloud_mcp_server/auth/unified_verifier.py | 44 +++++++++--- third_party/astrolabe | 2 +- 7 files changed, 151 insertions(+), 24 deletions(-) create mode 100755 app-hooks/post-installation/26-configure-astrolabe-oauth.sh 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 From f151eb10b397d304ee8ad1092005464cb1f351f6 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 29 Mar 2026 18:39:49 +0200 Subject: [PATCH 2/3] fix: move Astrolabe OAuth hook to before-starting for reliable OIDC client creation Move 26-configure-astrolabe-oauth.sh from post-installation (runs once on first boot) to before-starting (runs on every start). This ensures the Astrolabe OIDC client is created as soon as MCP_SERVER_URL is available, even if it wasn't set during initial installation. Also copy 25-configure-mcp-server-url.sh to before-starting so the mcp_server_url config stays current across container recreations. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../25-configure-mcp-server-url.sh | 17 +++++++++++++++++ .../26-configure-astrolabe-oauth.sh | 0 docker-compose.yml | 3 +++ 3 files changed, 20 insertions(+) create mode 100755 app-hooks/before-starting/25-configure-mcp-server-url.sh rename app-hooks/{post-installation => before-starting}/26-configure-astrolabe-oauth.sh (100%) diff --git a/app-hooks/before-starting/25-configure-mcp-server-url.sh b/app-hooks/before-starting/25-configure-mcp-server-url.sh new file mode 100755 index 00000000..3132672d --- /dev/null +++ b/app-hooks/before-starting/25-configure-mcp-server-url.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Configure MCP server URL for Astrolabe background sync +# This URL is used by Astrolabe to send app passwords to the MCP server + +set -e + +if [ -z "${MCP_SERVER_URL:-}" ]; then + echo "MCP_SERVER_URL not set, skipping Astrolabe MCP server URL configuration" + exit 0 +fi + +echo "Configuring MCP server URL: $MCP_SERVER_URL" + +# Set the mcp_server_url in config.php via occ +php occ config:system:set mcp_server_url --value="$MCP_SERVER_URL" + +echo "MCP server URL configured successfully" diff --git a/app-hooks/post-installation/26-configure-astrolabe-oauth.sh b/app-hooks/before-starting/26-configure-astrolabe-oauth.sh similarity index 100% rename from app-hooks/post-installation/26-configure-astrolabe-oauth.sh rename to app-hooks/before-starting/26-configure-astrolabe-oauth.sh diff --git a/docker-compose.yml b/docker-compose.yml index 256bb1c2..7a1d8751 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -47,6 +47,9 @@ services: - MYSQL_USER=nextcloud - MYSQL_HOST=db - REDIS_HOST=redis + # Set MCP_SERVER_URL to enable Astrolabe OAuth client auto-creation + # (before-starting hook creates OIDC client + stores credentials in config.php) + # Example: MCP_SERVER_URL=http://mcp-login-flow:8004 docker compose --profile login-flow up -d - MCP_SERVER_URL=${MCP_SERVER_URL:-} healthcheck: test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"] From 25788eecc70dec0b841cabebe60640ab42d7d4c1 Mon Sep 17 00:00:00 2001 From: Chris Coutinho Date: Sun, 29 Mar 2026 19:03:25 +0200 Subject: [PATCH 3/3] fix: allow HTTPS redirect URIs for non-localhost OAuth clients Relax redirect_uri validation to accept HTTPS for remote hosts (e.g., cloud-hosted MCP clients like Claude AI) while keeping HTTP allowed for localhost per RFC 8252 loopback exception. Co-Authored-By: Claude Opus 4.6 (1M context) --- nextcloud_mcp_server/auth/oauth_routes.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/nextcloud_mcp_server/auth/oauth_routes.py b/nextcloud_mcp_server/auth/oauth_routes.py index e1e49e27..63beff2b 100644 --- a/nextcloud_mcp_server/auth/oauth_routes.py +++ b/nextcloud_mcp_server/auth/oauth_routes.py @@ -193,12 +193,16 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse: status_code=400, ) - # Validate redirect_uri is localhost (RFC 8252 for native clients) - if not redirect_uri.startswith(("http://localhost:", "http://127.0.0.1:")): + # Validate redirect_uri scheme security (OAuth 2.1): + # - Localhost: HTTP allowed (RFC 8252 loopback exception for native clients) + # - Remote hosts: HTTPS required (cloud clients like Claude AI) + parsed_redirect = parse_url(redirect_uri) + is_loopback = parsed_redirect.hostname in ("localhost", "127.0.0.1") + if not (is_loopback or parsed_redirect.scheme == "https"): return JSONResponse( { "error": "invalid_request", - "error_description": "redirect_uri must be localhost for native clients", + "error_description": "redirect_uri must use HTTPS for non-localhost URIs", }, status_code=400, )