Merge pull request #664 from cbcoutinho/fix/login-flow-oauth-compat

fix: resolve OAuth compatibility issues for login-flow deployment
This commit is contained in:
Chris Coutinho
2026-03-29 20:57:52 +02:00
committed by GitHub
8 changed files with 178 additions and 27 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
+17
View File
@@ -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"
+72
View File
@@ -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"
+4 -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
@@ -47,6 +47,9 @@ services:
- MYSQL_USER=nextcloud - MYSQL_USER=nextcloud
- MYSQL_HOST=db - MYSQL_HOST=db
- REDIS_HOST=redis - 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:-} - MCP_SERVER_URL=${MCP_SERVER_URL:-}
healthcheck: healthcheck:
test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"] test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"]
+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"
+26 -5
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
@@ -193,12 +193,16 @@ async def oauth_authorize(request: Request) -> RedirectResponse | JSONResponse:
status_code=400, status_code=400,
) )
# Validate redirect_uri is localhost (RFC 8252 for native clients) # Validate redirect_uri scheme security (OAuth 2.1):
if not redirect_uri.startswith(("http://localhost:", "http://127.0.0.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( return JSONResponse(
{ {
"error": "invalid_request", "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, status_code=400,
) )
@@ -905,6 +909,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 +975,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