Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions ushadow/backend/src/config/keycloak_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Keycloak configuration settings.

This module provides configuration for Keycloak integration using OmegaConf.
All sensitive values (passwords, client secrets) are stored in secrets.yaml.
"""

from src.config import get_settings_store as get_settings

def get_keycloak_config() -> dict:
"""Get Keycloak configuration from OmegaConf settings.

Returns:
dict with keys:
- enabled: bool
- url: str (internal Docker URL)
- public_url: str (external browser URL)
- realm: str
- backend_client_id: str
- backend_client_secret: str (from secrets.yaml)
- frontend_client_id: str
- admin_user: str
- admin_password: str (from secrets.yaml)
"""
settings = get_settings()

# Public configuration (from config.defaults.yaml)
config = {
"enabled": settings.get_sync("keycloak.enabled", False),
"url": settings.get_sync("keycloak.url", "http://keycloak:8080"),
"public_url": settings.get_sync("keycloak.public_url", "http://localhost:8080"),
"realm": settings.get_sync("keycloak.realm", "ushadow"),
"backend_client_id": settings.get_sync("keycloak.backend_client_id", "ushadow-backend"),
"frontend_client_id": settings.get_sync("keycloak.frontend_client_id", "ushadow-frontend"),
"admin_user": settings.get_sync("keycloak.admin_user", "admin"),
}

# Secrets (from config/SECRETS/secrets.yaml)
config["backend_client_secret"] = settings.get_sync("keycloak.backend_client_secret")
config["admin_password"] = settings.get_sync("keycloak.admin_password")

return config


def is_keycloak_enabled() -> bool:
"""Check if Keycloak authentication is enabled.

This allows running both auth systems in parallel during migration:
- keycloak.enabled=false: Use existing fastapi-users auth
- keycloak.enabled=true: Use Keycloak (or hybrid mode)
"""
settings = get_settings()
return settings.get_sync("keycloak.enabled", False)
145 changes: 145 additions & 0 deletions ushadow/backend/src/routers/keycloak_admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
"""
Keycloak Admin Router

Admin endpoints for managing Keycloak configuration.
"""

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import logging

from src.services.keycloak_admin import get_keycloak_admin

router = APIRouter()
logger = logging.getLogger(__name__)


class ClientUpdateResponse(BaseModel):
"""Response for client update operations"""
success: bool
message: str
client_id: str


@router.post("/clients/{client_id}/enable-pkce", response_model=ClientUpdateResponse)
async def enable_pkce_for_client(client_id: str):
"""
Enable PKCE (Proof Key for Code Exchange) for a Keycloak client.

This updates the client configuration to require PKCE with S256 code challenge method.
PKCE is required for secure authentication in public clients (like SPAs).

Args:
client_id: The Keycloak client ID (e.g., "ushadow-frontend")

Returns:
Success status and message
"""
admin_client = get_keycloak_admin()

try:
# Get current client configuration
client = await admin_client.get_client_by_client_id(client_id)
if not client:
raise HTTPException(
status_code=404,
detail=f"Client '{client_id}' not found in Keycloak"
)

client_uuid = client["id"]
logger.info(f"[KC-ADMIN] Enabling PKCE for client: {client_id} ({client_uuid})")

# Update client attributes to require PKCE
import httpx
import os

token = await admin_client._get_admin_token()
keycloak_url = os.getenv("KEYCLOAK_URL", "http://keycloak:8080")
realm = os.getenv("KEYCLOAK_REALM", "ushadow")

# Get full client config first
async with httpx.AsyncClient() as http_client:
get_response = await http_client.get(
f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}",
headers={"Authorization": f"Bearer {token}"},
timeout=10.0
)

if get_response.status_code != 200:
raise HTTPException(
status_code=500,
detail=f"Failed to get client config: {get_response.text}"
)

full_client_config = get_response.json()

# Update attributes
if "attributes" not in full_client_config:
full_client_config["attributes"] = {}

full_client_config["attributes"]["pkce.code.challenge.method"] = "S256"

# Update client
update_response = await http_client.put(
f"{keycloak_url}/admin/realms/{realm}/clients/{client_uuid}",
headers={
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
},
json=full_client_config,
timeout=10.0
)

if update_response.status_code != 204:
raise HTTPException(
status_code=500,
detail=f"Failed to update client: {update_response.text}"
)

logger.info(f"[KC-ADMIN] ✓ PKCE enabled for client: {client_id}")

return ClientUpdateResponse(
success=True,
message=f"PKCE (S256) enabled for client '{client_id}'",
client_id=client_id
)

except HTTPException:
raise
except Exception as e:
logger.error(f"[KC-ADMIN] Failed to enable PKCE: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to enable PKCE: {str(e)}"
)


@router.get("/clients/{client_id}/config")
async def get_client_config(client_id: str):
"""
Get Keycloak client configuration.

Args:
client_id: The Keycloak client ID

Returns:
Client configuration including attributes
"""
admin_client = get_keycloak_admin()

client = await admin_client.get_client_by_client_id(client_id)
if not client:
raise HTTPException(
status_code=404,
detail=f"Client '{client_id}' not found"
)

return {
"client_id": client.get("clientId"),
"id": client.get("id"),
"enabled": client.get("enabled"),
"publicClient": client.get("publicClient"),
"standardFlowEnabled": client.get("standardFlowEnabled"),
"attributes": client.get("attributes", {}),
"redirectUris": client.get("redirectUris", []),
}
Loading
Loading