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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
@@ -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
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule third_party/astrolabe updated: d245ded7f8...829db07a8c
Reference in New Issue
Block a user