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:
co-authored by
Claude Opus 4.6
parent
6278b6eb75
commit
c6316dbb91
@@ -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}"
|
||||
)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user