Merge pull request #211 from cbcoutinho/feature/docs-oauth

fix(oauth): Remove the option to force_register new clients
This commit is contained in:
Chris Coutinho
2025-10-15 17:26:09 +02:00
committed by GitHub
7 changed files with 146 additions and 120 deletions
+7 -10
View File
@@ -135,16 +135,15 @@ Each Nextcloud app has a corresponding server module that:
OAuth integration tests support both **automated** (Playwright) and **interactive** authentication flows: OAuth integration tests support both **automated** (Playwright) and **interactive** authentication flows:
**Automated Testing (Default - Recommended for CI/CD):** **Automated Testing (Default - Recommended for CI/CD):**
- **Default fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` now use Playwright automation by default - **Default fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` use Playwright automation
- Uses Playwright headless browser automation to complete OAuth flow programmatically - Uses Playwright headless browser automation to complete OAuth flow programmatically
- **Shared OAuth Client**: All test users authenticate using a single OAuth client (matching MCP server behavior) - **Shared OAuth Client**: All test users authenticate using a single OAuth client
- Single `client_id`/`client_secret` pair is registered and reused for all test users - Stored in `.nextcloud_oauth_shared_test_client.json`
- Stored in `.nextcloud_oauth_shared_test_client.json` with `force_register=False` for reuse - Matches production MCP server behavior
- Reduces OAuth client registrations and matches production MCP server architecture - Each user gets their own unique access token
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py:812`
- All Playwright fixtures: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`, `nc_oauth_client_playwright`, `nc_mcp_oauth_client_playwright` - All Playwright fixtures: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`, `nc_oauth_client_playwright`, `nc_mcp_oauth_client_playwright`
- Multi-user fixtures: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token` - Multi-user fixtures: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
- All use `shared_oauth_client_credentials` fixture for consistent client credentials
- Each user gets unique access tokens via same OAuth client (like multiple users using the MCP server)
- Requires: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables - Requires: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
- Uses `pytest-playwright-asyncio` for async Playwright fixtures - Uses `pytest-playwright-asyncio` for async Playwright fixtures
- Playwright configuration: Use pytest CLI args like `--browser firefox --headed` to customize - Playwright configuration: Use pytest CLI args like `--browser firefox --headed` to customize
@@ -179,14 +178,12 @@ OAuth integration tests support both **automated** (Playwright) and **interactiv
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing - `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth` - Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp` - **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
- Shared OAuth client is registered once and reused across test runs - OAuth client credentials cached in `.nextcloud_oauth_shared_test_client.json`
- Client credentials cached in `.nextcloud_oauth_shared_test_client.json`
**CI/CD Considerations:** **CI/CD Considerations:**
- Interactive OAuth tests are automatically skipped when `GITHUB_ACTIONS` environment variable is set - Interactive OAuth tests are automatically skipped when `GITHUB_ACTIONS` environment variable is set
- Automated Playwright tests will run in CI/CD environments - Automated Playwright tests will run in CI/CD environments
- Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects) - Use Firefox browser in CI: `--browser firefox` (Chromium may have issues with localhost redirects)
- Shared client approach reduces test time and API calls to Nextcloud
### Configuration Files ### Configuration Files
+4 -3
View File
@@ -217,11 +217,12 @@ NEXTCLOUD_HOST=https://nextcloud.example.com
**How it works**: **How it works**:
1. Server checks `/.well-known/openid-configuration` for `registration_endpoint` 1. Server checks `/.well-known/openid-configuration` for `registration_endpoint`
2. Calls `/apps/oidc/register` to register new client 2. Calls `/apps/oidc/register` to register a client on first startup
3. Saves credentials to `.nextcloud_oauth_client.json` 3. Saves credentials to `.nextcloud_oauth_client.json`
4. Re-registers if credentials expire 4. Reuses these credentials on subsequent startups
5. Re-registers only if credentials are missing or expired
**Best for**: Development, testing, short-lived deployments **Best for**: Development, testing, quick deployments
### Pre-configured Client ### Pre-configured Client
+7 -7
View File
@@ -165,23 +165,23 @@ You have two options for managing OAuth clients:
### Mode A: Automatic Registration (Dynamic Client Registration) ### Mode A: Automatic Registration (Dynamic Client Registration)
**Best for**: Development, testing, short-lived deployments **Best for**: Development, testing, quick deployments
**How it works**: **How it works**:
- MCP server automatically registers OAuth client at startup - MCP server automatically registers an OAuth client on first startup
- Uses Nextcloud's dynamic client registration endpoint - Uses Nextcloud's dynamic client registration endpoint
- Saves credentials to `.nextcloud_oauth_client.json` - Saves credentials to `.nextcloud_oauth_client.json`
- Reuses stored credentials on subsequent restarts
- Re-registers automatically if credentials expire - Re-registers automatically if credentials expire
**Pros**: **Pros**:
- Zero configuration required - Zero configuration required
- Quick setup - Quick setup
- No manual client management - Automatic credential management
**Cons**: **Cons**:
- Clients expire (default: 1 hour, configurable) - Clients expire (default: 1 hour, configurable)
- Must re-register on restart if expired - Must have dynamic client registration enabled on Nextcloud
- Not ideal for long-running production
**Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config. **Configuration**: Skip to [Step 4](#step-4-configure-mcp-server) with minimal config.
@@ -192,8 +192,8 @@ You have two options for managing OAuth clients:
**Best for**: Production, long-running deployments, stable environments **Best for**: Production, long-running deployments, stable environments
**How it works**: **How it works**:
- You manually register OAuth client via Nextcloud CLI - You manually register an OAuth client via Nextcloud CLI
- Provide client credentials to MCP server - Provide client credentials to MCP server via environment variables
- Credentials don't expire - Credentials don't expire
**Pros**: **Pros**:
+4 -4
View File
@@ -151,11 +151,11 @@ curl https://your.nextcloud.instance.com/.well-known/openid-configuration
This quick start uses **automatic client registration** which is perfect for: This quick start uses **automatic client registration** which is perfect for:
- Development - Development
- Testing - Testing
- Short-lived deployments - Quick deployments
For **production deployments**, you should: For **production deployments**, consider:
1. Pre-register OAuth clients manually 1. Pre-registering OAuth client manually
2. Use dedicated client credentials 2. Using dedicated client credentials that don't expire
3. See [OAuth Setup Guide](oauth-setup.md) for production configuration 3. See [OAuth Setup Guide](oauth-setup.md) for production configuration
--- ---
@@ -211,7 +211,6 @@ async def load_or_register_client(
storage_path: str | Path, storage_path: str | Path,
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,
) -> 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.
@@ -219,7 +218,7 @@ async def load_or_register_client(
This function: This function:
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 (no stored credentials or expired)
4. Saves the new client credentials 4. Saves the new client credentials
Args: Args:
@@ -228,7 +227,6 @@ async def load_or_register_client(
storage_path: Path to store client credentials storage_path: Path to store client credentials
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
Returns: Returns:
ClientInfo with valid credentials ClientInfo with valid credentials
@@ -239,11 +237,10 @@ async def load_or_register_client(
""" """
storage_path = Path(storage_path) storage_path = Path(storage_path)
# Try to load existing client unless forced to register # Try to load existing client
if not force_register: client_info = load_client_from_file(storage_path)
client_info = load_client_from_file(storage_path) if client_info:
if client_info: return client_info
return client_info
# Register new client # Register new client
logger.info("Registering new OAuth client...") logger.info("Registering new OAuth client...")
+2 -2
View File
@@ -22,8 +22,8 @@ asyncio_mode = "auto"
asyncio_default_test_loop_scope = "session" asyncio_default_test_loop_scope = "session"
asyncio_default_fixture_loop_scope = "session" asyncio_default_fixture_loop_scope = "session"
log_cli = 1 log_cli = 1
log_cli_level = "INFO" log_cli_level = "WARN"
log_level = "INFO" log_level = "WARN"
markers = [ markers = [
"integration: marks tests as slow (deselect with '-m \"not slow\"')", "integration: marks tests as slow (deselect with '-m \"not slow\"')",
"oauth: marks tests as oauth (deselect with '-m \"not oauth\"')" "oauth: marks tests as oauth (deselect with '-m \"not oauth\"')"
+117 -86
View File
@@ -651,8 +651,10 @@ 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: Supports multiple concurrent OAuth flows using state parameters for correlation.
- auth_state: A dict with {"code": None} that will be populated with the auth code
Yields a tuple of (auth_states, server_url) where:
- auth_states: A dict mapping state parameter to auth code
- 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.
@@ -663,8 +665,9 @@ def oauth_callback_server():
from http.server import BaseHTTPRequestHandler, HTTPServer from http.server import BaseHTTPRequestHandler, HTTPServer
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 dict to store auth codes keyed by state parameter
auth_state = {"code": None} # This allows multiple concurrent OAuth flows
auth_states = {}
httpd = None httpd = None
server_thread = None server_thread = None
@@ -674,26 +677,27 @@ def oauth_callback_server():
pass pass
def do_GET(self): def do_GET(self):
# Ignore subsequent requests if we already have a code
# (this is a session-scoped fixture, so only process the first auth code)
if auth_state["code"] is not None:
self.send_response(200)
self.send_header("Content-type", "text/html")
self.end_headers()
self.wfile.write(
b"<html><body><h1>Authentication already completed</h1></body></html>"
)
return
# Parse the callback request # Parse the callback request
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]
# Only process if we have a valid code # Only process if we have a valid code
if code: if code:
auth_state["code"] = code # Store code keyed by state parameter for correlation
logger.info(f"OAuth callback received. Code: {code[:20]}...") if state:
auth_states[state] = code
logger.info(
f"OAuth callback received for state={state[:16]}... Code: {code[:20]}..."
)
else:
# Fallback for flows without state parameter (legacy interactive flow)
auth_states["_default"] = code
logger.info(
f"OAuth callback received (no state). Code: {code[: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()
@@ -714,8 +718,8 @@ def oauth_callback_server():
server_thread.start() server_thread.start()
logger.info("OAuth callback server started on http://localhost:8081") logger.info("OAuth callback server started on http://localhost:8081")
# Yield the auth state and server URL # Yield the auth states dict and server URL
yield auth_state, "http://localhost:8081" yield auth_states, "http://localhost:8081"
finally: finally:
# Clean up the server # Clean up the server
@@ -746,8 +750,8 @@ async def interactive_oauth_token(oauth_callback_server) -> str:
from nextcloud_mcp_server.auth.client_registration import load_or_register_client from nextcloud_mcp_server.auth.client_registration import load_or_register_client
# Unpack the server fixture # Unpack the server fixture (now returns dict of auth_states)
auth_state, callback_url = oauth_callback_server auth_states, callback_url = oauth_callback_server
nextcloud_host = os.getenv("NEXTCLOUD_HOST") nextcloud_host = os.getenv("NEXTCLOUD_HOST")
async with httpx.AsyncClient() as http_client: async with httpx.AsyncClient() as http_client:
@@ -762,7 +766,6 @@ async def interactive_oauth_token(oauth_callback_server) -> str:
registration_endpoint=registration_endpoint, registration_endpoint=registration_endpoint,
storage_path=".nextcloud_oauth_shared_test_client.json", storage_path=".nextcloud_oauth_shared_test_client.json",
redirect_uris=[callback_url], redirect_uris=[callback_url],
force_register=False, # Reuse existing credentials if valid
) )
# First, open Nextcloud login page to establish session # First, open Nextcloud login page to establish session
@@ -772,22 +775,22 @@ async def interactive_oauth_token(oauth_callback_server) -> str:
"After logging in, the OAuth authorization will proceed automatically" "After logging in, the OAuth authorization will proceed automatically"
) )
# Construct authorization URL # Construct authorization URL (no state parameter for interactive flow)
auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri={callback_url}&scope=openid%20profile%20email" auth_url = f"{authorization_endpoint}?response_type=code&client_id={client_info.client_id}&redirect_uri={callback_url}&scope=openid%20profile%20email"
# Open authorization URL in browser # Open authorization URL in browser
webbrowser.open(auth_url) webbrowser.open(auth_url)
# Wait for auth code with timeout # Wait for auth code with timeout (uses "_default" key for flows without state)
timeout = 120 # 2 minutes timeout = 120 # 2 minutes
start_time = time.time() start_time = time.time()
while not auth_state["code"]: while "_default" not in auth_states:
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...") logger.info("Waiting for OAuth authorization...")
time.sleep(1) time.sleep(1)
auth_code = auth_state["code"] auth_code = auth_states["_default"]
logger.info("Received authorization code, exchanging for token...") logger.info("Received authorization code, exchanging for token...")
token_response = await http_client.post( token_response = await http_client.post(
@@ -810,13 +813,15 @@ async def interactive_oauth_token(oauth_callback_server) -> str:
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def shared_oauth_client_credentials(): async def shared_oauth_client_credentials(oauth_callback_server):
""" """
Fixture to obtain shared OAuth client credentials that will be reused for all users. Fixture to obtain shared OAuth client credentials that will be reused for all users.
This registers a single OAuth client with Nextcloud that matches the MCP server's This registers a single OAuth client with Nextcloud that matches the MCP server's
registration, allowing all test users to authenticate using the same client_id/secret. registration, allowing all test users to authenticate using the same client_id/secret.
Now uses the real OAuth callback server for reliable token acquisition.
Returns: Returns:
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint) Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
""" """
@@ -826,7 +831,11 @@ async def shared_oauth_client_credentials():
if not nextcloud_host: if not nextcloud_host:
pytest.skip("Shared OAuth client requires NEXTCLOUD_HOST") pytest.skip("Shared OAuth client requires NEXTCLOUD_HOST")
# Get callback URL from the real callback server
auth_states, callback_url = oauth_callback_server
logger.info("Setting up shared OAuth client credentials for all test users...") logger.info("Setting up shared OAuth client credentials for all test users...")
logger.info(f"Using real callback server at: {callback_url}")
async with httpx.AsyncClient(timeout=30.0) as http_client: async with httpx.AsyncClient(timeout=30.0) as http_client:
# OIDC Discovery # OIDC Discovery
@@ -842,9 +851,6 @@ async def shared_oauth_client_credentials():
if not all([token_endpoint, registration_endpoint, authorization_endpoint]): if not all([token_endpoint, registration_endpoint, authorization_endpoint]):
raise ValueError("OIDC discovery missing required endpoints") raise ValueError("OIDC discovery missing required endpoints")
# Use callback URL that won't actually be used (we extract code from browser URL)
callback_url = "http://localhost:9999/oauth/callback"
# Register or load shared OAuth client (matches MCP server registration) # Register or load shared OAuth client (matches MCP server registration)
client_info = await load_or_register_client( client_info = await load_or_register_client(
nextcloud_url=nextcloud_host, nextcloud_url=nextcloud_host,
@@ -852,7 +858,6 @@ async def shared_oauth_client_credentials():
storage_path=".nextcloud_oauth_shared_test_client.json", storage_path=".nextcloud_oauth_shared_test_client.json",
client_name="Nextcloud MCP Server - Shared Test Client", client_name="Nextcloud MCP Server - Shared Test Client",
redirect_uris=[callback_url], redirect_uris=[callback_url],
force_register=False, # Reuse existing credentials if valid
) )
logger.info(f"Shared OAuth client ready: {client_info.client_id[:16]}...") logger.info(f"Shared OAuth client ready: {client_info.client_id[:16]}...")
@@ -868,7 +873,9 @@ async def shared_oauth_client_credentials():
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> str: async def playwright_oauth_token(
browser, shared_oauth_client_credentials, oauth_callback_server
) -> str:
""" """
Fixture to obtain an OAuth access token using Playwright headless browser automation. Fixture to obtain an OAuth access token using Playwright headless browser automation.
@@ -877,7 +884,7 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
2. Navigating to authorization URL in headless browser 2. Navigating to authorization URL in headless browser
3. Programmatically filling in login form 3. Programmatically filling in login form
4. Handling OAuth consent 4. Handling OAuth consent
5. Extracting auth code from redirect 5. Waiting for callback server to receive auth code (NEW: using real callback server!)
6. Exchanging code for access token 6. Exchanging code for access token
Environment variables required: Environment variables required:
@@ -890,7 +897,9 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
- Browser fixture provided by pytest-playwright-asyncio - Browser fixture provided by pytest-playwright-asyncio
- See: https://playwright.dev/python/docs/test-runners - See: https://playwright.dev/python/docs/test-runners
""" """
from urllib.parse import parse_qs, urlparse import secrets
import time
from urllib.parse import quote
nextcloud_host = os.getenv("NEXTCLOUD_HOST") nextcloud_host = os.getenv("NEXTCLOUD_HOST")
username = os.getenv("NEXTCLOUD_USERNAME") username = os.getenv("NEXTCLOUD_USERNAME")
@@ -901,6 +910,9 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
"Playwright OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD" "Playwright OAuth requires NEXTCLOUD_HOST, NEXTCLOUD_USERNAME, and NEXTCLOUD_PASSWORD"
) )
# Get auth_states dict from callback server
auth_states, _ = oauth_callback_server
# Unpack shared client credentials # Unpack shared client credentials
client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = ( client_id, client_secret, callback_url, token_endpoint, authorization_endpoint = (
shared_oauth_client_credentials shared_oauth_client_credentials
@@ -908,13 +920,19 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
logger.info(f"Starting Playwright-based OAuth flow for {username}...") logger.info(f"Starting Playwright-based OAuth flow for {username}...")
logger.info(f"Using shared OAuth client: {client_id[:16]}...") logger.info(f"Using shared OAuth client: {client_id[:16]}...")
logger.info(f"Using real callback server at: {callback_url}")
# Construct authorization URL # Generate unique state parameter for this OAuth flow
state = secrets.token_urlsafe(32)
logger.debug(f"Generated state: {state[:16]}...")
# Construct authorization URL with state parameter
auth_url = ( auth_url = (
f"{authorization_endpoint}?" f"{authorization_endpoint}?"
f"response_type=code&" f"response_type=code&"
f"client_id={client_id}&" f"client_id={client_id}&"
f"redirect_uri={callback_url}&" f"redirect_uri={quote(callback_url, safe='')}&"
f"state={state}&"
f"scope=openid%20profile%20email" f"scope=openid%20profile%20email"
) )
@@ -971,33 +989,24 @@ async def playwright_oauth_token(browser, shared_oauth_client_credentials) -> st
except Exception as e: except Exception as e:
logger.debug(f"No authorization button found or already authorized: {e}") logger.debug(f"No authorization button found or already authorized: {e}")
# Wait for redirect to callback URL (which will fail to load, but we just need the URL) # Wait for callback server to receive the auth code
try: # Browser will be redirected to localhost:8081 which will capture the code
# The redirect might fail since localhost:9999 isn't actually running logger.info("Waiting for callback server to receive auth code...")
# But we can still extract the code from the URL timeout_seconds = 30
await page.wait_for_url(f"{callback_url}*", timeout=10000) start_time = time.time()
except Exception as e: while state not in auth_states:
# Expected - the callback URL won't load, but we should have the URL if time.time() - start_time > timeout_seconds:
logger.debug(f"Callback redirect (expected to fail): {e}") # Take a screenshot for debugging
screenshot_path = "/tmp/playwright_oauth_error.png"
await page.screenshot(path=screenshot_path)
logger.error(f"Screenshot saved to {screenshot_path}")
raise TimeoutError(
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
)
await asyncio.sleep(0.5)
# Extract auth code from URL auth_code = auth_states[state]
final_url = page.url logger.info(f"Successfully received authorization code: {auth_code[:20]}...")
logger.debug(f"Final URL: {final_url}")
parsed_url = urlparse(final_url)
query_params = parse_qs(parsed_url.query)
auth_code = query_params.get("code", [None])[0]
if not auth_code:
# Take a screenshot for debugging
screenshot_path = "/tmp/playwright_oauth_error.png"
await page.screenshot(path=screenshot_path)
logger.error(f"Screenshot saved to {screenshot_path}")
raise ValueError(
f"No authorization code found in redirect URL: {final_url}"
)
logger.info(f"Successfully extracted authorization code: {auth_code[:20]}...")
finally: finally:
await context.close() await context.close()
@@ -1236,23 +1245,31 @@ async def test_users_setup(nc_client: NextcloudClient):
async def _get_oauth_token_for_user( async def _get_oauth_token_for_user(
browser, shared_oauth_client_credentials, username: str, password: str browser,
shared_oauth_client_credentials,
auth_states,
username: str,
password: str,
) -> str: ) -> str:
""" """
Helper function to get OAuth access token for a user via Playwright. Helper function to get OAuth access token for a user via Playwright.
Uses shared OAuth client credentials to authenticate multiple users with the same client. Uses shared OAuth client credentials to authenticate multiple users with the same client.
Now uses real callback server with state parameters for reliable token acquisition.
Args: Args:
browser: Playwright browser instance browser: Playwright browser instance
shared_oauth_client_credentials: Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint) shared_oauth_client_credentials: Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
auth_states: Dict mapping state parameters to auth codes (from callback server)
username: Username to authenticate as username: Username to authenticate as
password: Password for the user password: Password for the user
Returns: Returns:
OAuth access token string OAuth access token string
""" """
from urllib.parse import parse_qs, urlparse import secrets
import time
from urllib.parse import quote
nextcloud_host = os.getenv("NEXTCLOUD_HOST") nextcloud_host = os.getenv("NEXTCLOUD_HOST")
@@ -1267,14 +1284,17 @@ async def _get_oauth_token_for_user(
logger.info(f"Getting OAuth token for user: {username}...") logger.info(f"Getting OAuth token for user: {username}...")
logger.info(f"Using shared OAuth client: {client_id[:16]}...") logger.info(f"Using shared OAuth client: {client_id[:16]}...")
# Construct authorization URL with properly encoded redirect_uri # Generate unique state parameter for this OAuth flow
from urllib.parse import quote state = secrets.token_urlsafe(32)
logger.debug(f"Generated state for {username}: {state[:16]}...")
# Construct authorization URL with state parameter
auth_url = ( auth_url = (
f"{authorization_endpoint}?" f"{authorization_endpoint}?"
f"response_type=code&" f"response_type=code&"
f"client_id={client_id}&" f"client_id={client_id}&"
f"redirect_uri={quote(callback_url, safe='')}&" f"redirect_uri={quote(callback_url, safe='')}&"
f"state={state}&"
f"scope=openid%20profile%20email" f"scope=openid%20profile%20email"
) )
@@ -1311,22 +1331,25 @@ async def _get_oauth_token_for_user(
except Exception as e: except Exception as e:
logger.debug(f"No authorization needed for {username}: {e}") logger.debug(f"No authorization needed for {username}: {e}")
# Wait for redirect and extract auth code # Wait for callback server to receive the auth code
try: # Browser will be redirected to localhost:8081 which will capture the code
await page.wait_for_url(f"{callback_url}*", timeout=30000) logger.info(
except Exception: f"Waiting for callback server to receive auth code for {username}..."
pass # Expected - callback won't load )
timeout_seconds = 30
final_url = page.url start_time = time.time()
parsed_url = urlparse(final_url) while state not in auth_states:
query_params = parse_qs(parsed_url.query) if time.time() - start_time > timeout_seconds:
auth_code = query_params.get("code", [None])[0] # Take screenshot for debugging
screenshot_path = f"/tmp/playwright_oauth_timeout_{username}.png"
if not auth_code: await page.screenshot(path=screenshot_path)
raise ValueError( logger.error(f"Screenshot saved to {screenshot_path}")
f"No authorization code found for {username} in URL: {final_url}" raise TimeoutError(
) f"Timeout waiting for OAuth callback for {username} (state={state[:16]}...)"
)
await asyncio.sleep(0.5)
auth_code = auth_states[state]
logger.info(f"Got auth code for {username}: {auth_code[:20]}...") logger.info(f"Got auth code for {username}: {auth_code[:20]}...")
finally: finally:
@@ -1360,7 +1383,7 @@ async def _get_oauth_token_for_user(
# Parallel token retrieval fixture - fetches all OAuth tokens concurrently # Parallel token retrieval fixture - fetches all OAuth tokens concurrently
@pytest.fixture(scope="session") @pytest.fixture(scope="session")
async def all_oauth_tokens( async def all_oauth_tokens(
browser, shared_oauth_client_credentials, test_users_setup browser, shared_oauth_client_credentials, test_users_setup, oauth_callback_server
) -> dict[str, str]: ) -> dict[str, str]:
""" """
Fetch OAuth tokens for all test users in parallel for speed. Fetch OAuth tokens for all test users in parallel for speed.
@@ -1368,26 +1391,34 @@ async def all_oauth_tokens(
Returns a dict mapping username to OAuth access token. Returns a dict mapping username to OAuth access token.
This is significantly faster than fetching tokens sequentially. This is significantly faster than fetching tokens sequentially.
Note: We add a small stagger between starting each flow to avoid Now uses the real callback server with state parameters for reliable
race conditions in Nextcloud's OAuth session handling. concurrent token acquisition without race conditions.
""" """
import asyncio import asyncio
import time import time
# Get auth_states dict from callback server
auth_states, callback_url = oauth_callback_server
start_time = time.time() start_time = time.time()
logger.info("Fetching OAuth tokens for all users in parallel...") logger.info("Fetching OAuth tokens for all users in parallel...")
logger.info(f"Using callback server at {callback_url} with state-based correlation")
async def get_token_with_delay(username: str, config: dict, delay: float): async def get_token_with_delay(username: str, config: dict, delay: float):
"""Get token for a user after a small delay to stagger requests.""" """Get token for a user after a small delay to stagger requests."""
if delay > 0: if delay > 0:
await asyncio.sleep(delay) await asyncio.sleep(delay)
return await _get_oauth_token_for_user( return await _get_oauth_token_for_user(
browser, shared_oauth_client_credentials, username, config["password"] browser,
shared_oauth_client_credentials,
auth_states,
username,
config["password"],
) )
# Create tasks for all users with staggered starts (2.0s apart) # Create tasks for all users with staggered starts (2.0s apart)
tasks = { tasks = {
username: get_token_with_delay(username, config, (idx + 1) * 2.0) username: get_token_with_delay(username, config, idx * 0.5)
for idx, (username, config) in enumerate(test_users_setup.items()) for idx, (username, config) in enumerate(test_users_setup.items())
} }