diff --git a/ushadow/backend/src/config/keycloak_settings.py b/ushadow/backend/src/config/keycloak_settings.py new file mode 100644 index 00000000..0633cd84 --- /dev/null +++ b/ushadow/backend/src/config/keycloak_settings.py @@ -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) diff --git a/ushadow/backend/src/routers/keycloak_admin.py b/ushadow/backend/src/routers/keycloak_admin.py new file mode 100644 index 00000000..1190d456 --- /dev/null +++ b/ushadow/backend/src/routers/keycloak_admin.py @@ -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", []), + } diff --git a/ushadow/backend/src/services/keycloak_admin.py b/ushadow/backend/src/services/keycloak_admin.py new file mode 100644 index 00000000..e1cb80b7 --- /dev/null +++ b/ushadow/backend/src/services/keycloak_admin.py @@ -0,0 +1,400 @@ +""" +Keycloak Admin API Service + +Manages Keycloak configuration programmatically via Admin REST API. +Primary use case: Dynamic redirect URI registration for multi-environment worktrees. + +Each Ushadow environment (worktree) runs on a different port: +- ushadow: 3010 (PORT_OFFSET=10) +- ushadow-orange: 3020 (PORT_OFFSET=20) +- ushadow-yellow: 3030 (PORT_OFFSET=30) + +This service ensures Keycloak accepts redirects from all active environments. +""" + +import os +import logging +import httpx +from typing import Optional, List + +logger = logging.getLogger(__name__) + + +class KeycloakAdminClient: + """Keycloak Admin API client for managing realm configuration.""" + + def __init__( + self, + keycloak_url: str, + realm: str, + admin_user: str, + admin_password: str, + ): + self.keycloak_url = keycloak_url + self.realm = realm + self.admin_user = admin_user + self.admin_password = admin_password + self._access_token: Optional[str] = None + + async def _get_admin_token(self) -> str: + """ + Get admin access token for Keycloak Admin API. + + Uses master realm admin credentials to authenticate. + Token is cached and reused until it expires. + """ + if self._access_token: + # TODO: Check token expiration and refresh if needed + return self._access_token + + token_url = f"{self.keycloak_url}/realms/master/protocol/openid-connect/token" + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + token_url, + data={ + "grant_type": "password", + "client_id": "admin-cli", + "username": self.admin_user, + "password": self.admin_password, + }, + timeout=10.0, + ) + + if response.status_code != 200: + logger.error(f"[KC-ADMIN] Failed to get admin token: {response.text}") + raise Exception(f"Failed to authenticate as Keycloak admin: {response.status_code}") + + tokens = response.json() + self._access_token = tokens["access_token"] + logger.info("[KC-ADMIN] ✓ Authenticated as Keycloak admin") + return self._access_token + + except httpx.RequestError as e: + logger.error(f"[KC-ADMIN] Failed to connect to Keycloak: {e}") + raise Exception(f"Failed to connect to Keycloak Admin API: {e}") + + async def get_client_by_client_id(self, client_id: str) -> Optional[dict]: + """ + Get Keycloak client configuration by client_id. + + Args: + client_id: The client_id (e.g., "ushadow-frontend") + + Returns: + Client configuration dict if found, None otherwise + """ + token = await self._get_admin_token() + url = f"{self.keycloak_url}/admin/realms/{self.realm}/clients" + + async with httpx.AsyncClient() as client: + try: + response = await client.get( + url, + headers={"Authorization": f"Bearer {token}"}, + params={"clientId": client_id}, + timeout=10.0, + ) + + if response.status_code != 200: + logger.error(f"[KC-ADMIN] Failed to get client: {response.text}") + return None + + clients = response.json() + if not clients or len(clients) == 0: + logger.warning(f"[KC-ADMIN] Client '{client_id}' not found") + return None + + return clients[0] # Returns first match + + except httpx.RequestError as e: + logger.error(f"[KC-ADMIN] Failed to get client: {e}") + return None + + async def update_client_redirect_uris( + self, + client_id: str, + redirect_uris: List[str], + merge: bool = True + ) -> bool: + """ + Update redirect URIs for a Keycloak client. + + Args: + client_id: The client_id (e.g., "ushadow-frontend") + redirect_uris: List of redirect URIs to set + merge: If True, merge with existing URIs. If False, replace entirely. + + Returns: + True if successful, False otherwise + """ + # Get current client configuration + client = await self.get_client_by_client_id(client_id) + if not client: + logger.error(f"[KC-ADMIN] Cannot update redirect URIs - client '{client_id}' not found") + return False + + client_uuid = client["id"] # Internal UUID, not the client_id + + # Merge or replace redirect URIs + if merge: + existing_uris = set(client.get("redirectUris", [])) + new_uris = existing_uris.union(set(redirect_uris)) + final_uris = list(new_uris) + logger.info(f"[KC-ADMIN] Merging redirect URIs: {len(existing_uris)} existing + {len(redirect_uris)} new = {len(final_uris)} total") + else: + final_uris = redirect_uris + logger.info(f"[KC-ADMIN] Replacing redirect URIs with {len(final_uris)} URIs") + + # Update client configuration + token = await self._get_admin_token() + url = f"{self.keycloak_url}/admin/realms/{self.realm}/clients/{client_uuid}" + + async with httpx.AsyncClient() as client_http: + try: + # Prepare update payload (only redirect URIs) + update_payload = { + "id": client_uuid, + "clientId": client_id, + "redirectUris": final_uris, + } + + response = await client_http.put( + url, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json=update_payload, + timeout=10.0, + ) + + if response.status_code != 204: # Keycloak returns 204 No Content on success + logger.error(f"[KC-ADMIN] Failed to update client: {response.status_code} - {response.text}") + return False + + logger.info(f"[KC-ADMIN] ✓ Updated redirect URIs for client '{client_id}'") + for uri in final_uris: + logger.info(f"[KC-ADMIN] - {uri}") + return True + + except httpx.RequestError as e: + logger.error(f"[KC-ADMIN] Failed to update client: {e}") + return False + + async def register_redirect_uri(self, client_id: str, redirect_uri: str) -> bool: + """ + Register a single redirect URI for a client (merges with existing). + + Args: + client_id: The client_id (e.g., "ushadow-frontend") + redirect_uri: The redirect URI to add (e.g., "http://localhost:3010/auth/callback") + + Returns: + True if successful, False otherwise + """ + return await self.update_client_redirect_uris( + client_id=client_id, + redirect_uris=[redirect_uri], + merge=True + ) + + async def update_post_logout_redirect_uris( + self, + client_id: str, + post_logout_redirect_uris: List[str], + merge: bool = True + ) -> bool: + """ + Update post-logout redirect URIs for a Keycloak client. + + Args: + client_id: The client_id (e.g., "ushadow-frontend") + post_logout_redirect_uris: List of post-logout redirect URIs to set + merge: If True, merge with existing URIs. If False, replace entirely. + + Returns: + True if successful, False otherwise + """ + # Get client UUID + client = await self.get_client_by_client_id(client_id) + if not client: + logger.error(f"[KC-ADMIN] Client '{client_id}' not found") + return False + + client_uuid = client["id"] + + # Merge or replace post-logout redirect URIs + if merge: + existing_uris = set(client.get("attributes", {}).get("post.logout.redirect.uris", "").split("##")) + # Remove empty strings from the set + existing_uris = {uri for uri in existing_uris if uri} + new_uris = existing_uris.union(set(post_logout_redirect_uris)) + final_uris = list(new_uris) + logger.info(f"[KC-ADMIN] Merging post-logout redirect URIs: {len(existing_uris)} existing + {len(post_logout_redirect_uris)} new = {len(final_uris)} total") + else: + final_uris = post_logout_redirect_uris + logger.info(f"[KC-ADMIN] Replacing post-logout redirect URIs with {len(final_uris)} URIs") + + # Update client configuration + token = await self._get_admin_token() + url = f"{self.keycloak_url}/admin/realms/{self.realm}/clients/{client_uuid}" + + async with httpx.AsyncClient() as client_http: + try: + # Prepare update payload + # Post-logout redirect URIs are stored as a ## delimited string in attributes + attributes = client.get("attributes", {}) + attributes["post.logout.redirect.uris"] = "##".join(final_uris) + + update_payload = { + "id": client_uuid, + "clientId": client_id, + "attributes": attributes, + } + + response = await client_http.put( + url, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + json=update_payload, + timeout=10.0, + ) + + if response.status_code != 204: # Keycloak returns 204 No Content on success + logger.error(f"[KC-ADMIN] Failed to update post-logout redirect URIs: {response.status_code} - {response.text}") + return False + + logger.info(f"[KC-ADMIN] ✓ Updated post-logout redirect URIs for client '{client_id}'") + for uri in final_uris: + logger.info(f"[KC-ADMIN] - {uri}") + return True + + except httpx.RequestError as e: + logger.error(f"[KC-ADMIN] Failed to update post-logout redirect URIs: {e}") + return False + + +async def register_current_environment_redirect_uri() -> bool: + """ + Register this environment's redirect URIs with Keycloak. + + Registers both local (localhost/127.0.0.1) and Tailscale URIs if available. + Uses PORT_OFFSET to determine the correct frontend port. + Called during backend startup to ensure Keycloak accepts redirects from this environment. + + Example: + - ushadow (PORT_OFFSET=10): Registers http://localhost:3010/auth/callback + - ushadow-orange (PORT_OFFSET=20): Registers http://localhost:3020/auth/callback + - With Tailscale: Also registers https://ushadow.spangled-kettle.ts.net/auth/callback + """ + # Get configuration from environment + keycloak_url = os.getenv("KEYCLOAK_URL", "http://keycloak:8080") + keycloak_realm = os.getenv("KEYCLOAK_REALM", "ushadow") + keycloak_client_id = os.getenv("KEYCLOAK_FRONTEND_CLIENT_ID", "ushadow-frontend") + + # Admin credentials + admin_user = os.getenv("KEYCLOAK_ADMIN_USER", "admin") + admin_password = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin") + + # Calculate frontend port from PORT_OFFSET + port_offset = int(os.getenv("PORT_OFFSET", "0")) + frontend_port = 3000 + port_offset + + # Build redirect URIs - start with local URIs + redirect_uris = [ + f"http://localhost:{frontend_port}/oauth/callback", + f"http://127.0.0.1:{frontend_port}/oauth/callback", + ] + + post_logout_redirect_uris = [ + f"http://localhost:{frontend_port}/", + f"http://127.0.0.1:{frontend_port}/", + ] + + # Check if Tailscale is configured and add Tailscale URIs + try: + from src.utils.tailscale_serve import get_tailscale_status + ts_status = get_tailscale_status() + if ts_status.hostname and ts_status.authenticated: + # Add Tailscale URIs (HTTPS through Tailscale serve) + tailscale_redirect_uri = f"https://{ts_status.hostname}/oauth/callback" + tailscale_logout_uri = f"https://{ts_status.hostname}/" + + redirect_uris.append(tailscale_redirect_uri) + post_logout_redirect_uris.append(tailscale_logout_uri) + + logger.info(f"[KC-ADMIN] Detected Tailscale hostname: {ts_status.hostname}") + except Exception as e: + logger.debug(f"[KC-ADMIN] Could not detect Tailscale hostname: {e}") + + logger.info(f"[KC-ADMIN] Registering redirect URIs for environment:") + for uri in redirect_uris: + logger.info(f"[KC-ADMIN] - {uri}") + logger.info(f"[KC-ADMIN] Registering post-logout redirect URIs:") + for uri in post_logout_redirect_uris: + logger.info(f"[KC-ADMIN] - {uri}") + + # Create admin client and register URIs + admin_client = KeycloakAdminClient( + keycloak_url=keycloak_url, + realm=keycloak_realm, + admin_user=admin_user, + admin_password=admin_password, + ) + + # Register login redirect URIs + success = await admin_client.update_client_redirect_uris( + client_id=keycloak_client_id, + redirect_uris=redirect_uris, + merge=True # Merge with existing URIs (don't break other environments) + ) + + if not success: + logger.error(f"[KC-ADMIN] ❌ Failed to register redirect URIs for port {frontend_port}") + return False + + # Register post-logout redirect URIs + success = await admin_client.update_post_logout_redirect_uris( + client_id=keycloak_client_id, + post_logout_redirect_uris=post_logout_redirect_uris, + merge=True # Merge with existing URIs (don't break other environments) + ) + + if success: + logger.info(f"[KC-ADMIN] ✓ Successfully registered all redirect URIs for port {frontend_port}") + else: + logger.warning(f"[KC-ADMIN] ⚠️ Failed to register redirect URIs - Keycloak login may not work on port {frontend_port}") + + return success + + +# Singleton getter for dependency injection +_keycloak_admin_client: Optional[KeycloakAdminClient] = None + + +def get_keycloak_admin() -> KeycloakAdminClient: + """ + Get the Keycloak admin client singleton. + + Configuration is loaded from environment variables. + """ + global _keycloak_admin_client + + if _keycloak_admin_client is None: + keycloak_url = os.getenv("KEYCLOAK_URL", "http://keycloak:8080") + keycloak_realm = os.getenv("KEYCLOAK_REALM", "ushadow") + admin_user = os.getenv("KEYCLOAK_ADMIN_USER", "admin") + admin_password = os.getenv("KEYCLOAK_ADMIN_PASSWORD", "admin") + + _keycloak_admin_client = KeycloakAdminClient( + keycloak_url=keycloak_url, + realm=keycloak_realm, + admin_user=admin_user, + admin_password=admin_password, + ) + + return _keycloak_admin_client diff --git a/ushadow/backend/src/services/keycloak_auth.py b/ushadow/backend/src/services/keycloak_auth.py new file mode 100644 index 00000000..929f6bdd --- /dev/null +++ b/ushadow/backend/src/services/keycloak_auth.py @@ -0,0 +1,157 @@ +""" +Keycloak Token Validation + +Validates Keycloak JWT access tokens for API requests. +This allows federated users (authenticated via Keycloak) to access the API +without needing a local Ushadow account. +""" + +import os +import logging +from typing import Optional, Union +import jwt +from fastapi import HTTPException, status, Depends, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + +logger = logging.getLogger(__name__) + +# Security scheme for extracting Bearer tokens +security = HTTPBearer(auto_error=False) + + +def validate_keycloak_token(token: str) -> Optional[dict]: + """ + Validate a Keycloak access token. + + Args: + token: JWT access token from Keycloak + + Returns: + Decoded token payload if valid, None if invalid + + Note: + This is a simplified validation for development. + In production, you should: + 1. Fetch Keycloak's public keys from JWKS endpoint + 2. Verify signature using the public key + 3. Validate issuer, audience, and other claims + """ + try: + # For now, decode without verification (development only!) + # TODO: Add proper JWT signature verification using Keycloak's public keys + # Keycloak typically uses RS256 algorithm, so we need to allow it even when not verifying + payload = jwt.decode( + token, + algorithms=["RS256", "HS256"], # Allow common algorithms + options={ + "verify_signature": False, # FIXME: Enable in production! + "verify_exp": True, # Still check expiration + } + ) + + # Log the payload for debugging + logger.info(f"Decoded Keycloak token - issuer: {payload.get('iss')}, user: {payload.get('preferred_username')}") + + # Validate issuer (accept both internal and external URLs) + keycloak_external = os.getenv("KEYCLOAK_EXTERNAL_URL", "http://localhost:8081") + keycloak_internal = os.getenv("KEYCLOAK_URL", "http://keycloak:8080") + keycloak_realm = os.getenv("KEYCLOAK_REALM", "ushadow") + + expected_issuers = [ + f"{keycloak_external}/realms/{keycloak_realm}", + f"{keycloak_internal}/realms/{keycloak_realm}", + ] + + token_issuer = payload.get("iss") + if token_issuer not in expected_issuers: + logger.warning(f"Invalid issuer: {token_issuer} (expected one of {expected_issuers})") + # Don't reject - just log for now during development + # return None + + # Token is valid + logger.info(f"✓ Validated Keycloak token for user: {payload.get('preferred_username')}") + return payload + + except jwt.ExpiredSignatureError: + logger.warning("Keycloak token expired") + return None + except jwt.InvalidTokenError as e: + logger.warning(f"Invalid Keycloak token: {e}") + return None + except Exception as e: + logger.error(f"Error validating Keycloak token: {e}", exc_info=True) + return None + + +def get_keycloak_user_from_token(token: str) -> Optional[dict]: + """ + Extract user info from a Keycloak token. + + Args: + token: JWT access token from Keycloak + + Returns: + User info dict with keys: email, name, sub (user ID), etc. + """ + payload = validate_keycloak_token(token) + if not payload: + return None + + return { + "sub": payload.get("sub"), + "email": payload.get("email"), + "name": payload.get("name"), + "preferred_username": payload.get("preferred_username"), + "email_verified": payload.get("email_verified", False), + # Mark as Keycloak user for backend logic + "auth_type": "keycloak", + } + + +async def get_current_user_hybrid( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security) +) -> Union[dict, None]: + """ + Hybrid authentication dependency that accepts EITHER legacy OR Keycloak tokens. + + This is a FastAPI dependency that can be used in place of the legacy get_current_user. + It tries to validate the token as: + 1. Keycloak access token + 2. Legacy Ushadow JWT (via fastapi-users) + + Args: + credentials: HTTP Authorization credentials (Bearer token) + + Returns: + User info dict if authenticated, raises 401 if not + + Raises: + HTTPException: 401 if no valid authentication found + """ + if not credentials: + logger.warning("[AUTH] No credentials provided") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated" + ) + + token = credentials.credentials + token_preview = token[:20] + "..." if len(token) > 20 else token + logger.info(f"[AUTH] Validating token: {token_preview}") + + # Try Keycloak token validation first (simpler, no database lookup) + keycloak_user = get_keycloak_user_from_token(token) + if keycloak_user: + logger.info(f"[AUTH] ✅ Keycloak authentication successful: {keycloak_user.get('email')}") + return keycloak_user + + # Try legacy auth validation + # TODO: Add legacy token validation here if needed + # For now, we'll just check if it's a Keycloak token + # The existing fastapi-users middleware will handle legacy tokens + logger.warning(f"[AUTH] ❌ Token validation failed - neither Keycloak nor legacy token") + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token" + ) diff --git a/ushadow/backend/src/services/keycloak_user_sync.py b/ushadow/backend/src/services/keycloak_user_sync.py new file mode 100644 index 00000000..7a767473 --- /dev/null +++ b/ushadow/backend/src/services/keycloak_user_sync.py @@ -0,0 +1,120 @@ +""" +Keycloak User Synchronization + +Syncs Keycloak users to MongoDB User collection for Chronicle compatibility. +Chronicle requires MongoDB ObjectIds for user_id, but Keycloak uses UUIDs. + +This module creates/updates MongoDB User records for Keycloak-authenticated users. +""" + +import logging +from typing import Optional +from beanie import PydanticObjectId + +from src.models.user import User + +logger = logging.getLogger(__name__) + + +async def get_or_create_user_from_keycloak( + keycloak_sub: str, + email: str, + name: Optional[str] = None +) -> User: + """ + Get or create a MongoDB User record for a Keycloak user. + + This ensures Keycloak users have a corresponding MongoDB ObjectId that + Chronicle can use. The Keycloak subject ID is stored in keycloak_id field. + + Args: + keycloak_sub: Keycloak user ID (UUID format) + email: User's email address + name: User's full name (optional) + + Returns: + User: MongoDB User document with ObjectId + + Example: + >>> user = await get_or_create_user_from_keycloak( + ... keycloak_sub="f47ac10b-58cc-4372-a567-0e02b2c3d479", + ... email="alice@example.com", + ... name="Alice Smith" + ... ) + >>> str(user.id) # MongoDB ObjectId: "507f1f77bcf86cd799439011" + """ + # Try to find existing user by Keycloak ID + user = await User.find_one(User.keycloak_id == keycloak_sub) + + if user: + logger.info(f"[KC-USER-SYNC] Found existing user: {email} (MongoDB ID: {user.id})") + + # Update name if it changed + if name and user.name != name: + logger.info(f"[KC-USER-SYNC] Updating name: {user.name} → {name}") + user.name = name + await user.save() + + return user + + # Try to find by email (might be a legacy user who logged in via Keycloak) + user = await User.find_one(User.email == email) + + if user: + logger.info(f"[KC-USER-SYNC] Found legacy user by email: {email}") + logger.info(f"[KC-USER-SYNC] Linking to Keycloak ID: {keycloak_sub}") + + # Link to Keycloak + user.keycloak_id = keycloak_sub + if name and not user.name: + user.name = name + await user.save() + + return user + + # Create new user + logger.info(f"[KC-USER-SYNC] Creating new user for Keycloak account: {email}") + + user = User( + email=email, + name=name or email, # Fallback to email if no name provided + keycloak_id=keycloak_sub, + is_active=True, + is_verified=True, # Keycloak users are pre-verified + is_superuser=False, # Keycloak users are not admins by default + hashed_password="", # No password - auth is via Keycloak + ) + + await user.create() + + logger.info(f"[KC-USER-SYNC] ✓ Created user: {email} (MongoDB ID: {user.id})") + + return user + + +async def get_mongodb_user_id_for_keycloak_user( + keycloak_sub: str, + email: str, + name: Optional[str] = None +) -> str: + """ + Get MongoDB ObjectId string for a Keycloak user. + + This is a convenience wrapper around get_or_create_user_from_keycloak + that returns just the ObjectId as a string (for use in JWT tokens). + + Args: + keycloak_sub: Keycloak user ID (UUID) + email: User's email + name: User's full name (optional) + + Returns: + str: MongoDB ObjectId as string (24 hex chars) + """ + user = await get_or_create_user_from_keycloak( + keycloak_sub=keycloak_sub, + email=email, + name=name + ) + + return str(user.id) diff --git a/ushadow/backend/src/services/token_bridge.py b/ushadow/backend/src/services/token_bridge.py new file mode 100644 index 00000000..5fb6509d --- /dev/null +++ b/ushadow/backend/src/services/token_bridge.py @@ -0,0 +1,126 @@ +""" +Token Bridge Utility + +Automatically converts Keycloak OIDC tokens to service-compatible JWT tokens. +This allows proxy and audio relay to transparently bridge authentication. + +Usage: + token = extract_token_from_request(request) + service_token = await bridge_to_service_token(token, audiences=["chronicle"]) +""" + +import logging +from typing import Optional +from fastapi import Request +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer + +from .keycloak_auth import get_keycloak_user_from_token +from .keycloak_user_sync import get_mongodb_user_id_for_keycloak_user +from .auth import generate_jwt_for_service + +logger = logging.getLogger(__name__) +security = HTTPBearer(auto_error=False) + + +def extract_token_from_request(request: Request) -> Optional[str]: + """ + Extract Bearer token from Authorization header or query parameter. + + Args: + request: FastAPI request object + + Returns: + Token string if found, None otherwise + """ + # Try Authorization header first + auth_header = request.headers.get("authorization", "") + if auth_header.startswith("Bearer "): + return auth_header[7:] # Remove "Bearer " prefix + + # Try query parameter (for WebSocket connections) + token = request.query_params.get("token") + if token: + return token + + return None + + +async def bridge_to_service_token( + token: str, + audiences: Optional[list[str]] = None +) -> Optional[str]: + """ + Convert a Keycloak token to a service-compatible JWT token. + + If the token is already a service token (not a Keycloak token), + returns it unchanged. Otherwise, validates the Keycloak token + and generates a new service token. + + Args: + token: Token to bridge (Keycloak or service token) + audiences: Audiences for the service token (defaults to ["ushadow", "chronicle"]) + + Returns: + Service token if bridging succeeded, None if token is invalid + """ + if not token: + return None + + # Try to validate as Keycloak token + keycloak_user = get_keycloak_user_from_token(token) + + if not keycloak_user: + # Not a valid Keycloak token + # Could be a service token already, or invalid + # Let it through and let the downstream service validate + logger.debug("[TOKEN-BRIDGE] Token is not a Keycloak token, passing through") + return token + + # It's a Keycloak token - bridge it + user_email = keycloak_user.get("email") + keycloak_sub = keycloak_user.get("sub") + user_name = keycloak_user.get("name") + + if not user_email or not keycloak_sub: + logger.error(f"[TOKEN-BRIDGE] Missing user info: email={user_email}, keycloak_sub={keycloak_sub}") + return None + + # Sync Keycloak user to MongoDB (creates User record if needed) + # This gives us a MongoDB ObjectId that Chronicle can use + try: + mongodb_user_id = await get_mongodb_user_id_for_keycloak_user( + keycloak_sub=keycloak_sub, + email=user_email, + name=user_name + ) + logger.debug(f"[TOKEN-BRIDGE] Keycloak {keycloak_sub} → MongoDB {mongodb_user_id}") + except Exception as e: + logger.error(f"[TOKEN-BRIDGE] Failed to sync Keycloak user to MongoDB: {e}", exc_info=True) + return None + + # Generate service token with MongoDB ObjectId + audiences = audiences or ["ushadow", "chronicle"] + service_token = generate_jwt_for_service( + user_id=mongodb_user_id, # Use MongoDB ObjectId, not Keycloak UUID + user_email=user_email, + audiences=audiences + ) + + logger.info(f"[TOKEN-BRIDGE] ✓ Bridged Keycloak token for {user_email} → service token (MongoDB ID: {mongodb_user_id})") + logger.debug(f"[TOKEN-BRIDGE] Audiences: {audiences}, token: {service_token[:30]}...") + + return service_token + + +def is_keycloak_token(token: str) -> bool: + """ + Check if a token is a Keycloak token (vs service token). + + Args: + token: JWT token to check + + Returns: + True if token is from Keycloak, False otherwise + """ + keycloak_user = get_keycloak_user_from_token(token) + return keycloak_user is not None diff --git a/ushadow/frontend/src/auth/OAuthCallback.tsx b/ushadow/frontend/src/auth/OAuthCallback.tsx new file mode 100644 index 00000000..f59e3cda --- /dev/null +++ b/ushadow/frontend/src/auth/OAuthCallback.tsx @@ -0,0 +1,100 @@ +/** + * OAuth Callback Handler + * + * Handles the redirect from Keycloak after login. + * Exchanges authorization code for tokens and redirects to original page. + */ + +import { useEffect, useState, useRef } from 'react' +import { useNavigate } from 'react-router-dom' +import { useKeycloakAuth } from '../contexts/KeycloakAuthContext' +import { TokenManager } from './TokenManager' + +export default function OAuthCallback() { + const [error, setError] = useState(null) + const [processing, setProcessing] = useState(true) + const navigate = useNavigate() + const { handleCallback } = useKeycloakAuth() + const hasProcessed = useRef(false) + + useEffect(() => { + // Prevent duplicate processing (React StrictMode runs effects twice in dev) + if (hasProcessed.current) { + return + } + hasProcessed.current = true + + async function processCallback() { + try { + // Extract code and state from URL + const { code, error: oauthError, error_description, state } = + TokenManager.extractTokensFromCallback(window.location.href) + + // Check for OAuth errors + if (oauthError) { + throw new Error(error_description || oauthError) + } + + // Ensure we have a code + if (!code) { + throw new Error('Missing authorization code') + } + + // Ensure we have state (required for CSRF protection) + if (!state) { + throw new Error('Missing state parameter') + } + + // Exchange code for tokens (includes state verification) + await handleCallback(code, state) + + // Get return URL or default to test page (to avoid login loop) + const returnUrl = sessionStorage.getItem('login_return_url') || '/auth/test' + sessionStorage.removeItem('login_return_url') + + console.log('OAuth callback success, redirecting to:', returnUrl) + + // Redirect to original page + navigate(returnUrl, { replace: true }) + } catch (err) { + console.error('OAuth callback error:', err) + setError(err instanceof Error ? err.message : 'Authentication failed') + setProcessing(false) + } + } + + processCallback() + }, [handleCallback, navigate]) + + if (error) { + return ( +
+
+

+ Authentication Error +

+

{error}

+ +
+
+ ) + } + + if (processing) { + return ( +
+
+
+

Completing sign-in...

+
+
+ ) + } + + return null +} diff --git a/ushadow/frontend/src/auth/ServiceTokenManager.ts b/ushadow/frontend/src/auth/ServiceTokenManager.ts new file mode 100644 index 00000000..11bc00c8 --- /dev/null +++ b/ushadow/frontend/src/auth/ServiceTokenManager.ts @@ -0,0 +1,59 @@ +/** + * Service Token Manager + * + * Manages Chronicle-compatible JWT tokens generated from Keycloak tokens. + * This bridges Keycloak OIDC authentication with legacy JWT-based services. + */ + +const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000' + +export interface ServiceTokenResponse { + service_token: string + token_type: string + expires_in: number +} + +/** + * Exchange a Keycloak token for a Chronicle-compatible service token. + * + * @param keycloakToken - The Keycloak access token from sessionStorage + * @param audiences - Services this token should be valid for (default: ["ushadow", "chronicle"]) + * @returns Service token that Chronicle and other services can validate + */ +export async function getServiceToken( + keycloakToken: string, + audiences?: string[] +): Promise { + const response = await fetch(`${BACKEND_URL}/api/auth/token/service-token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${keycloakToken}` + }, + body: JSON.stringify({ audiences }) + }) + + if (!response.ok) { + const error = await response.json().catch(() => ({ detail: 'Unknown error' })) + throw new Error(`Failed to get service token: ${error.detail}`) + } + + const data: ServiceTokenResponse = await response.json() + return data.service_token +} + +/** + * Get a Chronicle-compatible token for the current user. + * Automatically retrieves the Keycloak token from session storage. + * + * @returns Service token ready to use with Chronicle WebSocket + */ +export async function getChronicleToken(): Promise { + const keycloakToken = sessionStorage.getItem('kc_access_token') + + if (!keycloakToken) { + throw new Error('No Keycloak token found. Please log in first.') + } + + return getServiceToken(keycloakToken, ['ushadow', 'chronicle']) +} diff --git a/ushadow/frontend/src/auth/TokenManager.ts b/ushadow/frontend/src/auth/TokenManager.ts new file mode 100644 index 00000000..dbcb2aa6 --- /dev/null +++ b/ushadow/frontend/src/auth/TokenManager.ts @@ -0,0 +1,325 @@ +/** + * Token Manager + * + * Handles OIDC token storage, retrieval, and validation. + * Uses sessionStorage for security (tokens cleared when tab closes). + */ + +import { jwtDecode } from 'jwt-decode' + +const TOKEN_KEY = 'kc_access_token' +const REFRESH_TOKEN_KEY = 'kc_refresh_token' +const ID_TOKEN_KEY = 'kc_id_token' + +interface TokenResponse { + access_token: string + refresh_token?: string + id_token?: string + expires_in?: number + token_type?: string +} + +interface LoginUrlParams { + keycloakUrl: string + realm: string + clientId: string + redirectUri: string + state: string +} + +interface LogoutUrlParams { + keycloakUrl: string + realm: string + redirectUri: string +} + +interface DecodedToken { + exp: number + iat: number + sub: string + preferred_username?: string + email?: string + name?: string + given_name?: string + family_name?: string + [key: string]: any +} + +export class TokenManager { + /** + * Store tokens in sessionStorage + */ + static storeTokens(tokens: TokenResponse): void { + if (tokens.access_token) { + sessionStorage.setItem(TOKEN_KEY, tokens.access_token) + } + if (tokens.refresh_token) { + sessionStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token) + } + if (tokens.id_token) { + sessionStorage.setItem(ID_TOKEN_KEY, tokens.id_token) + } + } + + /** + * Get access token from storage + */ + static getAccessToken(): string | null { + return sessionStorage.getItem(TOKEN_KEY) + } + + /** + * Get refresh token from storage + */ + static getRefreshToken(): string | null { + return sessionStorage.getItem(REFRESH_TOKEN_KEY) + } + + /** + * Get ID token from storage + */ + static getIdToken(): string | null { + return sessionStorage.getItem(ID_TOKEN_KEY) + } + + /** + * Clear all tokens from storage + */ + static clearTokens(): void { + sessionStorage.removeItem(TOKEN_KEY) + sessionStorage.removeItem(REFRESH_TOKEN_KEY) + sessionStorage.removeItem(ID_TOKEN_KEY) + } + + /** + * Check if user is authenticated (has valid token) + */ + static isAuthenticated(): boolean { + const token = this.getAccessToken() + if (!token) { + console.log('[TokenManager] No access token found') + return false + } + + try { + const decoded = jwtDecode(token) + const now = Math.floor(Date.now() / 1000) + const isValid = decoded.exp > now + const expiresIn = decoded.exp - now + + console.log('[TokenManager] Token check:', { + isValid, + expiresIn: `${Math.floor(expiresIn / 60)}m ${expiresIn % 60}s`, + expiresAt: new Date(decoded.exp * 1000).toISOString(), + now: new Date(now * 1000).toISOString() + }) + + if (!isValid) { + console.warn('[TokenManager] ⚠️ Token EXPIRED!', { + expiredAgo: `${Math.floor(Math.abs(expiresIn) / 60)}m ${Math.abs(expiresIn) % 60}s ago` + }) + } + + return isValid + } catch (error) { + console.error('[TokenManager] Invalid token:', error) + return false + } + } + + /** + * Get user info from decoded token + */ + static getUserInfo(): any | null { + const token = this.getAccessToken() + if (!token) return null + + try { + const decoded = jwtDecode(token) + return { + sub: decoded.sub, + username: decoded.preferred_username, + email: decoded.email, + name: decoded.name, + given_name: decoded.given_name, + family_name: decoded.family_name, + // Include all other claims + ...decoded, + } + } catch (error) { + console.error('Failed to decode token:', error) + return null + } + } + + /** + * Build Keycloak login URL with PKCE + */ + static async buildLoginUrl(params: LoginUrlParams): Promise { + const { keycloakUrl, realm, clientId, redirectUri, state } = params + + // Generate PKCE code verifier and challenge + const codeVerifier = this.generateCodeVerifier() + const codeChallenge = await this.generateCodeChallenge(codeVerifier) + + // Store code verifier for token exchange + sessionStorage.setItem('pkce_code_verifier', codeVerifier) + + const authUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth` + const queryParams = new URLSearchParams({ + client_id: clientId, + redirect_uri: redirectUri, + response_type: 'code', + scope: 'openid profile email', + state: state, + code_challenge: codeChallenge, + code_challenge_method: 'S256', + }) + + return `${authUrl}?${queryParams.toString()}` + } + + /** + * Build Keycloak logout URL + */ + static buildLogoutUrl(params: LogoutUrlParams): string { + const { keycloakUrl, realm, redirectUri } = params + const logoutUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/logout` + + // Get id_token from storage for proper logout + const idToken = this.getIdToken() + + const queryParams = new URLSearchParams({ + post_logout_redirect_uri: redirectUri, + }) + + // Add id_token_hint if available (recommended by OIDC spec) + if (idToken) { + queryParams.set('id_token_hint', idToken) + } + + return `${logoutUrl}?${queryParams.toString()}` + } + + /** + * Exchange authorization code for tokens via backend + */ + static async exchangeCodeForTokens( + code: string, + backendUrl: string + ): Promise { + const codeVerifier = sessionStorage.getItem('pkce_code_verifier') + if (!codeVerifier) { + throw new Error('Missing PKCE code verifier') + } + + const response = await fetch(`${backendUrl}/api/auth/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + code, + code_verifier: codeVerifier, + redirect_uri: `${window.location.origin}/oauth/callback`, + }), + }) + + if (!response.ok) { + const error = await response.text() + throw new Error(`Token exchange failed: ${error}`) + } + + const tokens = await response.json() + + // Clean up code verifier + sessionStorage.removeItem('pkce_code_verifier') + + return tokens + } + + /** + * Extract tokens from callback URL + */ + static extractTokensFromCallback(url: string): { + code?: string + state?: string + error?: string + error_description?: string + } { + const urlObj = new URL(url) + const params = new URLSearchParams(urlObj.search) + + return { + code: params.get('code') || undefined, + state: params.get('state') || undefined, + error: params.get('error') || undefined, + error_description: params.get('error_description') || undefined, + } + } + + /** + * Refresh access token using refresh token + */ + static async refreshAccessToken(backendUrl: string): Promise { + const refreshToken = this.getRefreshToken() + if (!refreshToken) { + throw new Error('No refresh token available') + } + + console.log('[TokenManager] Refreshing access token...') + + const response = await fetch(`${backendUrl}/api/auth/refresh`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + refresh_token: refreshToken, + }), + }) + + if (!response.ok) { + const error = await response.text() + console.error('[TokenManager] Token refresh failed:', error) + throw new Error(`Token refresh failed: ${error}`) + } + + const tokens = await response.json() + console.log('[TokenManager] ✅ Token refreshed successfully') + + return tokens + } + + // PKCE helpers + + /** + * Generate PKCE code verifier (random string) + */ + private static generateCodeVerifier(): string { + const array = new Uint8Array(32) + crypto.getRandomValues(array) + return this.base64UrlEncode(array) + } + + /** + * Generate PKCE code challenge (SHA-256 hash of verifier) + */ + private static async generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder() + const data = encoder.encode(verifier) + const hash = await crypto.subtle.digest('SHA-256', data) + return this.base64UrlEncode(new Uint8Array(hash)) + } + + /** + * Base64 URL encode (for PKCE) + */ + private static base64UrlEncode(array: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...Array.from(array))) + return base64 + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '') + } +} diff --git a/ushadow/frontend/src/auth/config.ts b/ushadow/frontend/src/auth/config.ts new file mode 100644 index 00000000..368a2e26 --- /dev/null +++ b/ushadow/frontend/src/auth/config.ts @@ -0,0 +1,35 @@ +/** + * Keycloak and Backend Configuration + * + * Loaded from environment variables (.env file) + */ + +/** + * Get backend URL based on current origin. + * + * When accessing via Tailscale (e.g., https://ushadow.spangled-kettle.ts.net), + * the backend is accessible at the same origin through /api routes. + * When accessing locally (localhost/127.0.0.1), use the configured backend port. + */ +function getBackendUrl(): string { + const origin = window.location.origin + + // If accessing via Tailscale (*.ts.net), use the same origin + // Tailscale serve routes /api to the backend + if (origin.includes('.ts.net')) { + return origin + } + + // Otherwise use the configured backend URL (local development) + return import.meta.env.VITE_BACKEND_URL || 'http://localhost:8000' +} + +export const keycloakConfig = { + url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8081', + realm: import.meta.env.VITE_KEYCLOAK_REALM || 'ushadow', + clientId: import.meta.env.VITE_KEYCLOAK_CLIENT_ID || 'ushadow-frontend', +} + +export const backendConfig = { + url: getBackendUrl(), +} diff --git a/ushadow/frontend/src/contexts/KeycloakAuthContext.tsx b/ushadow/frontend/src/contexts/KeycloakAuthContext.tsx new file mode 100644 index 00000000..b4828704 --- /dev/null +++ b/ushadow/frontend/src/contexts/KeycloakAuthContext.tsx @@ -0,0 +1,234 @@ +/** + * Keycloak Authentication Context + * + * Provides OIDC authentication using Keycloak for federated auth + * (voice message sharing, external user access) + * + * Works alongside the existing AuthContext (legacy email/password) + */ + +import { createContext, useContext, useState, useEffect, ReactNode } from 'react' +import { TokenManager } from '../auth/TokenManager' +import { keycloakConfig, backendConfig } from '../auth/config' + +interface KeycloakAuthContextType { + isAuthenticated: boolean + isLoading: boolean + userInfo: any | null + login: (redirectUri?: string) => void + logout: (redirectUri?: string) => void + getAccessToken: () => string | null + handleCallback: (code: string, state: string) => Promise +} + +const KeycloakAuthContext = createContext(undefined) + +export function KeycloakAuthProvider({ children }: { children: ReactNode }) { + // Initialize auth state synchronously to prevent flash of unauthenticated state + const initialAuthState = TokenManager.isAuthenticated() + const initialUserInfo = initialAuthState ? TokenManager.getUserInfo() : null + + const [isAuthenticated, setIsAuthenticated] = useState(initialAuthState) + const [isLoading, setIsLoading] = useState(false) // No loading needed - we check synchronously + const [userInfo, setUserInfo] = useState(initialUserInfo) + + useEffect(() => { + // Re-check auth state on mount (in case token expired between initial check and mount) + const authenticated = TokenManager.isAuthenticated() + if (authenticated !== isAuthenticated) { + setIsAuthenticated(authenticated) + if (authenticated) { + const info = TokenManager.getUserInfo() + setUserInfo(info) + } else { + setUserInfo(null) + } + } + + // Set up automatic token refresh + // Refresh token 60 seconds before it expires + const setupTokenRefresh = () => { + try { + const token = TokenManager.getAccessToken() + if (!token) { + console.log('[KC-AUTH] No token found, skipping refresh setup') + return undefined + } + + const decoded = TokenManager.getUserInfo() + if (!decoded?.exp) { + console.log('[KC-AUTH] No expiration in token, skipping refresh setup') + return undefined + } + + const now = Math.floor(Date.now() / 1000) + const expiresIn = decoded.exp - now + + // If token is already expired or expires in less than 0 seconds, don't set up refresh + if (expiresIn <= 0) { + console.warn('[KC-AUTH] Token already expired, skipping refresh setup') + setIsAuthenticated(false) + setUserInfo(null) + return undefined + } + + const refreshAt = Math.max(0, expiresIn - 60) // Refresh 60s before expiry + + console.log('[KC-AUTH] Setting up token refresh:', { + expiresIn: `${Math.floor(expiresIn / 60)}m ${expiresIn % 60}s`, + refreshIn: `${Math.floor(refreshAt / 60)}m ${refreshAt % 60}s` + }) + + const timeoutId = setTimeout(async () => { + try { + console.log('[KC-AUTH] Refreshing token...') + if (!backendConfig?.url) { + throw new Error('Backend URL not configured') + } + const newTokens = await TokenManager.refreshAccessToken(backendConfig.url) + TokenManager.storeTokens(newTokens) + console.log('[KC-AUTH] ✅ Token refreshed successfully') + + // Update context state + setIsAuthenticated(true) + const info = TokenManager.getUserInfo() + setUserInfo(info) + + // Schedule next refresh + setupTokenRefresh() + } catch (error) { + console.error('[KC-AUTH] ❌ Token refresh failed:', error) + // Token refresh failed - clear auth state (will trigger redirect to login) + setIsAuthenticated(false) + setUserInfo(null) + TokenManager.clearTokens() + } + }, refreshAt * 1000) + + return () => { + console.log('[KC-AUTH] Cleaning up token refresh timeout') + clearTimeout(timeoutId) + } + } catch (error) { + console.error('[KC-AUTH] Error setting up token refresh:', error) + return undefined + } + } + + const cleanup = setupTokenRefresh() + return () => { + if (cleanup) cleanup() + } + }, []) + + const login = async (redirectUri?: string) => { + // Save current location for return after login + const returnUrl = redirectUri || window.location.pathname + window.location.search + sessionStorage.setItem('login_return_url', returnUrl) + + // Generate CSRF state + const state = generateState() + sessionStorage.setItem('oauth_state', state) + + // Build Keycloak login URL (async because of PKCE SHA-256) + const loginUrl = await TokenManager.buildLoginUrl({ + keycloakUrl: keycloakConfig.url, + realm: keycloakConfig.realm, + clientId: keycloakConfig.clientId, + redirectUri: `${window.location.origin}/oauth/callback`, + state, + }) + + // Redirect to Keycloak + window.location.href = loginUrl + } + + const logout = (redirectUri?: string) => { + // Build logout URL FIRST (needs id_token from storage) + // Important: Keycloak requires exact match, so add trailing slash to origin + const defaultRedirectUri = `${window.location.origin}/` + const logoutUrl = TokenManager.buildLogoutUrl({ + keycloakUrl: keycloakConfig.url, + realm: keycloakConfig.realm, + redirectUri: redirectUri || defaultRedirectUri, + }) + + // THEN clear tokens (after we've read id_token for logout URL) + TokenManager.clearTokens() + setIsAuthenticated(false) + setUserInfo(null) + + // Redirect to Keycloak logout + window.location.href = logoutUrl + } + + const handleCallback = async (code: string, state: string) => { + // Verify state (CSRF protection) + const savedState = sessionStorage.getItem('oauth_state') + if (state !== savedState) { + throw new Error('Invalid state parameter - possible CSRF attack') + } + + // Exchange code for tokens via backend + const tokens = await TokenManager.exchangeCodeForTokens(code, backendConfig.url) + console.log('[KC-AUTH] Received tokens:', { + hasAccessToken: !!tokens.access_token, + hasRefreshToken: !!tokens.refresh_token, + hasIdToken: !!tokens.id_token, + tokenPreview: tokens.access_token?.substring(0, 30) + '...' + }) + + // Store tokens + TokenManager.storeTokens(tokens) + console.log('[KC-AUTH] Tokens stored in sessionStorage') + + // Verify storage worked + const storedToken = sessionStorage.getItem('kc_access_token') + console.log('[KC-AUTH] Verified storage:', { + hasStoredToken: !!storedToken, + storedTokenPreview: storedToken?.substring(0, 30) + '...' + }) + + // Update auth state + setIsAuthenticated(true) + const info = TokenManager.getUserInfo() + setUserInfo(info) + + // Clean up + sessionStorage.removeItem('oauth_state') + } + + const getAccessToken = () => { + return TokenManager.getAccessToken() + } + + return ( + + {children} + + ) +} + +export function useKeycloakAuth() { + const context = useContext(KeycloakAuthContext) + if (context === undefined) { + throw new Error('useKeycloakAuth must be used within a KeycloakAuthProvider') + } + return context +} + +// Helper function +function generateState(): string { + return Math.random().toString(36).substring(2, 15) + + Math.random().toString(36).substring(2, 15) +}