fix: resolve OAuth compatibility issues for login-flow deployment

- 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) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-03-29 15:05:26 +02:00
co-authored by Claude Opus 4.6
parent 3cf4c777ed
commit fe8799a133
7 changed files with 151 additions and 24 deletions
+8 -6
View File
@@ -120,12 +120,14 @@ jobs:
npm ci npm ci
npm run build npm run build
- name: Build OIDC app # OIDC app is now installed from the Nextcloud app store via app-hook
run: | # (third_party/oidc fork no longer mounted — upstream v1.16.3 fixes PR #631)
cd third_party/oidc # - name: Build OIDC app
composer install --no-dev --optimize-autoloader # run: |
npm ci # cd third_party/oidc
npm run build # composer install --no-dev --optimize-autoloader
# npm ci
# npm run build
# Start services with the appropriate profile # Start services with the appropriate profile
- name: Run docker compose - name: Run docker compose
@@ -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"
+1 -1
View File
@@ -37,7 +37,7 @@ services:
# The post-installation hook will register /opt/apps as an additional app directory # The post-installation hook will register /opt/apps as an additional app directory
#- ./third_party:/opt/apps:ro #- ./third_party:/opt/apps:ro
- ./third_party/astrolabe:/opt/apps/astrolabe:ro - ./third_party/astrolabe:/opt/apps/astrolabe:ro
- ./third_party/oidc:/opt/apps/oidc:ro #- ./third_party/oidc:/opt/apps/oidc:ro
environment: environment:
- NEXTCLOUD_TRUSTED_DOMAINS=app - NEXTCLOUD_TRUSTED_DOMAINS=app
- NEXTCLOUD_ADMIN_USER=admin - NEXTCLOUD_ADMIN_USER=admin
+16 -4
View File
@@ -456,12 +456,24 @@ async def load_oauth_client_credentials(
# and the authorization server will limit them to these allowed scopes. # and the authorization server will limit them to these allowed scopes.
# #
# The PRM endpoint advertises the same scopes dynamically via @require_scopes decorators. # 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 # Add conditional scopes based on server configuration
# Use settings.enable_offline_access which handles both ENABLE_BACKGROUND_OPERATIONS (new)
# and ENABLE_OFFLINE_ACCESS (deprecated) environment variables
dcr_settings = get_settings() 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 enable_offline_access = dcr_settings.enable_offline_access
if enable_offline_access: if enable_offline_access:
dcr_scopes = f"{dcr_scopes} offline_access" dcr_scopes = f"{dcr_scopes} offline_access"
+19 -2
View File
@@ -24,10 +24,10 @@ import logging
import os import os
import secrets import secrets
import time import time
from base64 import urlsafe_b64encode from base64 import b64decode, urlsafe_b64encode
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from urllib.parse import urlencode from urllib.parse import unquote, urlencode
from urllib.parse import urlparse as parse_url from urllib.parse import urlparse as parse_url
import jwt import jwt
@@ -905,6 +905,19 @@ async def _oauth_callback_as_proxy(
return RedirectResponse(redirect_url, status_code=302) 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: def _verify_pkce_s256(code_verifier: str, code_challenge: str) -> bool:
"""Verify PKCE S256 code_verifier against stored code_challenge. """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") code_verifier = form.get("code_verifier")
client_id = form.get("client_id") 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( logger.debug(
"AS proxy token: received code=%s client_id=%s redirect_uri=%s " "AS proxy token: received code=%s client_id=%s redirect_uri=%s "
"code_verifier=%s", "code_verifier=%s",
+34 -10
View File
@@ -82,6 +82,17 @@ class UnifiedTokenVerifier(TokenVerifier):
self.introspection_uri = settings.introspection_uri self.introspection_uri = settings.introspection_uri
logger.info(f"Token introspection enabled: {self.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) # Token cache: token_hash -> (userinfo, expiry_timestamp)
self._token_cache: dict[str, tuple[dict[str, Any], float]] = {} self._token_cache: dict[str, tuple[dict[str, Any], float]] = {}
self.cache_ttl = 3600 # 1 hour default self.cache_ttl = 3600 # 1 hour default
@@ -89,7 +100,8 @@ class UnifiedTokenVerifier(TokenVerifier):
logger.info( logger.info(
f"UnifiedTokenVerifier initialized in {self.mode} mode. " f"UnifiedTokenVerifier initialized in {self.mode} mode. "
f"MCP audience: {settings.oidc_client_id} or {settings.nextcloud_mcp_server_url}, " 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: async def verify_token(self, token: str) -> AccessToken | None:
@@ -208,6 +220,14 @@ class UnifiedTokenVerifier(TokenVerifier):
record_oauth_token_validation("jwt", "valid") record_oauth_token_validation("jwt", "valid")
else: else:
record_oauth_token_validation("jwt", "invalid") 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: else:
# Fall back to introspection for opaque tokens # Fall back to introspection for opaque tokens
validation_method = "introspect" validation_method = "introspect"
@@ -390,26 +410,30 @@ 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) # Issuer is checked manually below to support multiple valid issuers
should_verify_issuer = ( # (internal Docker URL vs public URL in AS proxy deployments)
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=(self.settings.oidc_issuer if should_verify_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": should_verify_issuer, "verify_iss": False, # Checked manually below
"verify_aud": False, # We handle audience validation separately "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')}") logger.debug(f"JWT signature verified for user: {payload.get('sub')}")
return payload return payload