diff --git a/app-hooks/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch b/app-hooks/patches/0001-Fix-Bearer-token-authentication-causing-session-logo.patch similarity index 100% rename from app-hooks/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch rename to app-hooks/patches/0001-Fix-Bearer-token-authentication-causing-session-logo.patch diff --git a/app-hooks/patches/cors-bearer-token.patch b/app-hooks/patches/cors-bearer-token.patch new file mode 100644 index 0000000..2186db9 --- /dev/null +++ b/app-hooks/patches/cors-bearer-token.patch @@ -0,0 +1,18 @@ +diff --git a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php +index 4453f5a7d4b..f1ca9b48d21 100644 +--- a/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php ++++ b/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php +@@ -73,6 +73,13 @@ class CORSMiddleware extends Middleware { + $user = array_key_exists('PHP_AUTH_USER', $this->request->server) ? $this->request->server['PHP_AUTH_USER'] : null; + $pass = array_key_exists('PHP_AUTH_PW', $this->request->server) ? $this->request->server['PHP_AUTH_PW'] : null; + ++ // Allow Bearer token authentication for CORS requests ++ // Bearer tokens are stateless and don't require CSRF protection ++ $authorizationHeader = $this->request->getHeader('Authorization'); ++ if (!empty($authorizationHeader) && str_starts_with($authorizationHeader, 'Bearer ')) { ++ return; ++ } ++ + // Allow to use the current session if a CSRF token is provided + if ($this->request->passesCSRFCheck()) { + return; diff --git a/app-hooks/post-installation/10-install-user_oidc-app.sh b/app-hooks/post-installation/10-install-user_oidc-app.sh index 282966c..dbfa582 100755 --- a/app-hooks/post-installation/10-install-user_oidc-app.sh +++ b/app-hooks/post-installation/10-install-user_oidc-app.sh @@ -15,4 +15,4 @@ php /var/www/html/occ config:system:set user_oidc httpclient.allowselfsigned --v # This enables user_oidc to fetch JWKS from internal Keycloak container php /var/www/html/occ config:system:set allow_local_remote_servers --value=true --type=boolean -patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/post-installation/0001-Fix-Bearer-token-authentication-causing-session-logo.patch +patch -u /var/www/html/custom_apps/user_oidc/lib/User/Backend.php -i /docker-entrypoint-hooks.d/patches/0001-Fix-Bearer-token-authentication-causing-session-logo.patch diff --git a/app-hooks/post-installation/20-apply-cors-bearer-token-patch.sh b/app-hooks/post-installation/20-apply-cors-bearer-token-patch.sh new file mode 100755 index 0000000..629a977 --- /dev/null +++ b/app-hooks/post-installation/20-apply-cors-bearer-token-patch.sh @@ -0,0 +1,64 @@ +#!/bin/bash +# +# Apply upstream CORSMiddleware Bearer token authentication patch +# +# This patch allows Bearer tokens to bypass CORS/CSRF checks, fixing +# authentication issues with app-specific APIs (Notes, Calendar, etc.) +# when using OAuth/OIDC Bearer tokens. +# +# Upstream PR: https://github.com/nextcloud/server/pull/XXXXX +# Commit: 8fb5e77db82 (fix(cors): Allow Bearer token authentication) +# + +set -e + +PATCH_FILE="/docker-entrypoint-hooks.d/patches/cors-bearer-token.patch" +TARGET_FILE="/var/www/html/lib/private/AppFramework/Middleware/Security/CORSMiddleware.php" + +echo "====================================================================" +echo "Applying CORSMiddleware Bearer token authentication patch..." +echo "====================================================================" + +# Check if patch file exists +if [ ! -f "$PATCH_FILE" ]; then + echo "⚠ Warning: Patch file not found: $PATCH_FILE" + echo " Skipping CORS Bearer token patch" + exit 0 +fi + +# Check if target file exists +if [ ! -f "$TARGET_FILE" ]; then + echo "⚠ Warning: Target file not found: $TARGET_FILE" + echo " Skipping CORS Bearer token patch" + exit 0 +fi + +# Check if already patched +if grep -q "Allow Bearer token authentication for CORS requests" "$TARGET_FILE"; then + echo "✓ CORSMiddleware already patched for Bearer token support" + exit 0 +fi + +echo "Applying patch to CORSMiddleware.php..." + +# Apply the patch +cd /var/www/html +if patch -p1 --dry-run < "$PATCH_FILE" > /dev/null 2>&1; then + patch -p1 < "$PATCH_FILE" + echo "✓ Patch applied successfully" +else + echo "⚠ Warning: Patch failed to apply (may already be applied or file changed)" + echo " This is expected if using a Nextcloud version that already includes the fix" + exit 0 +fi + +echo "" +echo "====================================================================" +echo "✓ CORSMiddleware Bearer token patch applied" +echo "====================================================================" +echo "" +echo "Benefits:" +echo " • Bearer tokens now work with app-specific APIs (Notes, Calendar, etc.)" +echo " • OAuth/OIDC authentication works without CORS errors" +echo " • Stateless API authentication is properly supported" +echo "" diff --git a/docker-compose.yml b/docker-compose.yml index 40a7011..37e6fa2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -30,10 +30,10 @@ services: - db volumes: - nextcloud:/var/www/html - - ./app-hooks/post-installation:/docker-entrypoint-hooks.d/post-installation:ro + - ./app-hooks:/docker-entrypoint-hooks.d:ro # Mount OIDC development directory outside /var/www/html to avoid rsync conflicts # The post-installation hook will register /opt/apps as an additional app directory - - ./third_party/oidc:/opt/apps/oidc:ro + - ./third_party:/opt/apps:ro environment: - NEXTCLOUD_TRUSTED_DOMAINS=app - NEXTCLOUD_ADMIN_USER=admin @@ -43,11 +43,11 @@ services: - MYSQL_USER=nextcloud - MYSQL_HOST=db - REDIS_HOST=redis - healthcheck: - test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"] - interval: 10s - timeout: 30s - retries: 30 + #healthcheck: + #test: ["CMD-SHELL", "curl -Ss http://localhost/status.php | grep '\"installed\":true' || exit 1"] + #interval: 10s + #timeout: 30s + #retries: 30 recipes: image: docker.io/library/nginx:alpine@sha256:b3c656d55d7ad751196f21b7fd2e8d4da9cb430e32f646adcf92441b72f82b14 @@ -115,6 +115,7 @@ services: - "start-dev" - "--import-realm" - "--hostname=http://localhost:8888" + - "--hostname-strict=false" - "--hostname-backchannel-dynamic=true" ports: - 127.0.0.1:8888:8080 diff --git a/env.sample b/env.sample index ef79c31..884217a 100644 --- a/env.sample +++ b/env.sample @@ -8,12 +8,19 @@ NEXTCLOUD_HOST= # - Requires Nextcloud OIDC app installed and configured # - Admin must enable "Dynamic Client Registration" in OIDC app settings # - Leave NEXTCLOUD_USERNAME and NEXTCLOUD_PASSWORD empty to use OAuth mode +# - OAuth client credentials are stored encrypted in SQLite (TOKEN_STORAGE_DB) # - Optional: Pre-register client and provide credentials (otherwise auto-registers) NEXTCLOUD_OIDC_CLIENT_ID= NEXTCLOUD_OIDC_CLIENT_SECRET= -NEXTCLOUD_OIDC_CLIENT_STORAGE=.nextcloud_oauth_client.json NEXTCLOUD_MCP_SERVER_URL=http://localhost:8000 +# OAuth Storage Configuration (SQLite storage for OAuth clients and refresh tokens) +# TOKEN_ENCRYPTION_KEY: Required for encrypting OAuth client secrets and refresh tokens +# Generate with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())" +#TOKEN_ENCRYPTION_KEY= +# TOKEN_STORAGE_DB: Path to SQLite database (default: /app/data/tokens.db) +#TOKEN_STORAGE_DB=/app/data/tokens.db + # Option 2: Basic Authentication (LEGACY - Less Secure) # - Requires username and password # - Credentials stored in environment variables diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json index 9645cd6..84aa6f8 100644 --- a/keycloak/realm-export.json +++ b/keycloak/realm-export.json @@ -45,7 +45,10 @@ "description": "${role_default-roles}", "composite": true, "composites": { - "realm": ["offline_access", "uma_authorization"] + "realm": [ + "offline_access", + "uma_authorization" + ] }, "clientRole": false } @@ -66,9 +69,14 @@ "temporary": false } ], - "realmRoles": ["default-roles-nextcloud-mcp", "offline_access"], + "realmRoles": [ + "default-roles-nextcloud-mcp", + "offline_access" + ], "attributes": { - "quota": ["1073741824"] + "quota": [ + "1073741824" + ] } } ], @@ -108,7 +116,9 @@ "http://localhost:*/callback", "http://127.0.0.1:*/callback" ], - "webOrigins": ["+"], + "webOrigins": [ + "+" + ], "bearerOnly": false, "consentRequired": false, "standardFlowEnabled": true, @@ -212,7 +222,12 @@ } } ], - "defaultClientScopes": ["web-origins", "profile", "roles", "email"], + "defaultClientScopes": [ + "web-origins", + "profile", + "roles", + "email" + ], "optionalClientScopes": [ "address", "phone", @@ -268,6 +283,48 @@ "access.token.claim": "true", "userinfo.token.claim": "true" } + }, + { + "name": "username", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "username", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "preferred_username", + "jsonType.label": "String" + } + }, + { + "name": "given name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "firstName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "given_name", + "jsonType.label": "String" + } + }, + { + "name": "family name", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-property-mapper", + "consentRequired": false, + "config": { + "userinfo.token.claim": "true", + "user.attribute": "lastName", + "id.token.claim": "true", + "access.token.claim": "true", + "claim.name": "family_name", + "jsonType.label": "String" + } } ] }, @@ -544,6 +601,101 @@ "display.on.consent.screen": "true", "consent.screen.text": "Create, update, and delete tasks" } + }, + { + "name": "audience", + "description": "Audience scope for token validation", + "protocol": "openid-connect", + "attributes": { + "include.in.token.scope": "true", + "display.on.consent.screen": "false" + }, + "protocolMappers": [ + { + "name": "mcp-server-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "nextcloud-mcp-server", + "id.token.claim": "false", + "access.token.claim": "true" + } + }, + { + "name": "nextcloud-audience", + "protocol": "openid-connect", + "protocolMapper": "oidc-audience-mapper", + "consentRequired": false, + "config": { + "included.client.audience": "nextcloud", + "id.token.claim": "false", + "access.token.claim": "true" + } + } + ] } + ], + "components": { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy": [ + { + "name": "Trusted Hosts", + "providerId": "trusted-hosts", + "subType": "anonymous", + "subComponents": {}, + "config": { + "trusted-hosts": [ + "localhost", + "127.0.0.1", + "172.19.0.1" + ], + "host-sending-registration-request-must-match": [ + "false" + ], + "client-uris-must-match": [ + "true" + ] + } + }, + { + "name": "Max Clients", + "providerId": "max-clients", + "subType": "anonymous", + "subComponents": {}, + "config": { + "max-clients": [ + "200" + ] + } + } + ] + }, + "defaultDefaultClientScopes": [ + "profile", + "email", + "roles", + "web-origins", + "audience" + ], + "defaultOptionalClientScopes": [ + "offline_access", + "notes:read", + "notes:write", + "calendar:read", + "calendar: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", + "todo:read", + "todo:write" ] } diff --git a/nextcloud_mcp_server/app.py b/nextcloud_mcp_server/app.py index 4bde439..3d96102 100644 --- a/nextcloud_mcp_server/app.py +++ b/nextcloud_mcp_server/app.py @@ -268,21 +268,22 @@ async def load_oauth_client_credentials( logger.info("Using pre-configured OAuth client credentials from environment") return (client_id, client_secret) - # Try loading from storage file - storage_path = os.getenv( - "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" - ) - from pathlib import Path + # Try loading from SQLite storage + try: + from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage - from nextcloud_mcp_server.auth.client_registration import load_client_from_file + storage = RefreshTokenStorage.from_env() + await storage.initialize() - client_info = load_client_from_file(Path(storage_path)) - - if client_info: - logger.info( - f"Loaded OAuth client from storage: {client_info.client_id[:16]}..." - ) - return (client_info.client_id, client_info.client_secret) + client_data = await storage.get_oauth_client() + if client_data: + logger.info( + f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..." + ) + return (client_data["client_id"], client_data["client_secret"]) + except ValueError: + # TOKEN_ENCRYPTION_KEY not set, skip SQLite storage check + logger.debug("SQLite storage not available (TOKEN_ENCRYPTION_KEY not set)") # Try dynamic registration if available if registration_endpoint: @@ -334,15 +335,17 @@ async def load_oauth_client_credentials( token_type = "Bearer" logger.info(f"Requesting token type: {token_type}") - # Load or register client - from nextcloud_mcp_server.auth.client_registration import ( - load_or_register_client, - ) + # Ensure OAuth client in SQLite storage + from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client + from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage - client_info = await load_or_register_client( + storage = RefreshTokenStorage.from_env() + await storage.initialize() + + client_info = await ensure_oauth_client( nextcloud_url=nextcloud_host, registration_endpoint=registration_endpoint, - storage_path=storage_path, + storage=storage, client_name=f"Nextcloud MCP Server ({token_type})", redirect_uris=redirect_uris, scopes=scopes, @@ -356,8 +359,9 @@ async def load_oauth_client_credentials( raise ValueError( "OAuth mode requires either:\n" "1. NEXTCLOUD_OIDC_CLIENT_ID and NEXTCLOUD_OIDC_CLIENT_SECRET environment variables, OR\n" - "2. Pre-existing client credentials file at NEXTCLOUD_OIDC_CLIENT_STORAGE, OR\n" - "3. Dynamic client registration enabled on Nextcloud OIDC app" + "2. Pre-existing client credentials in SQLite storage (TOKEN_STORAGE_DB), OR\n" + "3. Dynamic client registration enabled on Nextcloud OIDC app\n\n" + "Note: TOKEN_ENCRYPTION_KEY is required for SQLite storage" ) @@ -1026,13 +1030,6 @@ def get_app(transport: str = "sse", enabled_apps: list[str] | None = None): envvar="NEXTCLOUD_OIDC_CLIENT_SECRET", help="OAuth client secret (can also use NEXTCLOUD_OIDC_CLIENT_SECRET env var)", ) -@click.option( - "--oauth-storage-path", - envvar="NEXTCLOUD_OIDC_CLIENT_STORAGE", - default=".nextcloud_oauth_client.json", - show_default=True, - help="Path to store OAuth client credentials (can also use NEXTCLOUD_OIDC_CLIENT_STORAGE env var)", -) @click.option( "--mcp-server-url", envvar="NEXTCLOUD_MCP_SERVER_URL", @@ -1084,7 +1081,6 @@ def run( oauth: bool | None, oauth_client_id: str | None, oauth_client_secret: str | None, - oauth_storage_path: str, mcp_server_url: str, nextcloud_host: str | None, nextcloud_username: str | None, @@ -1139,8 +1135,6 @@ def run( os.environ["NEXTCLOUD_OIDC_CLIENT_ID"] = oauth_client_id if oauth_client_secret: os.environ["NEXTCLOUD_OIDC_CLIENT_SECRET"] = oauth_client_secret - if oauth_storage_path: - os.environ["NEXTCLOUD_OIDC_CLIENT_STORAGE"] = oauth_storage_path if oauth_scopes: os.environ["NEXTCLOUD_OIDC_SCOPES"] = oauth_scopes if oauth_token_type: @@ -1183,13 +1177,7 @@ def run( click.echo("OAuth Configuration:", err=True) click.echo(" Mode: Dynamic Client Registration", err=True) click.echo(" Host: " + nextcloud_host, err=True) - click.echo( - " Storage: " - + os.getenv( - "NEXTCLOUD_OIDC_CLIENT_STORAGE", ".nextcloud_oauth_client.json" - ), - err=True, - ) + click.echo(" Storage: SQLite (TOKEN_STORAGE_DB)", err=True) click.echo("", err=True) click.echo( "Note: Make sure 'Dynamic Client Registration' is enabled", err=True diff --git a/nextcloud_mcp_server/auth/__init__.py b/nextcloud_mcp_server/auth/__init__.py index 2a973af..dbb34f5 100644 --- a/nextcloud_mcp_server/auth/__init__.py +++ b/nextcloud_mcp_server/auth/__init__.py @@ -1,7 +1,7 @@ """OAuth authentication components for Nextcloud MCP server.""" from .bearer_auth import BearerAuth -from .client_registration import load_or_register_client, register_client +from .client_registration import ensure_oauth_client, register_client from .context_helper import get_client_from_context from .scope_authorization import ( InsufficientScopeError, @@ -20,7 +20,7 @@ __all__ = [ "BearerAuth", "NextcloudTokenVerifier", "register_client", - "load_or_register_client", + "ensure_oauth_client", "get_client_from_context", "require_scopes", "ScopeAuthorizationError", diff --git a/nextcloud_mcp_server/auth/client_registration.py b/nextcloud_mcp_server/auth/client_registration.py index 9b6defe..81ea4cf 100644 --- a/nextcloud_mcp_server/auth/client_registration.py +++ b/nextcloud_mcp_server/auth/client_registration.py @@ -1,16 +1,15 @@ """Dynamic client registration for Nextcloud OIDC.""" import datetime as dt -import json import logging -import os import time -from pathlib import Path from typing import Any import anyio import httpx +from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage + logger = logging.getLogger(__name__) @@ -170,72 +169,6 @@ async def register_client( raise ValueError(f"Invalid registration response: missing {e}") -def load_client_from_file(storage_path: Path) -> ClientInfo | None: - """ - Load client credentials from storage file. - - Args: - storage_path: Path to the JSON file containing client credentials - - Returns: - ClientInfo if file exists and is valid, None otherwise - """ - if not storage_path.exists(): - logger.debug(f"Client storage file not found: {storage_path}") - return None - - try: - with open(storage_path, "r") as f: - data = json.load(f) - - client_info = ClientInfo.from_dict(data) - - if client_info.is_expired: - logger.warning( - f"Stored client has expired (expired at {client_info.client_secret_expires_at})" - ) - return None - - logger.info(f"Loaded client from storage: {client_info.client_id[:16]}...") - if client_info.expires_soon: - logger.warning("Client expires soon (within 5 minutes)") - - return client_info - - except (json.JSONDecodeError, KeyError, ValueError) as e: - logger.error(f"Failed to load client from file: {e}") - return None - - -def save_client_to_file(client_info: ClientInfo, storage_path: Path): - """ - Save client credentials to storage file. - - Args: - client_info: Client information to save - storage_path: Path to save the JSON file - - Raises: - OSError: If file cannot be written - """ - try: - # Create directory if it doesn't exist - storage_path.parent.mkdir(parents=True, exist_ok=True) - - # Write client info - with open(storage_path, "w") as f: - json.dump(client_info.to_dict(), f, indent=2) - - # Set restrictive permissions (owner read/write only) - os.chmod(storage_path, 0o600) - - logger.info(f"Saved client credentials to {storage_path}") - - except OSError as e: - logger.error(f"Failed to save client credentials: {e}") - raise - - async def delete_client( nextcloud_url: str, client_id: str, @@ -362,28 +295,28 @@ async def delete_client( return False -async def load_or_register_client( +async def ensure_oauth_client( nextcloud_url: str, registration_endpoint: str, - storage_path: str | Path, + storage: RefreshTokenStorage, client_name: str = "Nextcloud MCP Server", redirect_uris: list[str] | None = None, scopes: str = "openid profile email", token_type: str = "Bearer", ) -> ClientInfo: """ - Load client from storage or register a new one if not found/expired. + Ensure OAuth client exists in SQLite storage. This function: - 1. Checks for existing client credentials in storage + 1. Checks for existing client credentials in SQLite storage 2. Validates the credentials are not expired 3. Registers a new client if needed (no stored credentials or expired) - 4. Saves the new client credentials + 4. Saves the new client credentials to SQLite Args: nextcloud_url: Base URL of the Nextcloud instance registration_endpoint: Full URL to the registration endpoint - storage_path: Path to store client credentials + storage: RefreshTokenStorage instance for SQLite storage client_name: Name of the client application redirect_uris: List of redirect URIs scopes: Space-separated list of scopes to request (default: "openid profile email") @@ -396,12 +329,13 @@ async def load_or_register_client( httpx.HTTPStatusError: If registration fails ValueError: If response is invalid """ - storage_path = Path(storage_path) - - # Try to load existing client - client_info = load_client_from_file(storage_path) - if client_info: - return client_info + # Try to load existing client from SQLite + client_data = await storage.get_oauth_client() + if client_data: + logger.info( + f"Loaded OAuth client from SQLite: {client_data['client_id'][:16]}..." + ) + return ClientInfo.from_dict(client_data) # Register new client logger.info("Registering new OAuth client...") @@ -414,7 +348,15 @@ async def load_or_register_client( token_type=token_type, ) - # Save to storage - save_client_to_file(client_info, storage_path) + # Save to SQLite storage + await storage.store_oauth_client( + client_id=client_info.client_id, + client_secret=client_info.client_secret, + client_id_issued_at=client_info.client_id_issued_at, + client_secret_expires_at=client_info.client_secret_expires_at, + redirect_uris=client_info.redirect_uris, + registration_access_token=client_info.registration_access_token, + registration_client_uri=client_info.registration_client_uri, + ) return client_info diff --git a/nextcloud_mcp_server/auth/refresh_token_storage.py b/nextcloud_mcp_server/auth/refresh_token_storage.py index 81a875b..cd50aa7 100644 --- a/nextcloud_mcp_server/auth/refresh_token_storage.py +++ b/nextcloud_mcp_server/auth/refresh_token_storage.py @@ -5,6 +5,7 @@ Securely stores and manages user refresh tokens for background operations. Tokens are encrypted at rest using Fernet symmetric encryption. """ +import json import logging import os import time @@ -123,6 +124,24 @@ class RefreshTokenStorage: "ON audit_logs(user_id, timestamp)" ) + # OAuth client credentials storage + await db.execute( + """ + CREATE TABLE IF NOT EXISTS oauth_clients ( + id INTEGER PRIMARY KEY, + client_id TEXT UNIQUE NOT NULL, + encrypted_client_secret BLOB NOT NULL, + client_id_issued_at INTEGER NOT NULL, + client_secret_expires_at INTEGER NOT NULL, + redirect_uris TEXT NOT NULL, + encrypted_registration_access_token BLOB, + registration_client_uri TEXT, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) + await db.commit() # Set restrictive permissions after creation @@ -295,6 +314,213 @@ class RefreshTokenStorage: return deleted + async def store_oauth_client( + self, + client_id: str, + client_secret: str, + client_id_issued_at: int, + client_secret_expires_at: int, + redirect_uris: list[str], + registration_access_token: Optional[str] = None, + registration_client_uri: Optional[str] = None, + ) -> None: + """ + Store encrypted OAuth client credentials. + + Args: + client_id: OAuth client identifier + client_secret: OAuth client secret (will be encrypted) + client_id_issued_at: Unix timestamp when client was issued + client_secret_expires_at: Unix timestamp when secret expires + redirect_uris: List of redirect URIs + registration_access_token: RFC 7592 registration token (will be encrypted) + registration_client_uri: RFC 7592 client management URI + """ + if not self._initialized: + await self.initialize() + + # Encrypt sensitive data + encrypted_secret = self.cipher.encrypt(client_secret.encode()) + encrypted_reg_token = ( + self.cipher.encrypt(registration_access_token.encode()) + if registration_access_token + else None + ) + + # Serialize redirect_uris as JSON + redirect_uris_json = json.dumps(redirect_uris) + now = int(time.time()) + + async with aiosqlite.connect(self.db_path) as db: + await db.execute( + """ + INSERT OR REPLACE INTO oauth_clients + (id, client_id, encrypted_client_secret, client_id_issued_at, + client_secret_expires_at, redirect_uris, encrypted_registration_access_token, + registration_client_uri, created_at, updated_at) + VALUES ( + 1, ?, ?, ?, ?, ?, ?, ?, + COALESCE((SELECT created_at FROM oauth_clients WHERE id = 1), ?), + ? + ) + """, + ( + client_id, + encrypted_secret, + client_id_issued_at, + client_secret_expires_at, + redirect_uris_json, + encrypted_reg_token, + registration_client_uri, + now, + now, + ), + ) + await db.commit() + + logger.info( + f"Stored OAuth client credentials (client_id: {client_id[:16]}..., " + f"expires at {client_secret_expires_at})" + ) + + # Audit log + await self._audit_log( + event="store_oauth_client", + user_id="system", + auth_method="oauth", + ) + + async def get_oauth_client(self) -> Optional[dict]: + """ + Retrieve and decrypt OAuth client credentials. + + Returns: + Dictionary with client credentials, or None if not found or expired: + { + "client_id": str, + "client_secret": str, + "client_id_issued_at": int, + "client_secret_expires_at": int, + "redirect_uris": list[str], + "registration_access_token": str | None, + "registration_client_uri": str | None, + } + """ + if not self._initialized: + await self.initialize() + + async with aiosqlite.connect(self.db_path) as db: + async with db.execute( + """ + SELECT client_id, encrypted_client_secret, client_id_issued_at, + client_secret_expires_at, redirect_uris, + encrypted_registration_access_token, registration_client_uri + FROM oauth_clients WHERE id = 1 + """ + ) as cursor: + row = await cursor.fetchone() + + if not row: + logger.debug("No OAuth client credentials found in storage") + return None + + ( + client_id, + encrypted_secret, + issued_at, + expires_at, + redirect_uris_json, + encrypted_reg_token, + reg_client_uri, + ) = row + + # Check expiration + if expires_at < time.time(): + logger.warning( + f"OAuth client has expired (expired at {expires_at}), deleting" + ) + await self.delete_oauth_client() + return None + + try: + # Decrypt sensitive data + client_secret = self.cipher.decrypt(encrypted_secret).decode() + reg_token = ( + self.cipher.decrypt(encrypted_reg_token).decode() + if encrypted_reg_token + else None + ) + + # Deserialize redirect_uris + redirect_uris = json.loads(redirect_uris_json) + + logger.debug( + f"Retrieved OAuth client credentials (client_id: {client_id[:16]}...)" + ) + + return { + "client_id": client_id, + "client_secret": client_secret, + "client_id_issued_at": issued_at, + "client_secret_expires_at": expires_at, + "redirect_uris": redirect_uris, + "registration_access_token": reg_token, + "registration_client_uri": reg_client_uri, + } + + except Exception as e: + logger.error(f"Failed to decrypt OAuth client credentials: {e}") + return None + + async def delete_oauth_client(self) -> bool: + """ + Delete OAuth client credentials. + + Returns: + True if client was deleted, False if not found + """ + if not self._initialized: + await self.initialize() + + async with aiosqlite.connect(self.db_path) as db: + cursor = await db.execute("DELETE FROM oauth_clients WHERE id = 1") + await db.commit() + deleted = cursor.rowcount > 0 + + if deleted: + logger.info("Deleted OAuth client credentials from storage") + await self._audit_log( + event="delete_oauth_client", + user_id="system", + auth_method="oauth", + ) + else: + logger.debug("No OAuth client credentials to delete") + + return deleted + + async def has_oauth_client(self) -> bool: + """ + Check if OAuth client credentials exist (and are not expired). + + Returns: + True if valid client exists, False otherwise + """ + if not self._initialized: + await self.initialize() + + async with aiosqlite.connect(self.db_path) as db: + async with db.execute( + "SELECT client_secret_expires_at FROM oauth_clients WHERE id = 1" + ) as cursor: + row = await cursor.fetchone() + + if not row: + return False + + expires_at = row[0] + return expires_at >= time.time() + async def _audit_log( self, event: str, diff --git a/pyproject.toml b/pyproject.toml index 9c17772..e8b6317 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -51,6 +51,7 @@ markers = [ "integration: Integration tests requiring Docker containers", "oauth: OAuth tests requiring Playwright (slowest)", "smoke: Critical path smoke tests for quick validation", + "keycloak: OAuth tests that utilize keycloak external identity provider", ] testpaths = [ "tests", diff --git a/tests/load/oauth_benchmark.py b/tests/load/oauth_benchmark.py index 840a27d..fbb1c3f 100644 --- a/tests/load/oauth_benchmark.py +++ b/tests/load/oauth_benchmark.py @@ -27,7 +27,8 @@ import click import httpx from playwright.async_api import async_playwright -from nextcloud_mcp_server.auth.client_registration import load_or_register_client +from nextcloud_mcp_server.auth.client_registration import ensure_oauth_client +from nextcloud_mcp_server.auth.refresh_token_storage import RefreshTokenStorage from nextcloud_mcp_server.client import NextcloudClient from tests.load.oauth_metrics import OAuthBenchmarkMetrics from tests.load.oauth_pool import ( @@ -142,7 +143,7 @@ async def setup_oauth_client( nextcloud_host: str, callback_url: str, registration_endpoint: str ) -> dict[str, str]: """ - Setup OAuth client using load_or_register_client. + Setup OAuth client using ensure_oauth_client with SQLite storage. Args: nextcloud_host: Nextcloud host URL @@ -154,11 +155,15 @@ async def setup_oauth_client( """ logger.info("Setting up OAuth client...") - # Use the client registration utility - client_info = await load_or_register_client( + # Initialize SQLite storage + storage = RefreshTokenStorage.from_env() + await storage.initialize() + + # Use the client registration utility with SQLite storage + client_info = await ensure_oauth_client( nextcloud_url=nextcloud_host, registration_endpoint=registration_endpoint, - storage_path=".nextcloud_oauth_benchmark_client.json", + storage=storage, client_name="OAuth Benchmark Test Client", redirect_uris=[callback_url], ) diff --git a/tests/server/oauth/test_keycloak_external_idp.py b/tests/server/oauth/test_keycloak_external_idp.py index 9aff31c..b4e075d 100644 --- a/tests/server/oauth/test_keycloak_external_idp.py +++ b/tests/server/oauth/test_keycloak_external_idp.py @@ -27,7 +27,7 @@ from nextcloud_mcp_server.client import NextcloudClient logger = logging.getLogger(__name__) -pytestmark = [pytest.mark.integration, pytest.mark.oauth] +pytestmark = [pytest.mark.integration, pytest.mark.keycloak] # ============================================================================