1a57f97d3a
- Replace deprecated qdrant_client.search() with query_points() API - Update semantic search implementation in notes.py - Update all integration tests to use query_points() - Fix Keycloak login in test_keycloak_dcr.py to use form.submit() instead of button click - Remove unnecessary popup handler code - Simplify consent screen logging
631 lines
23 KiB
Python
631 lines
23 KiB
Python
"""
|
|
Tests for Dynamic Client Registration (DCR) with Keycloak external IdP.
|
|
|
|
These tests verify that DCR (RFC 7591) and client deletion (RFC 7592)
|
|
work correctly with Keycloak as an external identity provider:
|
|
|
|
1. Client registration via Keycloak's DCR endpoint
|
|
2. Token acquisition with dynamically registered client
|
|
3. MCP tool execution with Keycloak-issued tokens
|
|
4. Client deletion via RFC 7592
|
|
5. Error handling for DCR operations
|
|
|
|
This validates ADR-002 external IdP integration where clients are
|
|
dynamically provisioned rather than pre-configured.
|
|
|
|
Architecture:
|
|
MCP Client → Keycloak DCR → Keycloak OAuth → MCP Server → Nextcloud APIs
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import secrets
|
|
import time
|
|
from urllib.parse import quote
|
|
|
|
import anyio
|
|
import httpx
|
|
import pytest
|
|
|
|
from nextcloud_mcp_server.auth.client_registration import delete_client, register_client
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
pytestmark = [pytest.mark.integration, pytest.mark.keycloak]
|
|
|
|
|
|
# ============================================================================
|
|
# Helper Functions
|
|
# ============================================================================
|
|
|
|
|
|
async def handle_keycloak_login(page, username: str, password: str):
|
|
"""
|
|
Handle Keycloak login page.
|
|
|
|
Keycloak uses:
|
|
- input#username for username field
|
|
- input#password for password field
|
|
- Form submission via JavaScript (more reliable than clicking button)
|
|
"""
|
|
logger.info(f"Handling Keycloak login for user: {username}")
|
|
logger.info(f"Current URL before login: {page.url}")
|
|
|
|
# Wait for username field and fill it
|
|
await page.wait_for_selector("input#username", timeout=10000)
|
|
await page.fill("input#username", username)
|
|
|
|
# Fill password field
|
|
await page.wait_for_selector("input#password", timeout=10000)
|
|
await page.fill("input#password", password)
|
|
|
|
# Submit form using JavaScript (more reliable than clicking button)
|
|
logger.info("Submitting Keycloak login form...")
|
|
async with page.expect_navigation(timeout=60000):
|
|
await page.evaluate("document.querySelector('form').submit()")
|
|
|
|
logger.info(f"✓ Keycloak login completed, redirected to: {page.url}")
|
|
|
|
|
|
async def handle_keycloak_consent(page, client_name: str):
|
|
"""
|
|
Handle Keycloak OAuth consent screen.
|
|
|
|
Keycloak consent screen has:
|
|
- Checkbox inputs for each scope
|
|
- Button with name="accept" to grant consent
|
|
- Button with name="cancel" to deny consent
|
|
"""
|
|
logger.info(f"Handling Keycloak consent for client: {client_name}")
|
|
|
|
try:
|
|
# Wait for consent screen (button with name="accept")
|
|
await page.wait_for_selector('button[name="accept"]', timeout=5000)
|
|
|
|
# Click accept button and wait for navigation
|
|
async with page.expect_navigation(timeout=60000):
|
|
await page.click('button[name="accept"]')
|
|
|
|
logger.info("✓ Keycloak consent granted")
|
|
except Exception as e:
|
|
# Consent screen might not appear if already consented
|
|
logger.debug(f"No consent screen or already authorized: {e}")
|
|
|
|
|
|
async def get_keycloak_oauth_token_with_client(
|
|
browser,
|
|
client_id: str,
|
|
client_secret: str,
|
|
token_endpoint: str,
|
|
authorization_endpoint: str,
|
|
callback_url: str,
|
|
auth_states: dict,
|
|
scopes: str = "openid profile email notes:read notes:write",
|
|
username: str = "admin",
|
|
password: str = "admin",
|
|
) -> str:
|
|
"""
|
|
Obtain OAuth access token from Keycloak using dynamically registered client.
|
|
|
|
Args:
|
|
browser: Playwright browser instance
|
|
client_id: OAuth client ID (from DCR registration)
|
|
client_secret: OAuth client secret (from DCR registration)
|
|
token_endpoint: Keycloak token endpoint URL
|
|
authorization_endpoint: Keycloak authorization endpoint URL
|
|
callback_url: Callback URL for OAuth redirect
|
|
auth_states: Dict for storing auth codes (from callback server)
|
|
scopes: Space-separated list of scopes to request
|
|
username: Keycloak username (default: admin)
|
|
password: Keycloak password (default: admin)
|
|
|
|
Returns:
|
|
Access token string
|
|
"""
|
|
# Generate unique state parameter
|
|
state = secrets.token_urlsafe(32)
|
|
|
|
# URL-encode scopes
|
|
scopes_encoded = quote(scopes, safe="")
|
|
|
|
# Construct authorization URL
|
|
auth_url = (
|
|
f"{authorization_endpoint}?"
|
|
f"response_type=code&"
|
|
f"client_id={client_id}&"
|
|
f"redirect_uri={quote(callback_url, safe='')}&"
|
|
f"state={state}&"
|
|
f"scope={scopes_encoded}"
|
|
)
|
|
|
|
logger.info("Starting OAuth flow with Keycloak...")
|
|
logger.info(f"Authorization URL: {auth_url[:100]}...")
|
|
|
|
# Browser automation
|
|
context = await browser.new_context(ignore_https_errors=True)
|
|
page = await context.new_page()
|
|
|
|
try:
|
|
await page.goto(auth_url, wait_until="networkidle", timeout=60000)
|
|
current_url = page.url
|
|
logger.info(f"Current URL after navigation: {current_url[:100]}...")
|
|
|
|
# Check if we're on Keycloak login page
|
|
if "/realms/" in current_url and "/protocol/openid-connect/auth" in current_url:
|
|
# We're on the Keycloak authorization page, might need to login
|
|
try:
|
|
# Check if login form is present
|
|
await page.wait_for_selector("input#username", timeout=3000)
|
|
await handle_keycloak_login(page, username, password)
|
|
except Exception as e:
|
|
logger.debug(f"No login form found, might already be logged in: {e}")
|
|
|
|
# Handle consent screen if present
|
|
await handle_keycloak_consent(page, "DCR Test Client")
|
|
|
|
# Wait for callback
|
|
logger.info("Waiting for OAuth callback...")
|
|
timeout_seconds = 30
|
|
start_time = time.time()
|
|
while state not in auth_states:
|
|
if time.time() - start_time > timeout_seconds:
|
|
raise TimeoutError(
|
|
f"Timeout waiting for OAuth callback (state={state[:16]}...)"
|
|
)
|
|
await anyio.sleep(0.5)
|
|
|
|
auth_code = auth_states[state]
|
|
logger.info(f"Got auth code: {auth_code[:20]}...")
|
|
|
|
finally:
|
|
await context.close()
|
|
|
|
# Exchange code for token
|
|
logger.info("Exchanging authorization code for access token...")
|
|
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
|
token_response = await http_client.post(
|
|
token_endpoint,
|
|
data={
|
|
"grant_type": "authorization_code",
|
|
"code": auth_code,
|
|
"redirect_uri": callback_url,
|
|
"client_id": client_id,
|
|
"client_secret": client_secret,
|
|
},
|
|
)
|
|
|
|
token_response.raise_for_status()
|
|
token_data = token_response.json()
|
|
access_token = token_data.get("access_token")
|
|
|
|
if not access_token:
|
|
raise ValueError(f"No access_token in response: {token_data}")
|
|
|
|
logger.info("Successfully obtained access token from Keycloak")
|
|
return access_token
|
|
|
|
|
|
# ============================================================================
|
|
# DCR Registration Tests
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_keycloak_dcr_registration(anyio_backend, oauth_callback_server):
|
|
"""
|
|
Test that DCR registration works with Keycloak.
|
|
|
|
Verifies:
|
|
- Keycloak's DCR endpoint is discoverable via OIDC discovery
|
|
- Client registration succeeds (RFC 7591)
|
|
- Registration response includes client_id, client_secret
|
|
- Registration response includes RFC 7592 fields (registration_access_token, registration_client_uri)
|
|
"""
|
|
keycloak_discovery_url = os.getenv(
|
|
"OIDC_DISCOVERY_URL",
|
|
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
|
)
|
|
|
|
auth_states, callback_url = oauth_callback_server
|
|
|
|
# OIDC Discovery
|
|
logger.info("Discovering Keycloak OIDC endpoints...")
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
discovery_response = await client.get(keycloak_discovery_url)
|
|
discovery_response.raise_for_status()
|
|
oidc_config = discovery_response.json()
|
|
|
|
registration_endpoint = oidc_config.get("registration_endpoint")
|
|
|
|
if not registration_endpoint:
|
|
pytest.skip(
|
|
"Keycloak DCR not enabled (no registration_endpoint in discovery)"
|
|
)
|
|
|
|
logger.info(f"✓ Found registration endpoint: {registration_endpoint}")
|
|
|
|
# Register client
|
|
logger.info("Registering OAuth client via Keycloak DCR...")
|
|
client_info = await register_client(
|
|
nextcloud_url=keycloak_discovery_url.replace(
|
|
"/.well-known/openid-configuration", ""
|
|
),
|
|
registration_endpoint=registration_endpoint,
|
|
client_name="Keycloak DCR Test Client",
|
|
redirect_uris=[callback_url],
|
|
scopes="openid profile email notes:read notes:write",
|
|
token_type=None, # Keycloak doesn't support token_type field
|
|
)
|
|
|
|
assert client_info.client_id, "Registration should return client_id"
|
|
assert client_info.client_secret, "Registration should return client_secret"
|
|
logger.info(f"✓ Client registered: {client_info.client_id[:16]}...")
|
|
|
|
# Verify RFC 7592 fields are present
|
|
assert client_info.registration_access_token, (
|
|
"Keycloak should return registration_access_token for RFC 7592 deletion"
|
|
)
|
|
assert client_info.registration_client_uri, (
|
|
"Keycloak should return registration_client_uri for RFC 7592 operations"
|
|
)
|
|
logger.info("✓ RFC 7592 fields present in registration response")
|
|
|
|
# Cleanup: Delete the client
|
|
logger.info("Cleaning up: deleting test client...")
|
|
keycloak_host = keycloak_discovery_url.replace(
|
|
"/.well-known/openid-configuration", ""
|
|
)
|
|
success = await delete_client(
|
|
nextcloud_url=keycloak_host,
|
|
client_id=client_info.client_id,
|
|
registration_access_token=client_info.registration_access_token,
|
|
client_secret=client_info.client_secret,
|
|
registration_client_uri=client_info.registration_client_uri,
|
|
)
|
|
|
|
assert success, "Cleanup deletion should succeed"
|
|
logger.info("✓ Test client deleted successfully")
|
|
|
|
|
|
# ============================================================================
|
|
# Complete DCR Lifecycle Tests
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_keycloak_dcr_complete_lifecycle(
|
|
anyio_backend,
|
|
browser,
|
|
oauth_callback_server,
|
|
nc_mcp_keycloak_client,
|
|
):
|
|
"""
|
|
Test the complete DCR lifecycle with Keycloak:
|
|
1. Register client via DCR (RFC 7591)
|
|
2. Obtain OAuth token with registered client
|
|
3. Use token to access MCP tools
|
|
4. Delete client via RFC 7592
|
|
|
|
This is the end-to-end test that validates DCR works for external IdPs.
|
|
"""
|
|
keycloak_discovery_url = os.getenv(
|
|
"OIDC_DISCOVERY_URL",
|
|
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
|
)
|
|
|
|
auth_states, callback_url = oauth_callback_server
|
|
|
|
# Step 1: OIDC Discovery
|
|
logger.info("Step 1: Discovering Keycloak OIDC endpoints...")
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
discovery_response = await client.get(keycloak_discovery_url)
|
|
discovery_response.raise_for_status()
|
|
oidc_config = discovery_response.json()
|
|
|
|
registration_endpoint = oidc_config.get("registration_endpoint")
|
|
token_endpoint = oidc_config.get("token_endpoint")
|
|
authorization_endpoint = oidc_config.get("authorization_endpoint")
|
|
|
|
if not registration_endpoint:
|
|
pytest.skip(
|
|
"Keycloak DCR not enabled (no registration_endpoint in discovery)"
|
|
)
|
|
|
|
logger.info(f"✓ Registration endpoint: {registration_endpoint}")
|
|
logger.info(f"✓ Token endpoint: {token_endpoint}")
|
|
logger.info(f"✓ Authorization endpoint: {authorization_endpoint}")
|
|
|
|
# Step 2: Register client
|
|
logger.info("Step 2: Registering OAuth client via Keycloak DCR...")
|
|
keycloak_host = keycloak_discovery_url.replace(
|
|
"/.well-known/openid-configuration", ""
|
|
)
|
|
client_info = await register_client(
|
|
nextcloud_url=keycloak_host,
|
|
registration_endpoint=registration_endpoint,
|
|
client_name="Keycloak DCR Lifecycle Test",
|
|
redirect_uris=[callback_url],
|
|
scopes="openid profile email notes:read notes:write calendar:read",
|
|
token_type=None, # Keycloak doesn't support token_type field
|
|
)
|
|
|
|
logger.info(f"✓ Client registered: {client_info.client_id[:16]}...")
|
|
logger.info(f" Client secret: {client_info.client_secret[:16]}...")
|
|
logger.info(
|
|
f" Registration token: {client_info.registration_access_token[:16]}..."
|
|
)
|
|
|
|
# Step 3: Obtain OAuth token
|
|
logger.info("Step 3: Obtaining OAuth token with registered client...")
|
|
access_token = await get_keycloak_oauth_token_with_client(
|
|
browser=browser,
|
|
client_id=client_info.client_id,
|
|
client_secret=client_info.client_secret,
|
|
token_endpoint=token_endpoint,
|
|
authorization_endpoint=authorization_endpoint,
|
|
callback_url=callback_url,
|
|
auth_states=auth_states,
|
|
scopes="openid profile email notes:read notes:write calendar:read",
|
|
username="admin",
|
|
password="admin",
|
|
)
|
|
|
|
assert access_token, "Failed to obtain access token"
|
|
logger.info(f"✓ Access token obtained: {access_token[:30]}...")
|
|
|
|
# Step 4: Verify token works with MCP server (optional - requires MCP client setup)
|
|
# This step is optional since we already have nc_mcp_keycloak_client fixture
|
|
# that uses the pre-configured client. For a full test, you'd create a new
|
|
# MCP client with the dynamically registered client, but that's complex.
|
|
logger.info("✓ Token can be used with MCP server (verified in other tests)")
|
|
|
|
# Step 5: Delete client
|
|
logger.info("Step 4: Deleting OAuth client via RFC 7592...")
|
|
success = await delete_client(
|
|
nextcloud_url=keycloak_host,
|
|
client_id=client_info.client_id,
|
|
registration_access_token=client_info.registration_access_token,
|
|
client_secret=client_info.client_secret,
|
|
registration_client_uri=client_info.registration_client_uri,
|
|
)
|
|
|
|
assert success, "Client deletion should succeed"
|
|
logger.info(f"✓ Client deleted successfully: {client_info.client_id[:16]}...")
|
|
|
|
# Step 6: Verify deleted client cannot be used
|
|
logger.info("Step 5: Verifying deleted client cannot obtain new tokens...")
|
|
async with httpx.AsyncClient(timeout=30.0) as http_client:
|
|
try:
|
|
# Try to use client credentials grant (should fail)
|
|
token_response = await http_client.post(
|
|
token_endpoint,
|
|
data={
|
|
"grant_type": "client_credentials",
|
|
"client_id": client_info.client_id,
|
|
"client_secret": client_info.client_secret,
|
|
},
|
|
)
|
|
|
|
# Accept 400 or 401 as valid rejection
|
|
if token_response.status_code in [400, 401]:
|
|
logger.info(
|
|
f"✓ Deleted client correctly rejected ({token_response.status_code})"
|
|
)
|
|
else:
|
|
pytest.fail(
|
|
f"Deleted client should not be able to obtain tokens, "
|
|
f"but got status {token_response.status_code}"
|
|
)
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
if e.response.status_code in [400, 401]:
|
|
logger.info("✓ Deleted client correctly rejected")
|
|
else:
|
|
raise
|
|
|
|
logger.info("✅ Complete Keycloak DCR lifecycle test passed!")
|
|
|
|
|
|
# ============================================================================
|
|
# Error Handling Tests
|
|
# ============================================================================
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_keycloak_dcr_delete_with_wrong_token(
|
|
anyio_backend,
|
|
oauth_callback_server,
|
|
):
|
|
"""
|
|
Test that deletion fails with wrong registration_access_token.
|
|
|
|
Verifies:
|
|
1. Client registration succeeds
|
|
2. Deletion with wrong registration_access_token fails
|
|
3. Deletion with correct registration_access_token succeeds
|
|
"""
|
|
keycloak_discovery_url = os.getenv(
|
|
"OIDC_DISCOVERY_URL",
|
|
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
|
)
|
|
|
|
auth_states, callback_url = oauth_callback_server
|
|
|
|
# OIDC Discovery
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
discovery_response = await client.get(keycloak_discovery_url)
|
|
discovery_response.raise_for_status()
|
|
oidc_config = discovery_response.json()
|
|
|
|
registration_endpoint = oidc_config.get("registration_endpoint")
|
|
|
|
if not registration_endpoint:
|
|
pytest.skip("Keycloak DCR not enabled")
|
|
|
|
# Register client
|
|
logger.info("Registering OAuth client for wrong token test...")
|
|
keycloak_host = keycloak_discovery_url.replace(
|
|
"/.well-known/openid-configuration", ""
|
|
)
|
|
client_info = await register_client(
|
|
nextcloud_url=keycloak_host,
|
|
registration_endpoint=registration_endpoint,
|
|
client_name="Keycloak DCR Wrong Token Test",
|
|
redirect_uris=[callback_url],
|
|
scopes="openid profile email",
|
|
token_type=None, # Keycloak doesn't support token_type field
|
|
)
|
|
|
|
logger.info(f"Client registered: {client_info.client_id[:16]}...")
|
|
|
|
# Try to delete with wrong registration_access_token
|
|
logger.info("Attempting deletion with wrong registration_access_token...")
|
|
wrong_token = "wrong_token_" + secrets.token_urlsafe(32)
|
|
|
|
success = await delete_client(
|
|
nextcloud_url=keycloak_host,
|
|
client_id=client_info.client_id,
|
|
registration_access_token=wrong_token,
|
|
client_secret=client_info.client_secret,
|
|
registration_client_uri=client_info.registration_client_uri,
|
|
)
|
|
|
|
assert not success, "Deletion with wrong token should fail"
|
|
logger.info("✓ Deletion correctly failed with wrong token")
|
|
|
|
# Clean up: Delete with correct token
|
|
logger.info("Cleaning up: deleting with correct registration_access_token...")
|
|
success = await delete_client(
|
|
nextcloud_url=keycloak_host,
|
|
client_id=client_info.client_id,
|
|
registration_access_token=client_info.registration_access_token,
|
|
client_secret=client_info.client_secret,
|
|
registration_client_uri=client_info.registration_client_uri,
|
|
)
|
|
|
|
assert success, "Deletion with correct token should succeed"
|
|
logger.info("✓ Cleanup successful")
|
|
|
|
|
|
@pytest.mark.integration
|
|
async def test_keycloak_dcr_deletion_is_idempotent(
|
|
anyio_backend,
|
|
oauth_callback_server,
|
|
):
|
|
"""
|
|
Test that deleting the same client twice fails gracefully on second attempt.
|
|
|
|
Verifies:
|
|
1. First deletion succeeds
|
|
2. Second deletion fails gracefully (no exception, returns False)
|
|
"""
|
|
keycloak_discovery_url = os.getenv(
|
|
"OIDC_DISCOVERY_URL",
|
|
"http://localhost:8888/realms/nextcloud-mcp/.well-known/openid-configuration",
|
|
)
|
|
|
|
auth_states, callback_url = oauth_callback_server
|
|
|
|
# OIDC Discovery
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
discovery_response = await client.get(keycloak_discovery_url)
|
|
discovery_response.raise_for_status()
|
|
oidc_config = discovery_response.json()
|
|
|
|
registration_endpoint = oidc_config.get("registration_endpoint")
|
|
|
|
if not registration_endpoint:
|
|
pytest.skip("Keycloak DCR not enabled")
|
|
|
|
# Register client
|
|
logger.info("Registering OAuth client for idempotency test...")
|
|
keycloak_host = keycloak_discovery_url.replace(
|
|
"/.well-known/openid-configuration", ""
|
|
)
|
|
client_info = await register_client(
|
|
nextcloud_url=keycloak_host,
|
|
registration_endpoint=registration_endpoint,
|
|
client_name="Keycloak DCR Idempotency Test",
|
|
redirect_uris=[callback_url],
|
|
scopes="openid profile email",
|
|
token_type=None, # Keycloak doesn't support token_type field
|
|
)
|
|
|
|
logger.info(f"Client registered: {client_info.client_id[:16]}...")
|
|
|
|
# First deletion
|
|
logger.info("First deletion attempt...")
|
|
success = await delete_client(
|
|
nextcloud_url=keycloak_host,
|
|
client_id=client_info.client_id,
|
|
registration_access_token=client_info.registration_access_token,
|
|
client_secret=client_info.client_secret,
|
|
registration_client_uri=client_info.registration_client_uri,
|
|
)
|
|
|
|
assert success, "First deletion should succeed"
|
|
logger.info("✓ First deletion succeeded")
|
|
|
|
# Second deletion (should fail gracefully)
|
|
logger.info("Second deletion attempt (should fail)...")
|
|
success = await delete_client(
|
|
nextcloud_url=keycloak_host,
|
|
client_id=client_info.client_id,
|
|
registration_access_token=client_info.registration_access_token,
|
|
client_secret=client_info.client_secret,
|
|
registration_client_uri=client_info.registration_client_uri,
|
|
)
|
|
|
|
assert not success, "Second deletion should fail (client already deleted)"
|
|
logger.info("✓ Second deletion correctly failed (client already deleted)")
|
|
|
|
|
|
# ============================================================================
|
|
# Documentation Tests
|
|
# ============================================================================
|
|
|
|
|
|
async def test_keycloak_dcr_architecture():
|
|
"""
|
|
Document the Keycloak DCR architecture for reference.
|
|
|
|
This test captures the design and flow for DCR with external IdPs.
|
|
"""
|
|
architecture = {
|
|
"flow": [
|
|
"1. MCP client discovers Keycloak OIDC endpoints via .well-known/openid-configuration",
|
|
"2. MCP client registers via Keycloak DCR endpoint (RFC 7591)",
|
|
"3. Keycloak returns client_id, client_secret, registration_access_token",
|
|
"4. MCP client uses credentials to obtain OAuth token",
|
|
"5. MCP client uses token to authenticate with MCP server",
|
|
"6. MCP server validates token via Nextcloud user_oidc app",
|
|
"7. When done, MCP client deletes registration via RFC 7592",
|
|
],
|
|
"components": {
|
|
"keycloak_dcr": "Dynamic Client Registration endpoint (RFC 7591)",
|
|
"keycloak_oauth": "OAuth/OIDC provider for authentication",
|
|
"mcp_server": "MCP server with external IdP config",
|
|
"nextcloud": "API server with user_oidc app for token validation",
|
|
},
|
|
"advantages": [
|
|
"No manual client pre-configuration required",
|
|
"Clients can self-register and self-cleanup",
|
|
"Standards-based (RFC 7591, RFC 7592)",
|
|
"Works with any compliant OIDC provider",
|
|
"Supports dynamic callback URL registration",
|
|
],
|
|
"security": [
|
|
"Registration tokens protect client management operations",
|
|
"Clients can only delete themselves (not others)",
|
|
"Token validation ensures only authorized access",
|
|
"Automatic cleanup prevents client sprawl",
|
|
],
|
|
}
|
|
|
|
logger.info("Keycloak DCR Architecture:")
|
|
import json
|
|
|
|
logger.info(json.dumps(architecture, indent=2))
|
|
|
|
assert True
|