Compare commits

...

1 Commits

Author SHA1 Message Date
Chris Coutinho b50fa9b824 feat(server): Wait on OIDC client initialization 2025-10-14 23:25:40 +02:00
3 changed files with 144 additions and 14 deletions
@@ -17,6 +17,11 @@ patch -u /var/www/html/custom_apps/oidc/lib/Util/DiscoveryGenerator.php -i /dock
php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true' php /var/www/html/occ config:app:set oidc dynamic_client_registration --value='true'
php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean php /var/www/html/occ config:app:set oidc proof_key_for_code_exchange --value=true --type=boolean
# Set the OIDC issuer URL (defaults to http://localhost:8080 if not provided)
OIDC_ISSUER="${NEXTCLOUD_PUBLIC_ISSUER_URL:-http://localhost:8080}"
php /var/www/html/occ config:app:set oidc issuer --value="${OIDC_ISSUER}"
echo "OIDC issuer set to: ${OIDC_ISSUER}"
# Configure user_oidc to validate bearer tokens from the OIDC Identity Provider # Configure user_oidc to validate bearer tokens from the OIDC Identity Provider
php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean php /var/www/html/occ config:system:set user_oidc oidc_provider_bearer_validation --value=true --type=boolean
@@ -1,5 +1,6 @@
"""Dynamic client registration for Nextcloud OIDC.""" """Dynamic client registration for Nextcloud OIDC."""
import asyncio
import json import json
import logging import logging
import os import os
@@ -205,6 +206,65 @@ def save_client_to_file(client_info: ClientInfo, storage_path: Path):
raise raise
async def wait_for_client_propagation(
nextcloud_url: str,
client_id: str,
max_retries: int = 10,
initial_delay: float = 0.5,
max_delay: float = 5.0,
) -> None:
"""
Wait for the registered OAuth client to be fully propagated in Nextcloud.
This function attempts to verify the client is ready by checking if we can
access OIDC-related endpoints. Uses exponential backoff for retries.
Args:
nextcloud_url: Base URL of the Nextcloud instance
client_id: The registered client ID
max_retries: Maximum number of retry attempts
initial_delay: Initial delay in seconds before first verification
max_delay: Maximum delay between retries
Note:
This is a best-effort approach to mitigate race conditions between
client registration and first use. Nextcloud's OIDC provider may need
time to propagate newly registered clients to its cache/database.
"""
# Always wait at least the initial delay to give Nextcloud time to propagate
logger.debug(
f"Waiting {initial_delay}s for OAuth client {client_id[:16]}... to propagate"
)
await asyncio.sleep(initial_delay)
# Verify the client is accessible by checking OIDC discovery again
# (this gives Nextcloud additional time to complete any async operations)
discovery_url = f"{nextcloud_url}/.well-known/openid-configuration"
delay = initial_delay
async with httpx.AsyncClient(timeout=10.0) as client:
for attempt in range(1, max_retries + 1):
try:
response = await client.get(discovery_url)
response.raise_for_status()
logger.debug(
f"OAuth client propagation verification successful (attempt {attempt})"
)
return
except Exception as e:
if attempt < max_retries:
delay = min(delay * 1.5, max_delay)
logger.debug(
f"Verification attempt {attempt} failed: {e}. Retrying in {delay:.1f}s..."
)
await asyncio.sleep(delay)
else:
logger.warning(
f"Could not verify client propagation after {max_retries} attempts. "
"Continuing anyway - first authorization may fail."
)
async def load_or_register_client( async def load_or_register_client(
nextcloud_url: str, nextcloud_url: str,
registration_endpoint: str, registration_endpoint: str,
@@ -212,6 +272,7 @@ async def load_or_register_client(
client_name: str = "Nextcloud MCP Server", client_name: str = "Nextcloud MCP Server",
redirect_uris: list[str] | None = None, redirect_uris: list[str] | None = None,
force_register: bool = True, force_register: bool = True,
wait_for_propagation: bool = True,
) -> ClientInfo: ) -> ClientInfo:
""" """
Load client from storage or register a new one if not found/expired. Load client from storage or register a new one if not found/expired.
@@ -220,7 +281,8 @@ async def load_or_register_client(
1. Checks for existing client credentials in storage 1. Checks for existing client credentials in storage
2. Validates the credentials are not expired 2. Validates the credentials are not expired
3. Registers a new client if needed 3. Registers a new client if needed
4. Saves the new client credentials 4. Waits for the client to propagate (if newly registered)
5. Saves the new client credentials
Args: Args:
nextcloud_url: Base URL of the Nextcloud instance nextcloud_url: Base URL of the Nextcloud instance
@@ -229,6 +291,7 @@ async def load_or_register_client(
client_name: Name of the client application client_name: Name of the client application
redirect_uris: List of redirect URIs redirect_uris: List of redirect URIs
force_register: Force registration even if valid credentials exist force_register: Force registration even if valid credentials exist
wait_for_propagation: Wait for newly registered clients to propagate (default: True)
Returns: Returns:
ClientInfo with valid credentials ClientInfo with valid credentials
@@ -254,6 +317,15 @@ async def load_or_register_client(
redirect_uris=redirect_uris, redirect_uris=redirect_uris,
) )
# Wait for client to propagate in Nextcloud's OIDC provider
# This mitigates race conditions where the client is used immediately after registration
if wait_for_propagation:
logger.info("Waiting for OAuth client to propagate in Nextcloud...")
await wait_for_client_propagation(
nextcloud_url=nextcloud_url,
client_id=client_info.client_id,
)
# Save to storage # Save to storage
save_client_to_file(client_info, storage_path) save_client_to_file(client_info, storage_path)
+66 -13
View File
@@ -652,7 +652,8 @@ def oauth_callback_server():
Fixture to create an HTTP server for OAuth callback handling. Fixture to create an HTTP server for OAuth callback handling.
Yields a tuple of (auth_state, server_url) where: Yields a tuple of (auth_state, server_url) where:
- auth_state: A dict with {"code": None} that will be populated with the auth code - auth_state: A dict with {"code": None, "expected_state": None, "received_state": None}
that will be populated with the auth code and state verification
- server_url: The callback URL for the server (e.g., "http://localhost:8081") - server_url: The callback URL for the server (e.g., "http://localhost:8081")
The server automatically shuts down when the fixture is torn down. The server automatically shuts down when the fixture is torn down.
@@ -664,7 +665,7 @@ def oauth_callback_server():
from urllib.parse import parse_qs, urlparse from urllib.parse import parse_qs, urlparse
# Use a mutable container to share state across threads # Use a mutable container to share state across threads
auth_state = {"code": None} auth_state = {"code": None, "expected_state": None, "received_state": None}
httpd = None httpd = None
server_thread = None server_thread = None
@@ -689,11 +690,42 @@ def oauth_callback_server():
parsed_path = urlparse(self.path) parsed_path = urlparse(self.path)
query = parse_qs(parsed_path.query) query = parse_qs(parsed_path.query)
code = query.get("code", [None])[0] code = query.get("code", [None])[0]
state = query.get("state", [None])[0]
error = query.get("error", [None])[0]
# Check for OAuth error
if error:
error_description = query.get("error_description", ["Unknown error"])[0]
logger.error(f"OAuth error received: {error} - {error_description}")
self.send_response(400)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(
f"<html><body><h1>OAuth Error</h1><p>{error}: {error_description}</p></body></html>".encode()
)
return
# Verify state parameter if expected_state is set
if auth_state["expected_state"] and state != auth_state["expected_state"]:
logger.error(
f"State mismatch! Expected: {auth_state['expected_state'][:20]}..., "
f"Received: {state[:20] if state else 'None'}..."
)
self.send_response(400)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h1>State Verification Failed</h1><p>CSRF protection triggered.</p></body></html>"
)
return
# Only process if we have a valid code # Only process if we have a valid code
if code: if code:
auth_state["code"] = code auth_state["code"] = code
auth_state["received_state"] = state
logger.info(f"OAuth callback received. Code: {code[:20]}...") logger.info(f"OAuth callback received. Code: {code[:20]}...")
if state:
logger.debug(f"State verified: {state[:20]}...")
self.send_response(200) self.send_response(200)
self.send_header("Content-type", "text/html") self.send_header("Content-type", "text/html")
self.end_headers() self.end_headers()
@@ -741,8 +773,10 @@ async def interactive_oauth_token(oauth_callback_server) -> str:
Automatically skips when running in GitHub Actions CI. Automatically skips when running in GitHub Actions CI.
""" """
import secrets
import time import time
import webbrowser import webbrowser
from urllib.parse import urlencode
from nextcloud_mcp_server.auth.client_registration import load_or_register_client from nextcloud_mcp_server.auth.client_registration import load_or_register_client
@@ -757,25 +791,38 @@ async def interactive_oauth_token(oauth_callback_server) -> str:
token_endpoint = oidc_config.get("token_endpoint") token_endpoint = oidc_config.get("token_endpoint")
registration_endpoint = oidc_config.get("registration_endpoint") registration_endpoint = oidc_config.get("registration_endpoint")
authorization_endpoint = oidc_config.get("authorization_endpoint") authorization_endpoint = oidc_config.get("authorization_endpoint")
# Try to load existing client first, register only if needed
client_info = await load_or_register_client( client_info = await load_or_register_client(
nextcloud_url=nextcloud_host, nextcloud_url=nextcloud_host,
registration_endpoint=registration_endpoint, registration_endpoint=registration_endpoint,
storage_path=".nextcloud_oauth_test_client.json", storage_path=".nextcloud_oauth_test_client.json",
redirect_uris=[callback_url], redirect_uris=[callback_url],
force_register=True, force_register=False, # Only register if no valid client exists
wait_for_propagation=True,
) )
# First, open Nextcloud login page to establish session # Generate state parameter for CSRF protection
login_url = f"{nextcloud_host}/login" state = secrets.token_urlsafe(32)
logger.info(f"Please log in to Nextcloud at: {login_url}") auth_state["expected_state"] = state
logger.info(
"After logging in, the OAuth authorization will proceed automatically"
)
# Construct authorization URL # Construct authorization URL with proper parameters
auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri={callback_url}&scope=openid%20profile%20email" auth_params = {
"response_type": "code",
"client_id": client_info.client_id,
"redirect_uri": callback_url,
"scope": "openid profile email",
"state": state,
}
auth_url = f"{authorization_endpoint}?{urlencode(auth_params)}"
# Open authorization URL in browser # Open authorization URL in browser
# Nextcloud will automatically redirect to login if needed
logger.info("Opening OAuth authorization URL in browser...")
logger.info(
"Please log in to Nextcloud if prompted, then authorize the application."
)
logger.info(f"Authorization URL: {auth_url[:80]}...")
webbrowser.open(auth_url) webbrowser.open(auth_url)
# Wait for auth code with timeout # Wait for auth code with timeout
@@ -784,8 +831,7 @@ async def interactive_oauth_token(oauth_callback_server) -> str:
while not auth_state["code"]: while not auth_state["code"]:
if time.time() - start_time > timeout: if time.time() - start_time > timeout:
raise TimeoutError("OAuth authorization timed out after 2 minutes") raise TimeoutError("OAuth authorization timed out after 2 minutes")
logger.info("Waiting for OAuth authorization...") await asyncio.sleep(1)
time.sleep(1)
auth_code = auth_state["code"] auth_code = auth_state["code"]
logger.info("Received authorization code, exchanging for token...") logger.info("Received authorization code, exchanging for token...")
@@ -891,6 +937,13 @@ async def playwright_oauth_token(browser) -> str:
client_id = client_info_dict["client_id"] client_id = client_info_dict["client_id"]
client_secret = client_info_dict["client_secret"] client_secret = client_info_dict["client_secret"]
# Wait for client to propagate in Nextcloud's OIDC provider
# This mitigates race conditions where the client is used immediately after registration
logger.info(
f"Waiting for OAuth client {client_id[:16]}... to propagate in Nextcloud..."
)
await asyncio.sleep(0.5)
# Construct authorization URL # Construct authorization URL
auth_url = ( auth_url = (
f"{authorization_endpoint}?" f"{authorization_endpoint}?"