diff --git a/docs/ADR-002-vector-sync-authentication.md b/docs/ADR-002-vector-sync-authentication.md index 42b5b2f..aee8708 100644 --- a/docs/ADR-002-vector-sync-authentication.md +++ b/docs/ADR-002-vector-sync-authentication.md @@ -51,8 +51,14 @@ We will implement a **tiered OAuth authentication strategy** for background oper - Background worker uses service account token directly - No user-specific delegation or impersonation - **Implementation**: `KeycloakOAuthClient.get_service_account_token()` (keycloak_oauth.py:341-395) -- **Testing**: Manual test in `tests/manual/test_token_exchange.py` -- **TODO**: Automated integration tests needed for both Keycloak and Nextcloud OIDC app +- **Testing**: + - ✅ **Automated test**: `tests/server/oauth/test_keycloak_external_idp.py::test_keycloak_service_account_token_acquisition` + - ✅ **Manual test**: `tests/manual/test_token_exchange.py` +- **Supported Providers**: + - ✅ **Keycloak** (external IdP mode) - Fully tested and validated + - ❌ **Nextcloud OIDC app** (integrated mode) - Not yet implemented (see app.py:631-635) + - The `KeycloakOAuthClient` class is provider-agnostic and works with any OIDC provider + - Extending support to Nextcloud OIDC app requires configuration/initialization only **Trade-offs**: - ✅ Works with nearly all OIDC providers diff --git a/tests/conftest.py b/tests/conftest.py index 880fe98..cd94c7e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2526,6 +2526,59 @@ async def keycloak_oauth_client_credentials(anyio_backend, oauth_callback_server # No cleanup needed - client is pre-configured in realm export +@pytest.fixture(scope="session") +async def keycloak_oauth_client(anyio_backend, keycloak_oauth_client_credentials): + """ + Fixture to create a KeycloakOAuthClient instance for service account token operations. + + This fixture is used to test ADR-002 Tier 1 (service account token acquisition) and + Tier 3 (token exchange with delegation). + + Returns: + KeycloakOAuthClient instance configured with Keycloak credentials + """ + from nextcloud_mcp_server.auth.keycloak_oauth import KeycloakOAuthClient + + # Get Keycloak configuration from environment + keycloak_discovery_url = os.getenv( + "OIDC_DISCOVERY_URL", + "http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration", + ) + + # Extract base URL and realm from discovery URL + # Format: http://keycloak:8080/realms/nextcloud-mcp/.well-known/openid-configuration + if "/realms/" in keycloak_discovery_url: + base_url = keycloak_discovery_url.split("/realms/")[0] + realm = keycloak_discovery_url.split("/realms/")[1].split("/")[0] + else: + pytest.skip("Invalid Keycloak discovery URL format") + + client_id, client_secret, callback_url, _, _ = keycloak_oauth_client_credentials + + logger.info("Creating KeycloakOAuthClient for service account operations...") + logger.info(f" Keycloak URL: {base_url}") + logger.info(f" Realm: {realm}") + logger.info(f" Client ID: {client_id}") + + oauth_client = KeycloakOAuthClient( + keycloak_url=base_url, + realm=realm, + client_id=client_id, + client_secret=client_secret, + redirect_uri=callback_url, + ) + + # Discover endpoints + await oauth_client.discover() + logger.info("✓ KeycloakOAuthClient initialized") + logger.info(f" Token endpoint: {oauth_client.token_endpoint}") + + yield oauth_client + + # Cleanup (close http client if needed) + await oauth_client.close() + + async def _get_keycloak_oauth_token( browser, keycloak_oauth_client_credentials, diff --git a/tests/server/oauth/test_keycloak_external_idp.py b/tests/server/oauth/test_keycloak_external_idp.py index 99da439..7fb7f2d 100644 --- a/tests/server/oauth/test_keycloak_external_idp.py +++ b/tests/server/oauth/test_keycloak_external_idp.py @@ -20,6 +20,7 @@ Tests: import json import logging +import os import pytest @@ -92,6 +93,80 @@ async def test_keycloak_oauth_client_credentials_discovery( logger.info(f" Authorization endpoint: {authorization_endpoint}") +async def test_keycloak_service_account_token_acquisition(keycloak_oauth_client): + """Test service account token acquisition via client_credentials grant (ADR-002 Tier 1). + + Verifies: + - Service account token is acquired using client_credentials grant + - Token response includes access_token, token_type, expires_in + - Token can be used to access Nextcloud APIs + - Token type is Bearer + + This test validates ADR-002 Tier 1 implementation for Keycloak external IdP. + + Note: For Nextcloud OIDC app (integrated mode), service account token acquisition + is not yet implemented. See app.py:631-635 which states "OAuth client for token + refresh not yet implemented for integrated mode". The KeycloakOAuthClient class + works with any OIDC provider, so extending support to Nextcloud OIDC app is + primarily a configuration/initialization issue rather than a fundamental limitation. + """ + # Get service account token with standard scopes + token_response = await keycloak_oauth_client.get_service_account_token( + scopes=["openid", "profile", "email"] + ) + + # Verify token response structure + assert "access_token" in token_response, "Missing access_token in response" + assert "token_type" in token_response, "Missing token_type in response" + assert "expires_in" in token_response, "Missing expires_in in response" + + assert token_response["token_type"].lower() == "bearer", ( + f"Expected Bearer token type, got {token_response['token_type']}" + ) + assert isinstance(token_response["expires_in"], int), ( + f"Expected integer expires_in, got {type(token_response['expires_in'])}" + ) + assert token_response["expires_in"] > 0, ( + f"Expected positive expires_in, got {token_response['expires_in']}" + ) + + logger.info("✓ Service account token acquired successfully") + logger.info(f" Token type: {token_response['token_type']}") + logger.info(f" Expires in: {token_response['expires_in']}s") + logger.info(f" Scope: {token_response.get('scope', 'N/A')}") + logger.info(f" Token length: {len(token_response['access_token'])} chars") + + # Verify token works with Nextcloud APIs + # The service account token should be validated by Nextcloud's user_oidc app + from nextcloud_mcp_server.client import NextcloudClient + + nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080") + + # Create a NextcloudClient using the service account token + nc_client = NextcloudClient.from_token( + base_url=nextcloud_host, + token=token_response["access_token"], + username="service-account-nextcloud-mcp-server", # Keycloak service account username + ) + + try: + # Verify token works with Nextcloud API (using OCS endpoint which works without patch) + capabilities = await nc_client.capabilities() + assert capabilities is not None, ( + "Failed to get capabilities with service account token" + ) + + logger.info("✓ Service account token works with Nextcloud APIs") + logger.info( + f" Nextcloud version: {capabilities.get('version', {}).get('string', 'unknown')}" + ) + + finally: + await nc_client.close() + + logger.info("✓ ADR-002 Tier 1 (Service Account Token) validated for Keycloak") + + # ============================================================================ # MCP Server Connectivity Tests # ============================================================================