fix: address PR review — remove token exchange tests, improve logging

- Remove all RFC 8693 token exchange tests (integration, manual, keycloak)
  since Nextcloud doesn't support bearer tokens without upstream patches
- Remove manual impersonation/ADR-004 scripts and their docs
- Clean up token_exchange singleton from integration conftest
- Improve logging in _complete_login_flow_v2_as_user with step-by-step
  [username] prefixed messages matching _complete_login_flow_v2 style
- Remove unnecessary time staggering from all_login_flow_user_tokens;
  concurrent token acquisition works without artificial delays

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Chris Coutinho
2026-04-01 17:43:44 +02:00
co-authored by Claude Opus 4.6
parent 6278b6eb75
commit c6316dbb91
13 changed files with 40 additions and 2782 deletions
@@ -1,308 +0,0 @@
"""
Integration test for RFC 8693 Token Exchange - Legacy V1 (Impersonation/Tier 1).
Tests the advanced impersonation feature where the service account token is
exchanged for a token with the target user's identity (sub claim changes).
This requires:
1. Keycloak with --features=preview enabled
2. Impersonation role granted to the service account
⚠️ This test will SKIP if impersonation permissions are not configured.
Configuration (one-time setup):
# Grant impersonation role
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh config credentials \\
--server http://localhost:8080 \\
--realm master \\
--user admin \\
--password admin
docker compose exec keycloak /opt/keycloak/bin/kcadm.sh add-roles \\
-r nextcloud-mcp \\
--uusername service-account-nextcloud-mcp-server \\
--cclientid realm-management \\
--rolename impersonation
Usage:
pytest tests/integration/auth/test_token_exchange_legacy_v1.py -v
"""
import base64
import json
import os
import httpx
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.anyio, pytest.mark.keycloak]
def decode_jwt(token: str) -> dict:
"""Decode JWT token payload without verification."""
try:
parts = token.split(".")
if len(parts) != 3:
return {"error": "Invalid JWT format"}
payload = parts[1]
padding = 4 - (len(payload) % 4)
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
return json.loads(decoded)
except Exception as e:
return {"error": str(e)}
@pytest.fixture
def keycloak_config():
"""Keycloak configuration for testing."""
return {
"url": os.getenv("KEYCLOAK_URL", "http://localhost:8888"),
"realm": os.getenv("KEYCLOAK_REALM", "nextcloud-mcp"),
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server"),
"client_secret": os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
),
"token_endpoint": f"{os.getenv('KEYCLOAK_URL', 'http://localhost:8888')}/realms/{os.getenv('KEYCLOAK_REALM', 'nextcloud-mcp')}/protocol/openid-connect/token",
}
@pytest.fixture
async def service_account_token(keycloak_config):
"""Get a service account token using client_credentials grant."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "client_credentials",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"scope": "openid profile email",
},
)
response.raise_for_status()
token_data = response.json()
return token_data["access_token"]
async def test_token_exchange_impersonation_requires_permissions(
keycloak_config, service_account_token
):
"""Test that impersonation requires explicit permission grant.
This test documents that Legacy V1 impersonation is opt-in and requires
administrative configuration via Keycloak CLI.
"""
target_user = "admin" # User to impersonate
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user, # ← KEY: Request impersonation
},
)
# If permissions not granted, we expect 403 Forbidden
if exchange_response.status_code == 403:
pytest.skip(
"Impersonation permissions not configured. "
"Run tests/manual/configure_impersonation.py or grant manually via Keycloak CLI. "
"See test docstring for configuration commands."
)
# If permissions are granted, exchange should succeed
assert exchange_response.status_code == 200, (
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
)
async def test_token_exchange_impersonation_changes_subject(
keycloak_config, service_account_token
):
"""Test Legacy V1 impersonation - subject claim should change."""
target_user = "admin"
# Decode service account token
service_claims = decode_jwt(service_account_token)
assert "error" not in service_claims
service_sub = service_claims["sub"]
assert "service-account" in service_sub.lower()
# Exchange token WITH requested_subject (Legacy V1 impersonation)
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user, # ← KEY: Impersonate admin
},
)
# Skip if permissions not configured
if exchange_response.status_code == 403:
pytest.skip(
"Impersonation permissions not configured. "
"See test docstring for setup instructions."
)
# Token exchange should succeed with permissions
assert exchange_response.status_code == 200, (
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
)
exchanged_data = exchange_response.json()
assert "access_token" in exchanged_data
exchanged_token = exchanged_data["access_token"]
# Decode exchanged token
exchanged_claims = decode_jwt(exchanged_token)
assert "error" not in exchanged_claims
exchanged_sub = exchanged_claims["sub"]
# CRITICAL: Verify impersonation - sub claim MUST change
assert service_sub != exchanged_sub, (
f"Impersonation should change subject claim. "
f"Original: {service_sub}, Exchanged: {exchanged_sub}"
)
# Verify the new token represents the target user
assert "preferred_username" in exchanged_claims
assert exchanged_claims["preferred_username"] == target_user
async def test_impersonated_token_with_nextcloud(
keycloak_config, service_account_token
):
"""Test that impersonated token works with Nextcloud APIs."""
target_user = "admin"
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
# Exchange token with impersonation
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": target_user,
},
)
# Skip if permissions not configured
if exchange_response.status_code == 403:
pytest.skip("Impersonation permissions not configured.")
exchange_response.raise_for_status()
exchanged_token = exchange_response.json()["access_token"]
# Test with Nextcloud API
nc_response = await client.get(
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
headers={"Authorization": f"Bearer {exchanged_token}"},
)
# Should get valid response from Nextcloud
assert nc_response.status_code in [
200,
401,
], f"Unexpected status: {nc_response.status_code}"
if nc_response.status_code == 200:
# Token was accepted - verify we got a valid response
# Nextcloud OCS API can return XML or JSON
assert len(nc_response.content) > 0, "Response should not be empty"
content_type = nc_response.headers.get("content-type", "")
assert any(t in content_type for t in ["json", "xml"]), (
f"Unexpected content type: {content_type}"
)
async def test_standard_v2_rejects_requested_subject():
"""Verify that Standard V2 (without preview features) rejects requested_subject.
This test documents the key difference between Standard V2 and Legacy V1.
NOTE: This test will PASS if preview features are enabled, as Keycloak
accepts the parameter in Legacy V1 mode. The test exists to document the
expected behavior when preview features are DISABLED.
"""
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
)
token_endpoint = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
async with httpx.AsyncClient(timeout=30.0) as client:
# Get service account token
token_response = await client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "openid profile email",
},
)
token_response.raise_for_status()
service_token = token_response.json()["access_token"]
# Try token exchange with requested_subject
exchange_response = await client.post(
token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": client_id,
"client_secret": client_secret,
"subject_token": service_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_subject": "admin", # Try to impersonate
},
)
# Standard V2: expects 400 Bad Request with "not supported" message
# Legacy V1: accepts parameter, returns 200 or 403 (depending on permissions)
if exchange_response.status_code == 400:
# Standard V2 behavior
error_data = exchange_response.json()
assert (
"requested_subject" in error_data.get("error_description", "").lower()
)
# Test passes - Standard V2 correctly rejects the parameter
elif exchange_response.status_code in [200, 403]:
# Legacy V1 behavior - parameter is accepted
pytest.skip(
"Preview features enabled - Keycloak is in Legacy V1 mode. "
"This test documents Standard V2 behavior which rejects requested_subject."
)
else:
pytest.fail(
f"Unexpected status code: {exchange_response.status_code}. "
f"Expected 400 (Standard V2) or 200/403 (Legacy V1)"
)
@@ -1,222 +0,0 @@
"""
Integration test for RFC 8693 Token Exchange - Standard V2 (Delegation/Tier 2).
Tests the production-ready token exchange without impersonation.
The service account exchanges its token for a user-scoped token while
maintaining its own identity (sub claim unchanged).
This is the RECOMMENDED approach for most use cases.
Requirements:
- Keycloak container running (can be Standard V2 or Legacy V1)
- MCP Keycloak service running on port 8002
Usage:
pytest tests/integration/auth/test_token_exchange_standard_v2.py -v
"""
import base64
import json
import os
import httpx
import pytest
pytestmark = [pytest.mark.integration, pytest.mark.anyio, pytest.mark.keycloak]
def decode_jwt(token: str) -> dict:
"""Decode JWT token payload without verification."""
try:
parts = token.split(".")
if len(parts) != 3:
return {"error": "Invalid JWT format"}
payload = parts[1]
padding = 4 - (len(payload) % 4)
if padding != 4:
payload += "=" * padding
decoded = base64.urlsafe_b64decode(payload)
return json.loads(decoded)
except Exception as e:
return {"error": str(e)}
@pytest.fixture
def keycloak_config():
"""Keycloak configuration for testing."""
return {
"url": os.getenv("KEYCLOAK_URL", "http://localhost:8888"),
"realm": os.getenv("KEYCLOAK_REALM", "nextcloud-mcp"),
"client_id": os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server"),
"client_secret": os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
),
"token_endpoint": f"{os.getenv('KEYCLOAK_URL', 'http://localhost:8888')}/realms/{os.getenv('KEYCLOAK_REALM', 'nextcloud-mcp')}/protocol/openid-connect/token",
}
@pytest.fixture
async def service_account_token(keycloak_config):
"""Get a service account token using client_credentials grant."""
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "client_credentials",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"scope": "openid profile email",
},
)
response.raise_for_status()
token_data = response.json()
return token_data["access_token"]
async def test_token_exchange_delegation(keycloak_config, service_account_token):
"""Test Standard V2 token exchange with delegation (no impersonation)."""
# Decode service account token to get original claims
service_claims = decode_jwt(service_account_token)
assert "error" not in service_claims, "Failed to decode service account token"
assert "sub" in service_claims
service_sub = service_claims["sub"]
# Exchange token WITHOUT requested_subject (Standard V2 delegation)
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
# NOTE: NO requested_subject parameter - this is delegation, not impersonation
},
)
# Token exchange should succeed
assert exchange_response.status_code == 200, (
f"Token exchange failed: {exchange_response.status_code} {exchange_response.text}"
)
exchanged_data = exchange_response.json()
assert "access_token" in exchanged_data
assert "token_type" in exchanged_data
assert exchanged_data["token_type"].lower() == "bearer"
exchanged_token = exchanged_data["access_token"]
# Decode exchanged token
exchanged_claims = decode_jwt(exchanged_token)
assert "error" not in exchanged_claims, "Failed to decode exchanged token"
assert "sub" in exchanged_claims
exchanged_sub = exchanged_claims["sub"]
# CRITICAL: Verify delegation behavior - sub claim should NOT change
assert service_sub == exchanged_sub, (
f"Subject should remain unchanged in delegation (service account identity preserved). Original: {service_sub}, Exchanged: {exchanged_sub}"
)
# The exchanged token should still identify as the service account
assert "service-account" in exchanged_sub.lower(), (
"Exchanged token should maintain service account identity"
)
async def test_exchanged_token_with_nextcloud(keycloak_config, service_account_token):
"""Test that exchanged token works with Nextcloud APIs."""
nextcloud_host = os.getenv("NEXTCLOUD_HOST", "http://localhost:8080")
# Exchange the service account token
async with httpx.AsyncClient(timeout=30.0) as client:
exchange_response = await client.post(
keycloak_config["token_endpoint"],
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": keycloak_config["client_id"],
"client_secret": keycloak_config["client_secret"],
"subject_token": service_account_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
},
)
exchange_response.raise_for_status()
exchanged_token = exchange_response.json()["access_token"]
# Test the exchanged token with Nextcloud API
nc_response = await client.get(
f"{nextcloud_host}/ocs/v2.php/cloud/capabilities",
headers={"Authorization": f"Bearer {exchanged_token}"},
)
# Should get a valid response from Nextcloud
# Note: This might fail with 401 if user_oidc doesn't accept the token
# That's expected - this test verifies the token exchange itself works
assert nc_response.status_code in [
200,
401,
], f"Unexpected status: {nc_response.status_code}"
if nc_response.status_code == 200:
# Token was accepted - verify we got a valid response
# Nextcloud OCS API can return XML or JSON
assert len(nc_response.content) > 0, "Response should not be empty"
# Verify we got either JSON or XML capabilities response
content_type = nc_response.headers.get("content-type", "")
assert any(t in content_type for t in ["json", "xml"]), (
f"Unexpected content type: {content_type}"
)
async def test_token_exchange_without_permissions_should_work():
"""Verify Standard V2 doesn't require special permissions (unlike Legacy V1 impersonation)."""
# This test documents that Standard V2 token exchange works out-of-the-box
# without needing to grant impersonation roles via Keycloak CLI
keycloak_url = os.getenv("KEYCLOAK_URL", "http://localhost:8888")
realm = os.getenv("KEYCLOAK_REALM", "nextcloud-mcp")
client_id = os.getenv("KEYCLOAK_CLIENT_ID", "nextcloud-mcp-server")
client_secret = os.getenv(
"KEYCLOAK_CLIENT_SECRET", "mcp-secret-change-in-production"
)
token_endpoint = f"{keycloak_url}/realms/{realm}/protocol/openid-connect/token"
async with httpx.AsyncClient(timeout=30.0) as client:
# Get service account token
token_response = await client.post(
token_endpoint,
data={
"grant_type": "client_credentials",
"client_id": client_id,
"client_secret": client_secret,
"scope": "openid profile email",
},
)
token_response.raise_for_status()
service_token = token_response.json()["access_token"]
# Exchange token - should work without any special role grants
exchange_response = await client.post(
token_endpoint,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": client_id,
"client_secret": client_secret,
"subject_token": service_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
},
)
# Should succeed without 403 Forbidden (no permission requirements)
assert exchange_response.status_code == 200, (
f"Standard V2 delegation should work without special permissions. "
f"Got: {exchange_response.status_code} {exchange_response.text}"
)
-4
View File
@@ -43,7 +43,6 @@ async def reset_all_singletons():
# Import all modules with singletons
import nextcloud_mcp_server.app as app_module
import nextcloud_mcp_server.auth.client_registry as client_registry_module
import nextcloud_mcp_server.auth.token_exchange as token_exchange_module
import nextcloud_mcp_server.embedding.service as embedding_module
import nextcloud_mcp_server.observability.tracing as tracing_module
import nextcloud_mcp_server.providers.registry as registry_module
@@ -63,7 +62,6 @@ async def reset_all_singletons():
),
"tracer": tracing_module._tracer,
"registry": client_registry_module._registry,
"token_exchange_service": token_exchange_module._token_exchange_service,
}
# Close any open memory streams before reset
@@ -89,7 +87,6 @@ async def reset_all_singletons():
app_module._vector_sync_state.scanner_wake_event = None
tracing_module._tracer = None
client_registry_module._registry = None
token_exchange_module._token_exchange_service = None
logger.debug("All singletons reset for test module")
@@ -115,4 +112,3 @@ async def reset_all_singletons():
) = originals["vector_sync_state"]
tracing_module._tracer = originals["tracer"]
client_registry_module._registry = originals["registry"]
token_exchange_module._token_exchange_service = originals["token_exchange_service"]
@@ -1,380 +0,0 @@
"""Integration tests for RFC 8693 Token Exchange with Keycloak.
These tests validate the complete token exchange flow:
1. Obtain client token from Keycloak
2. Exchange for Nextcloud-audience token via RFC 8693
3. Use exchanged token to access Nextcloud APIs
4. Verify CRUD operations work with exchanged tokens
Requirements:
- Keycloak running with nextcloud-mcp realm configured
- Nextcloud running with user_oidc app configured
- Standard Token Exchange enabled on both clients
- token-exchange-nextcloud scope configured
"""
from typing import Any
import httpx
import jwt
import pytest
@pytest.fixture
async def keycloak_base_url() -> str:
"""Keycloak base URL (external)."""
return "http://localhost:8888"
@pytest.fixture
async def keycloak_token_url(keycloak_base_url: str) -> str:
"""Keycloak token endpoint URL."""
return f"{keycloak_base_url}/realms/nextcloud-mcp/protocol/openid-connect/token"
@pytest.fixture
async def nextcloud_base_url() -> str:
"""Nextcloud base URL."""
return "http://localhost:8080"
@pytest.fixture
async def http_client() -> httpx.AsyncClient:
"""Async HTTP client for API requests."""
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
yield client
@pytest.fixture
async def keycloak_client_token(
http_client: httpx.AsyncClient, keycloak_token_url: str
) -> str:
"""Get client token from Keycloak using password grant.
Returns token with aud: ["nextcloud-mcp-server", "nextcloud"]
"""
response = await http_client.post(
keycloak_token_url,
data={
"grant_type": "password",
"client_id": "nextcloud-mcp-server",
"client_secret": "mcp-secret-change-in-production",
"username": "admin",
"password": "admin",
"scope": "openid profile email offline_access notes:read notes:write",
},
)
response.raise_for_status()
token_data = response.json()
return token_data["access_token"]
async def exchange_token(
http_client: httpx.AsyncClient,
token_url: str,
subject_token: str,
audience: str = "nextcloud",
) -> dict[str, Any]:
"""Exchange token using RFC 8693.
Args:
http_client: HTTP client
token_url: Token endpoint URL
subject_token: Token to exchange
audience: Target audience
Returns:
Token response with access_token and expires_in
"""
response = await http_client.post(
token_url,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": "nextcloud-mcp-server",
"client_secret": "mcp-secret-change-in-production",
"subject_token": subject_token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": audience,
},
)
response.raise_for_status()
return response.json()
def decode_token_claims(token: str) -> dict[str, Any]:
"""Decode JWT token claims without verification.
Args:
token: JWT token
Returns:
Token claims
"""
return jwt.decode(token, options={"verify_signature": False})
@pytest.mark.integration
@pytest.mark.keycloak
class TestKeycloakTokenExchange:
"""Test RFC 8693 Token Exchange with Keycloak."""
async def test_token_exchange_basic(
self,
http_client: httpx.AsyncClient,
keycloak_token_url: str,
keycloak_client_token: str,
):
"""Test basic token exchange flow."""
# Verify initial token has both audiences
initial_claims = decode_token_claims(keycloak_client_token)
assert "nextcloud-mcp-server" in initial_claims["aud"]
assert "nextcloud" in initial_claims["aud"]
assert initial_claims["azp"] == "nextcloud-mcp-server"
# Exchange for Nextcloud-audience token
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
assert "access_token" in exchange_response
assert "expires_in" in exchange_response
assert exchange_response["expires_in"] > 0
# Verify exchanged token has correct audience
exchanged_token = exchange_response["access_token"]
exchanged_claims = decode_token_claims(exchanged_token)
assert exchanged_claims["aud"] == "nextcloud"
assert exchanged_claims["azp"] == "nextcloud-mcp-server"
assert exchanged_claims["sub"] == initial_claims["sub"]
async def test_token_exchange_with_nextcloud_api(
self,
http_client: httpx.AsyncClient,
keycloak_token_url: str,
keycloak_client_token: str,
nextcloud_base_url: str,
):
"""Test exchanged token works with Nextcloud APIs."""
# Exchange token
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
nextcloud_token = exchange_response["access_token"]
# Call Nextcloud Capabilities API
response = await http_client.get(
f"{nextcloud_base_url}/ocs/v1.php/cloud/capabilities",
headers={
"Authorization": f"Bearer {nextcloud_token}",
"OCS-APIRequest": "true",
},
)
response.raise_for_status()
# Verify response contains OCS data
assert "ocs" in response.text.lower()
async def test_token_exchange_multiple_times(
self,
http_client: httpx.AsyncClient,
keycloak_token_url: str,
keycloak_client_token: str,
):
"""Test multiple exchanges from same client token (stateless)."""
# Exchange token three times
tokens = []
for _ in range(3):
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
tokens.append(exchange_response["access_token"])
# All exchanges should succeed
assert len(tokens) == 3
# Tokens should be different (fresh ephemeral tokens)
# Note: Keycloak may cache, so tokens might be identical
# The important thing is that all exchanges succeeded
async def test_token_exchange_crud_operations(
self,
http_client: httpx.AsyncClient,
keycloak_token_url: str,
keycloak_client_token: str,
nextcloud_base_url: str,
):
"""Test CRUD operations with exchanged tokens."""
notes_api = f"{nextcloud_base_url}/index.php/apps/notes/api/v1/notes"
# Step 1: Exchange token for CREATE
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
create_token = exchange_response["access_token"]
# Step 2: Create a test note
create_response = await http_client.post(
notes_api,
headers={"Authorization": f"Bearer {create_token}"},
json={
"title": "Token Exchange Test",
"content": "This note was created using an RFC 8693 exchanged token!",
"category": "Test",
},
)
create_response.raise_for_status()
note_data = create_response.json()
note_id = note_data["id"]
assert note_data["title"] == "Token Exchange Test"
assert note_data["category"] == "Test"
# Step 3: Exchange token again for READ (simulate new request)
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
read_token = exchange_response["access_token"]
# Step 4: Read the note back
read_response = await http_client.get(
f"{notes_api}/{note_id}",
headers={"Authorization": f"Bearer {read_token}"},
)
read_response.raise_for_status()
read_data = read_response.json()
assert read_data["id"] == note_id
assert read_data["title"] == "Token Exchange Test"
assert "RFC 8693 exchanged token" in read_data["content"]
# Step 5: Exchange token again for DELETE
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
delete_token = exchange_response["access_token"]
# Step 6: Delete the note
delete_response = await http_client.delete(
f"{notes_api}/{note_id}",
headers={"Authorization": f"Bearer {delete_token}"},
)
# Notes API returns the deleted note or empty array
assert delete_response.status_code in (200, 204)
async def test_token_claims_preservation(
self,
http_client: httpx.AsyncClient,
keycloak_token_url: str,
keycloak_client_token: str,
):
"""Test that important claims are preserved during exchange."""
initial_claims = decode_token_claims(keycloak_client_token)
# Exchange token
exchange_response = await exchange_token(
http_client, keycloak_token_url, keycloak_client_token
)
exchanged_token = exchange_response["access_token"]
exchanged_claims = decode_token_claims(exchanged_token)
# Subject (user ID) should be preserved
assert exchanged_claims["sub"] == initial_claims["sub"]
# Authorized party should show delegation
assert exchanged_claims["azp"] == "nextcloud-mcp-server"
# Audience should be filtered to target
assert exchanged_claims["aud"] == "nextcloud"
# Token should have expiration
assert "exp" in exchanged_claims
assert exchanged_claims["exp"] > 0
async def test_token_exchange_scope_configuration(
self, http_client: httpx.AsyncClient, keycloak_token_url: str
):
"""Test that token-exchange-nextcloud scope is configured as default.
Since token-exchange-nextcloud is a default scope for nextcloud-mcp-server,
all tokens should have the nextcloud audience available for exchange.
"""
# Get a token - should automatically include default scopes
response = await http_client.post(
keycloak_token_url,
data={
"grant_type": "password",
"client_id": "nextcloud-mcp-server",
"client_secret": "mcp-secret-change-in-production",
"username": "admin",
"password": "admin",
"scope": "openid profile email",
},
)
response.raise_for_status()
token = response.json()["access_token"]
# Verify token has nextcloud in aud (from default token-exchange-nextcloud scope)
claims = decode_token_claims(token)
assert "nextcloud" in claims.get("aud", [])
# Exchange should succeed
exchange_response = await http_client.post(
keycloak_token_url,
data={
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": "nextcloud-mcp-server",
"client_secret": "mcp-secret-change-in-production",
"subject_token": token,
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"requested_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": "nextcloud",
},
)
# Should succeed because token-exchange-nextcloud is a default scope
assert exchange_response.status_code == 200
exchanged_data = exchange_response.json()
assert "access_token" in exchanged_data
@pytest.mark.integration
@pytest.mark.keycloak
class TestTokenExchangeService:
"""Test the TokenExchangeService implementation."""
async def test_exchange_token_for_audience(
self, keycloak_client_token: str, keycloak_token_url: str
):
"""Test the exchange_token_for_audience function."""
from nextcloud_mcp_server.auth.token_exchange import (
TokenExchangeService,
)
# Create service
service = TokenExchangeService(
oidc_discovery_url="http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
client_id="nextcloud-mcp-server",
client_secret="mcp-secret-change-in-production",
)
try:
# Exchange token
exchanged_token, expires_in = await service.exchange_token_for_audience(
subject_token=keycloak_client_token,
requested_audience="nextcloud",
)
# Verify exchange succeeded
assert exchanged_token is not None
assert isinstance(exchanged_token, str)
assert expires_in > 0
# Verify token has correct claims
claims = decode_token_claims(exchanged_token)
assert claims["aud"] == "nextcloud"
assert claims["azp"] == "nextcloud-mcp-server"
finally:
await service.close()