feat: Migrate to vue 3

This commit is contained in:
Chris Coutinho
2025-12-23 05:46:49 +01:00
parent d7c99fcc69
commit 4248b67b2e
15 changed files with 1881 additions and 2777 deletions
+250 -12
View File
@@ -1,3 +1,5 @@
from __future__ import annotations
import logging
import os
import time
@@ -42,6 +44,7 @@ from nextcloud_mcp_server.auth.unified_verifier import UnifiedTokenVerifier
from nextcloud_mcp_server.client import NextcloudClient
from nextcloud_mcp_server.config import (
DeploymentMode,
Settings,
get_document_processor_config,
get_settings,
)
@@ -1012,6 +1015,160 @@ async def setup_oauth_config():
)
async def setup_oauth_config_for_multi_user_basic(
settings: Settings,
client_id: str,
client_secret: str,
) -> tuple[UnifiedTokenVerifier, RefreshTokenStorage | None, str, str]:
"""
Setup minimal OAuth configuration for multi-user BasicAuth mode.
This is a lightweight version of setup_oauth_config() that:
- Performs OIDC discovery to get endpoints
- Creates UnifiedTokenVerifier for management API token validation
- Creates RefreshTokenStorage for webhook token storage
- Skips OAuth client creation (not needed for BasicAuth background sync)
- Skips AuthSettings creation (not needed for BasicAuth MCP operations)
This enables hybrid authentication mode where:
- MCP operations use BasicAuth (stateless, simple)
- Management APIs use OAuth bearer tokens (secure, per-user)
- Background operations use OAuth refresh tokens (webhook sync)
Args:
settings: Application settings
client_id: OAuth client ID (from DCR or static config)
client_secret: OAuth client secret
Returns:
Tuple of (token_verifier, refresh_token_storage, client_id, client_secret)
Raises:
ValueError: If NEXTCLOUD_HOST is not set
httpx.HTTPError: If OIDC discovery fails
"""
nextcloud_host = settings.nextcloud_host
if not nextcloud_host:
raise ValueError("NEXTCLOUD_HOST is required for OAuth infrastructure setup")
nextcloud_host = nextcloud_host.rstrip("/")
# Get OIDC discovery URL (always Nextcloud integrated mode for multi-user BasicAuth)
discovery_url = os.getenv(
"OIDC_DISCOVERY_URL",
f"{nextcloud_host}/.well-known/openid-configuration",
)
logger.info(
f"Performing OIDC discovery for multi-user BasicAuth hybrid mode: {discovery_url}"
)
# Perform OIDC discovery
async with httpx.AsyncClient() as http_client:
response = await http_client.get(discovery_url)
response.raise_for_status()
discovery = response.json()
logger.info("✓ OIDC discovery successful (multi-user BasicAuth)")
# Extract OIDC endpoints
issuer = discovery["issuer"]
userinfo_uri = discovery["userinfo_endpoint"]
jwks_uri = discovery.get("jwks_uri")
introspection_uri = discovery.get("introspection_endpoint")
# For multi-user BasicAuth, always assume Nextcloud integrated mode
# and rewrite endpoints to use internal URL for backend access
if jwks_uri:
internal_jwks_uri = f"{nextcloud_host}/apps/oidc/jwks"
jwks_uri = internal_jwks_uri
if introspection_uri:
internal_introspection_uri = f"{nextcloud_host}/apps/oidc/introspect"
introspection_uri = internal_introspection_uri
if userinfo_uri:
internal_userinfo_uri = f"{nextcloud_host}/apps/oidc/userinfo"
userinfo_uri = internal_userinfo_uri
logger.info("OIDC endpoints configured for management API:")
logger.info(f" Issuer: {issuer}")
logger.info(f" Userinfo: {userinfo_uri}")
logger.info(f" JWKS: {jwks_uri}")
logger.info(f" Introspection: {introspection_uri}")
# Get MCP server URL for audience validation
mcp_server_url = os.getenv("NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000")
nextcloud_resource_uri = os.getenv("NEXTCLOUD_RESOURCE_URI", nextcloud_host)
# Handle public issuer override (for JWT validation)
public_issuer = os.getenv("NEXTCLOUD_PUBLIC_ISSUER_URL")
if public_issuer:
public_issuer = public_issuer.rstrip("/")
logger.info(
f"Using public issuer URL override for JWT validation: {public_issuer}"
)
client_issuer = public_issuer
else:
client_issuer = issuer
# Update settings with discovered values for UnifiedTokenVerifier
if not settings.oidc_client_id:
settings.oidc_client_id = client_id
if not settings.oidc_client_secret:
settings.oidc_client_secret = client_secret
if not settings.jwks_uri:
settings.jwks_uri = jwks_uri
if not settings.introspection_uri:
settings.introspection_uri = introspection_uri
if not settings.userinfo_uri:
settings.userinfo_uri = userinfo_uri
if not settings.oidc_issuer:
settings.oidc_issuer = client_issuer
if not settings.nextcloud_mcp_server_url:
settings.nextcloud_mcp_server_url = mcp_server_url
if not settings.nextcloud_resource_uri:
settings.nextcloud_resource_uri = nextcloud_resource_uri
# Create Unified Token Verifier for management API authentication
token_verifier = UnifiedTokenVerifier(settings)
logger.info("✓ Token verifier created for management API (hybrid mode)")
if introspection_uri:
logger.info(" Opaque token introspection enabled (RFC 7662)")
if jwks_uri:
logger.info(" JWT signature verification enabled (JWKS)")
# Initialize refresh token storage for background operations
refresh_token_storage = None
if settings.enable_offline_access:
try:
from nextcloud_mcp_server.auth.storage import RefreshTokenStorage
encryption_key = os.getenv("TOKEN_ENCRYPTION_KEY")
if not encryption_key:
logger.warning(
"ENABLE_OFFLINE_ACCESS=true but TOKEN_ENCRYPTION_KEY not set. "
"Refresh tokens will NOT be stored. Generate a key with:\n"
' python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
else:
refresh_token_storage = RefreshTokenStorage.from_env()
await refresh_token_storage.initialize()
logger.info(
"✓ Refresh token storage initialized for background operations (hybrid mode)"
)
except Exception as e:
logger.error(f"Failed to initialize refresh token storage: {e}")
logger.debug(f"Full traceback:\n{traceback.format_exc()}")
logger.warning(
"Continuing without refresh token storage - webhook management may be limited"
)
logger.info(
"OAuth infrastructure setup complete for multi-user BasicAuth hybrid mode"
)
return (token_verifier, refresh_token_storage, client_id, client_secret)
def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None = None):
# Initialize observability (logging will be configured by uvicorn)
settings = get_settings()
@@ -1043,6 +1200,15 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
else DeploymentMode.SELF_HOSTED
)
# Log hybrid authentication status for multi-user BasicAuth with offline access
if mode == AuthMode.MULTI_USER_BASIC and settings.enable_offline_access:
logger.info(
"🔄 Hybrid authentication mode will be enabled:\n"
" - MCP operations: BasicAuth (stateless, credentials per-request)\n"
" - Management APIs: OAuth bearer tokens (secure, per-user)\n"
" - Background operations: OAuth refresh tokens (webhook sync)"
)
# Setup Prometheus metrics (always enabled by default)
if settings.metrics_enabled:
setup_metrics(port=settings.metrics_port)
@@ -1070,6 +1236,8 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# This must happen BEFORE uvicorn starts (same lifecycle point as OAuth modes)
# to avoid async context issues
multi_user_basic_oauth_creds: tuple[str, str] | None = None
multi_user_token_verifier: UnifiedTokenVerifier | None = None
multi_user_refresh_storage: RefreshTokenStorage | None = None
if (
mode == AuthMode.MULTI_USER_BASIC
@@ -1135,6 +1303,42 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Run DCR synchronously before uvicorn starts
multi_user_basic_oauth_creds = anyio.run(setup_multi_user_basic_dcr)
# Setup OAuth infrastructure for management APIs and background operations
# This creates the UnifiedTokenVerifier needed by management.py and
# RefreshTokenStorage for webhook token persistence
if multi_user_basic_oauth_creds:
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
logger.info(
"Setting up OAuth infrastructure for management APIs (hybrid mode)..."
)
try:
(
multi_user_token_verifier,
multi_user_refresh_storage,
_,
_,
) = anyio.run(
setup_oauth_config_for_multi_user_basic,
settings,
sync_client_id,
sync_client_secret,
)
logger.info(
"✓ OAuth infrastructure setup complete for multi-user BasicAuth hybrid mode"
)
except Exception as e:
logger.error(f"Failed to setup OAuth infrastructure: {e}")
logger.debug(f"Full traceback:\n{traceback.format_exc()}")
logger.warning(
"Management API will be unavailable. "
"Webhook management from Astrolabe admin UI will not work."
)
# Set to None to indicate failure
multi_user_token_verifier = None
multi_user_refresh_storage = None
# Create MCP server based on detected mode
if mode in (AuthMode.OAUTH_SINGLE_AUDIENCE, AuthMode.OAUTH_TOKEN_EXCHANGE):
logger.info("Configuring MCP server for OAuth mode")
@@ -1410,11 +1614,14 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# For multi-user BasicAuth with offline access, create oauth_context for management APIs
# This allows Astrolabe to use management APIs with OAuth bearer tokens
if settings.enable_multi_user_basic_auth and settings.enable_offline_access:
# Check if we have OAuth credentials from DCR
if multi_user_basic_oauth_creds:
# Check if we have OAuth credentials AND infrastructure from setup
if (
multi_user_basic_oauth_creds
and multi_user_token_verifier is not None
):
sync_client_id, sync_client_secret = multi_user_basic_oauth_creds
# Create minimal oauth_context for management API authentication
# Create oauth_context for management API authentication
nextcloud_host_for_context = settings.nextcloud_host
mcp_server_url = os.getenv(
"NEXTCLOUD_MCP_SERVER_URL", "http://localhost:8000"
@@ -1425,9 +1632,10 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
oauth_context_dict = {
"storage": basic_auth_storage,
# Use OAuth refresh token storage if available, fallback to basic_auth_storage
"storage": multi_user_refresh_storage or basic_auth_storage,
"oauth_client": None, # Not needed for management APIs
"token_verifier": None, # Will be set when token broker is created
"token_verifier": multi_user_token_verifier, # FIXED: Now has real verifier!
"config": {
"mcp_server_url": mcp_server_url,
"discovery_url": discovery_url,
@@ -1441,7 +1649,19 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
}
app.state.oauth_context = oauth_context_dict
logger.info(
f"OAuth context initialized for management APIs (multi-user BasicAuth, client_id={sync_client_id[:16]}...)"
f"OAuth context initialized for management APIs (hybrid mode, client_id={sync_client_id[:16]}...)"
)
elif multi_user_basic_oauth_creds and multi_user_token_verifier is None:
logger.warning(
"OAuth infrastructure setup failed - management API will be unavailable. "
"This is expected if OIDC discovery failed or token verifier creation failed. "
"Webhook management from Astrolabe admin UI will not work."
)
else:
logger.warning(
"OAuth credentials not available - management API will be unavailable. "
"This is expected if DCR failed or static credentials were not provided. "
"Webhook management from Astrolabe admin UI will not work."
)
# Also share with browser_app for webhook routes
@@ -2028,7 +2248,21 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
# Note: Metrics endpoint is NOT exposed on main HTTP port for security reasons.
# Metrics are served on dedicated port via setup_metrics() (default: 9090)
if oauth_enabled:
# Determine if OAuth provisioning is available
# This is true for:
# 1. OAuth modes (primary auth method for MCP operations)
# 2. Multi-user BasicAuth with offline access (hybrid mode)
oauth_provisioning_available = oauth_enabled or (
mode == AuthMode.MULTI_USER_BASIC
and settings.enable_offline_access
and multi_user_token_verifier is not None # Ensure OAuth setup succeeded
)
if oauth_provisioning_available:
logger.info(
f"OAuth provisioning routes enabled for mode: {mode.value} "
f"(oauth_enabled={oauth_enabled}, hybrid_mode={not oauth_enabled})"
)
# Import OAuth routes (ADR-004 Progressive Consent)
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
@@ -2091,10 +2325,6 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
"Protected Resource Metadata (PRM) endpoints enabled (path-based + root)"
)
# Add OAuth login routes (ADR-004 Progressive Consent Flow 1)
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
# Add unified OAuth callback endpoint supporting both flows
from nextcloud_mcp_server.auth.oauth_routes import (
oauth_authorize_nextcloud,
@@ -2124,9 +2354,17 @@ def get_app(transport: str = "streamable-http", enabled_apps: list[str] | None =
)
)
logger.info(
"OAuth resource provisioning routes enabled: /oauth/authorize-nextcloud, /oauth/callback-nextcloud (Flow 2, legacy)"
"OAuth resource provisioning routes enabled: /oauth/authorize-nextcloud, /oauth/callback-nextcloud (Flow 2)"
)
# Add OAuth Flow 1 routes (MCP client login) - ONLY for OAuth modes
# Multi-user BasicAuth uses hybrid mode with only Flow 2 (resource provisioning)
if oauth_enabled:
from nextcloud_mcp_server.auth.oauth_routes import oauth_authorize
routes.append(Route("/oauth/authorize", oauth_authorize, methods=["GET"]))
logger.info("OAuth login routes enabled: /oauth/authorize (Flow 1)")
# Add browser OAuth login routes (OAuth mode only)
if oauth_enabled:
from nextcloud_mcp_server.auth.browser_oauth_routes import (