test: Replace persistent OAuth client cache with session-scoped fixtures

Remove file-based caching of OAuth client credentials and implement automatic
client lifecycle management for test fixtures.

Changes:
- Add RFC 7592 client deletion function in auth/client_registration.py
- Remove cache_file parameter from _create_oauth_client_with_scopes helper
- Update all OAuth credential fixtures to use yield/finalizer pattern
- Add automatic client cleanup at end of test session (best-effort)
- Remove persistent .nextcloud_oauth_*.json cache files

Benefits:
- No persistent cache files cluttering repository
- Fresh OAuth clients created for each test session via DCR
- Automatic cleanup attempts (RFC 7592 DELETE endpoint)
- Cleaner test environment with proper fixture lifecycle

Note: Client deletion may fail due to Nextcloud authentication middleware
(logged as warning). The key improvement is removing persistent cache files.
OAuth clients may accumulate in Nextcloud but can be cleaned manually.
This commit is contained in:
Chris Coutinho
2025-10-24 08:11:16 +02:00
parent 13f76a7734
commit 1e877f17f7
3 changed files with 236 additions and 65 deletions
+4 -3
View File
@@ -327,10 +327,12 @@ OAuth integration tests use **automated Playwright browser automation** to compl
**OAuth Testing Setup:**
- **Main fixtures**: `nc_oauth_client`, `nc_mcp_oauth_client` - Use Playwright automation
- **Shared OAuth Client**: All test users authenticate using a single OAuth client
- Stored in `.nextcloud_oauth_shared_test_client.json`
- Matches production MCP server behavior
- **Created fresh for each test session** via Dynamic Client Registration (DCR)
- Matches production MCP server behavior (one client, multiple user tokens)
- Each user gets their own unique access token
- **Automatic cleanup**: Client is registered at session start, deleted at session end (RFC 7592)
- Implementation: `shared_oauth_client_credentials` fixture in `tests/conftest.py`
- **Note**: Client deletion may fail due to Nextcloud middleware (logged as warning). This doesn't affect tests.
- **Available fixtures**: `playwright_oauth_token`, `nc_oauth_client`, `nc_mcp_oauth_client`
- **Multi-user fixtures**: `alice_oauth_token`, `bob_oauth_token`, `charlie_oauth_token`, `diana_oauth_token`
- **Requirements**: `NEXTCLOUD_HOST`, `NEXTCLOUD_USERNAME`, `NEXTCLOUD_PASSWORD` environment variables
@@ -356,7 +358,6 @@ uv run pytest -m oauth -v
- `mcp-oauth` (port 8001): Uses OAuth authentication - for OAuth-specific testing
- Start OAuth MCP server: `docker-compose up --build -d mcp-oauth`
- **Important**: When working on OAuth functionality, always rebuild `mcp-oauth` container, not `mcp`
- OAuth client credentials cached in `.nextcloud_oauth_shared_test_client.json`
**CI/CD Notes:**
- Playwright tests run in CI/CD environments
@@ -212,6 +212,80 @@ def save_client_to_file(client_info: ClientInfo, storage_path: Path):
raise
async def delete_client(
nextcloud_url: str,
client_id: str,
client_secret: str,
) -> bool:
"""
Delete a dynamically registered OAuth client using RFC 7592.
This implements RFC 7592 Section 2.3 (Client Delete Request).
The client authenticates using client_secret_post method and
requests deletion via DELETE to the client configuration endpoint.
Args:
nextcloud_url: Base URL of the Nextcloud instance
client_id: Client identifier to delete
client_secret: Client secret for authentication
Returns:
True if deletion successful, False otherwise
Note:
Per RFC 7592, the deletion endpoint is:
{nextcloud_url}/apps/oidc/register/{client_id}
Authentication uses HTTP Basic Auth or client_secret_post:
- HTTP Basic Auth: client_id as username, client_secret as password
- client_secret_post: credentials in request body
"""
deletion_endpoint = f"{nextcloud_url}/apps/oidc/register/{client_id}"
logger.info(f"Deleting OAuth client: {client_id[:16]}...")
logger.debug(f"Deletion endpoint: {deletion_endpoint}")
async with httpx.AsyncClient(timeout=30.0) as http_client:
try:
# RFC 7592 requires client authentication
# Use HTTP Basic Auth (client_id as username, client_secret as password)
response = await http_client.delete(
deletion_endpoint,
auth=(client_id, client_secret),
)
# RFC 7592: Successful deletion returns 204 No Content
if response.status_code == 204:
logger.info(f"Successfully deleted OAuth client: {client_id[:16]}...")
return True
elif response.status_code == 401:
logger.error(
f"Failed to delete client {client_id[:16]}...: Authentication failed (invalid credentials)"
)
return False
elif response.status_code == 403:
logger.error(
f"Failed to delete client {client_id[:16]}...: Not authorized (not a DCR client or wrong client)"
)
return False
else:
logger.error(
f"Failed to delete client {client_id[:16]}...: HTTP {response.status_code}"
)
logger.debug(f"Response: {response.text}")
return False
except httpx.HTTPStatusError as e:
logger.error(
f"HTTP error deleting client {client_id[:16]}...: {e.response.status_code}"
)
logger.debug(f"Response: {e.response.text}")
return False
except Exception as e:
logger.error(f"Unexpected error deleting client {client_id[:16]}...: {e}")
return False
async def load_or_register_client(
nextcloud_url: str,
registration_endpoint: str,
+158 -62
View File
@@ -952,9 +952,13 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
server (port 8001). While opaque tokens don't embed scopes, the allowed_scopes
configuration ensures tokens have proper scopes when introspected.
The client is automatically deleted from Nextcloud after the test session completes.
Returns:
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
"""
from nextcloud_mcp_server.auth.client_registration import delete_client
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("Shared OAuth client requires NEXTCLOUD_HOST")
@@ -982,13 +986,11 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
# Create opaque token client with allowed_scopes (not JWT)
# This ensures the token has proper scopes even though they're not embedded
# Cache to file to avoid creating new client on every test run
client_id, client_secret = await _create_oauth_client_with_scopes(
callback_url=callback_url,
client_name="Pytest - Shared Test Client (Opaque)",
allowed_scopes=DEFAULT_FULL_SCOPES,
token_type="Bearer", # Opaque tokens for port 8001
cache_file=".nextcloud_oauth_shared_test_client.json",
)
logger.info(f"Shared OAuth client ready: {client_id[:16]}...")
@@ -996,7 +998,7 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
"This opaque token client with full scopes will be reused for all test user authentications"
)
return (
yield (
client_id,
client_secret,
callback_url,
@@ -1004,6 +1006,27 @@ async def shared_oauth_client_credentials(anyio_backend, oauth_callback_server):
authorization_endpoint,
)
# Cleanup: Delete OAuth client from Nextcloud using RFC 7592
try:
logger.info(f"Cleaning up shared OAuth client: {client_id[:16]}...")
success = await delete_client(
nextcloud_url=nextcloud_host,
client_id=client_id,
client_secret=client_secret,
)
if success:
logger.info(
f"Successfully deleted shared OAuth client: {client_id[:16]}..."
)
else:
logger.warning(
f"Failed to delete shared OAuth client: {client_id[:16]}..."
)
except Exception as e:
logger.warning(
f"Error cleaning up shared OAuth client {client_id[:16]}...: {e}"
)
@pytest.fixture(scope="session")
async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_server):
@@ -1014,9 +1037,13 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv
is configured with token_type="JWT" to request JWT-formatted access tokens from the
OIDC server (instead of opaque tokens).
The client is automatically deleted from Nextcloud after the test session completes.
Returns:
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
"""
from nextcloud_mcp_server.auth.client_registration import delete_client
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("Shared JWT OAuth client requires NEXTCLOUD_HOST")
@@ -1043,13 +1070,11 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv
)
# Create JWT client with full scopes (all app read/write scopes)
# Cache to file to avoid creating new client on every test run
client_id, client_secret = await _create_oauth_client_with_scopes(
callback_url=callback_url,
client_name="Pytest - Shared JWT Test Client",
allowed_scopes=DEFAULT_FULL_SCOPES,
token_type="JWT", # Explicitly set JWT token type
cache_file=".nextcloud_oauth_shared_jwt_test_client.json",
)
logger.info(f"Shared JWT OAuth client ready: {client_id[:16]}...")
@@ -1057,7 +1082,7 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv
"This JWT client with full scopes will be reused for JWT MCP server tests"
)
return (
yield (
client_id,
client_secret,
callback_url,
@@ -1065,53 +1090,48 @@ async def shared_jwt_oauth_client_credentials(anyio_backend, oauth_callback_serv
authorization_endpoint,
)
# Cleanup: Delete OAuth client from Nextcloud using RFC 7592
try:
logger.info(f"Cleaning up shared JWT OAuth client: {client_id[:16]}...")
success = await delete_client(
nextcloud_url=nextcloud_host,
client_id=client_id,
client_secret=client_secret,
)
if success:
logger.info(
f"Successfully deleted shared JWT OAuth client: {client_id[:16]}..."
)
else:
logger.warning(
f"Failed to delete shared JWT OAuth client: {client_id[:16]}..."
)
except Exception as e:
logger.warning(
f"Error cleaning up shared JWT OAuth client {client_id[:16]}...: {e}"
)
async def _create_oauth_client_with_scopes(
callback_url: str,
client_name: str,
allowed_scopes: str,
token_type: str = "JWT",
cache_file: str | None = None,
) -> tuple[str, str]:
"""
Helper function to create an OAuth client with specific allowed_scopes using DCR.
Supports optional file-based caching to avoid creating duplicate clients.
Args:
callback_url: OAuth callback URL
client_name: Name of the OAuth client
allowed_scopes: Space-separated list of allowed scopes
token_type: Either "JWT" or "Bearer" (default: "JWT")
cache_file: Optional path to cache file (e.g., ".nextcloud_oauth_shared_test_client.json")
Returns:
Tuple of (client_id, client_secret)
"""
import json
from pathlib import Path
from nextcloud_mcp_server.auth.client_registration import register_client
# Try to load from cache if specified
if cache_file:
cache_path = Path(cache_file)
if cache_path.exists():
try:
with open(cache_path, "r") as f:
cached_data = json.load(f)
client_id = cached_data.get("client_id")
client_secret = cached_data.get("client_secret")
if client_id and client_secret:
logger.info(
f"Loaded cached OAuth client from {cache_file}: {client_id[:16]}..."
)
return client_id, client_secret
except (json.JSONDecodeError, KeyError, OSError) as e:
logger.warning(f"Failed to load cached client from {cache_file}: {e}")
logger.info(
f"Creating {token_type} OAuth client '{client_name}' with scopes: {allowed_scopes} using DCR"
)
@@ -1149,32 +1169,6 @@ async def _create_oauth_client_with_scopes(
f"Created OAuth client via DCR: {client_id[:16]}... with scopes: {allowed_scopes}"
)
# Save to cache if specified
if cache_file:
cache_path = Path(cache_file)
try:
# Create parent directory if needed
cache_path.parent.mkdir(parents=True, exist_ok=True)
# Save client data
with open(cache_path, "w") as f:
json.dump(
{
"client_id": client_id,
"client_secret": client_secret,
"redirect_uris": [callback_url],
},
f,
indent=2,
)
# Set restrictive permissions
cache_path.chmod(0o600)
logger.info(f"Cached OAuth client to {cache_file}")
except OSError as e:
logger.warning(f"Failed to cache client to {cache_file}: {e}")
return client_id, client_secret
@@ -1183,9 +1177,13 @@ async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_serve
"""
Fixture for OAuth client with only read scopes.
The client is automatically deleted from Nextcloud after the test session completes.
Returns:
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
"""
from nextcloud_mcp_server.auth.client_registration import delete_client
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("Read-only OAuth client requires NEXTCLOUD_HOST")
@@ -1209,7 +1207,7 @@ async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_serve
token_type="JWT", # JWT tokens for scope validation
)
return (
yield (
client_id,
client_secret,
callback_url,
@@ -1217,15 +1215,40 @@ async def read_only_oauth_client_credentials(anyio_backend, oauth_callback_serve
authorization_endpoint,
)
# Cleanup: Delete OAuth client from Nextcloud using RFC 7592
try:
logger.info(f"Cleaning up read-only OAuth client: {client_id[:16]}...")
success = await delete_client(
nextcloud_url=nextcloud_host,
client_id=client_id,
client_secret=client_secret,
)
if success:
logger.info(
f"Successfully deleted read-only OAuth client: {client_id[:16]}..."
)
else:
logger.warning(
f"Failed to delete read-only OAuth client: {client_id[:16]}..."
)
except Exception as e:
logger.warning(
f"Error cleaning up read-only OAuth client {client_id[:16]}...: {e}"
)
@pytest.fixture(scope="session")
async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_server):
"""
Fixture for OAuth client with only write scopes.
The client is automatically deleted from Nextcloud after the test session completes.
Returns:
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
"""
from nextcloud_mcp_server.auth.client_registration import delete_client
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("Write-only OAuth client requires NEXTCLOUD_HOST")
@@ -1249,7 +1272,7 @@ async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_serv
token_type="JWT", # JWT tokens for scope validation
)
return (
yield (
client_id,
client_secret,
callback_url,
@@ -1257,15 +1280,40 @@ async def write_only_oauth_client_credentials(anyio_backend, oauth_callback_serv
authorization_endpoint,
)
# Cleanup: Delete OAuth client from Nextcloud using RFC 7592
try:
logger.info(f"Cleaning up write-only OAuth client: {client_id[:16]}...")
success = await delete_client(
nextcloud_url=nextcloud_host,
client_id=client_id,
client_secret=client_secret,
)
if success:
logger.info(
f"Successfully deleted write-only OAuth client: {client_id[:16]}..."
)
else:
logger.warning(
f"Failed to delete write-only OAuth client: {client_id[:16]}..."
)
except Exception as e:
logger.warning(
f"Error cleaning up write-only OAuth client {client_id[:16]}...: {e}"
)
@pytest.fixture(scope="session")
async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_server):
"""
Fixture for OAuth client with both read and write scopes.
The client is automatically deleted from Nextcloud after the test session completes.
Returns:
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
"""
from nextcloud_mcp_server.auth.client_registration import delete_client
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("Full-access OAuth client requires NEXTCLOUD_HOST")
@@ -1289,7 +1337,7 @@ async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_ser
token_type="JWT", # JWT tokens for scope validation
)
return (
yield (
client_id,
client_secret,
callback_url,
@@ -1297,6 +1345,27 @@ async def full_access_oauth_client_credentials(anyio_backend, oauth_callback_ser
authorization_endpoint,
)
# Cleanup: Delete OAuth client from Nextcloud using RFC 7592
try:
logger.info(f"Cleaning up full-access OAuth client: {client_id[:16]}...")
success = await delete_client(
nextcloud_url=nextcloud_host,
client_id=client_id,
client_secret=client_secret,
)
if success:
logger.info(
f"Successfully deleted full-access OAuth client: {client_id[:16]}..."
)
else:
logger.warning(
f"Failed to delete full-access OAuth client: {client_id[:16]}..."
)
except Exception as e:
logger.warning(
f"Error cleaning up full-access OAuth client {client_id[:16]}...: {e}"
)
@pytest.fixture(scope="session")
async def no_custom_scopes_oauth_client_credentials(
@@ -1308,9 +1377,13 @@ async def no_custom_scopes_oauth_client_credentials(
Tests the security behavior when a user grants only the default OIDC scopes
(openid, profile, email) but declines custom application scopes (notes:read, notes:write, etc.).
The client is automatically deleted from Nextcloud after the test session completes.
Returns:
Tuple of (client_id, client_secret, callback_url, token_endpoint, authorization_endpoint)
"""
from nextcloud_mcp_server.auth.client_registration import delete_client
nextcloud_host = os.getenv("NEXTCLOUD_HOST")
if not nextcloud_host:
pytest.skip("No-custom-scopes OAuth client requires NEXTCLOUD_HOST")
@@ -1334,7 +1407,7 @@ async def no_custom_scopes_oauth_client_credentials(
token_type="JWT", # JWT tokens for scope validation
)
return (
yield (
client_id,
client_secret,
callback_url,
@@ -1342,6 +1415,29 @@ async def no_custom_scopes_oauth_client_credentials(
authorization_endpoint,
)
# Cleanup: Delete OAuth client from Nextcloud using RFC 7592
try:
logger.info(
f"Cleaning up no-custom-scopes OAuth client: {client_id[:16]}..."
)
success = await delete_client(
nextcloud_url=nextcloud_host,
client_id=client_id,
client_secret=client_secret,
)
if success:
logger.info(
f"Successfully deleted no-custom-scopes OAuth client: {client_id[:16]}..."
)
else:
logger.warning(
f"Failed to delete no-custom-scopes OAuth client: {client_id[:16]}..."
)
except Exception as e:
logger.warning(
f"Error cleaning up no-custom-scopes OAuth client {client_id[:16]}...: {e}"
)
@pytest.fixture(scope="session")
async def playwright_oauth_token(