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 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
+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
#- ./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
@@ -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"]
+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.
#
# 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"
+26 -5
View File
@@ -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
@@ -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,
)
@@ -905,6 +909,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 +975,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",
+34 -10
View File
@@ -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