From e2ceeb989dac2ec2e3f44b31f953921dc54520c0 Mon Sep 17 00:00:00 2001 From: Stuart Alexander Date: Mon, 2 Feb 2026 22:53:36 +0000 Subject: [PATCH] Revert "F13f auth complete (#149)" This reverts commit dd21556ec58b739239e2646fe503837e2efb6427. --- .../backend/src/config/keycloak_settings.py | 52 --- ushadow/backend/src/routers/keycloak_admin.py | 145 ------- .../backend/src/services/keycloak_admin.py | 400 ------------------ ushadow/backend/src/services/keycloak_auth.py | 157 ------- .../src/services/keycloak_user_sync.py | 120 ------ ushadow/backend/src/services/token_bridge.py | 126 ------ ushadow/frontend/src/auth/OAuthCallback.tsx | 100 ----- .../frontend/src/auth/ServiceTokenManager.ts | 59 --- ushadow/frontend/src/auth/TokenManager.ts | 325 -------------- ushadow/frontend/src/auth/config.ts | 35 -- .../src/contexts/KeycloakAuthContext.tsx | 234 ---------- 11 files changed, 1753 deletions(-) delete mode 100644 ushadow/backend/src/config/keycloak_settings.py delete mode 100644 ushadow/backend/src/routers/keycloak_admin.py delete mode 100644 ushadow/backend/src/services/keycloak_admin.py delete mode 100644 ushadow/backend/src/services/keycloak_auth.py delete mode 100644 ushadow/backend/src/services/keycloak_user_sync.py delete mode 100644 ushadow/backend/src/services/token_bridge.py delete mode 100644 ushadow/frontend/src/auth/OAuthCallback.tsx delete mode 100644 ushadow/frontend/src/auth/ServiceTokenManager.ts delete mode 100644 ushadow/frontend/src/auth/TokenManager.ts delete mode 100644 ushadow/frontend/src/auth/config.ts delete mode 100644 ushadow/frontend/src/contexts/KeycloakAuthContext.tsx diff --git a/ushadow/backend/src/config/keycloak_settings.py b/ushadow/backend/src/config/keycloak_settings.py deleted file mode 100644 index 0633cd84..00000000 --- a/ushadow/backend/src/config/keycloak_settings.py +++ /dev/null @@ -1,52 +0,0 @@ -"""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 deleted file mode 100644 index 1190d456..00000000 --- a/ushadow/backend/src/routers/keycloak_admin.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -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 deleted file mode 100644 index e1cb80b7..00000000 --- a/ushadow/backend/src/services/keycloak_admin.py +++ /dev/null @@ -1,400 +0,0 @@ -""" -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 deleted file mode 100644 index 929f6bdd..00000000 --- a/ushadow/backend/src/services/keycloak_auth.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -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 deleted file mode 100644 index 7a767473..00000000 --- a/ushadow/backend/src/services/keycloak_user_sync.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -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 deleted file mode 100644 index 5fb6509d..00000000 --- a/ushadow/backend/src/services/token_bridge.py +++ /dev/null @@ -1,126 +0,0 @@ -""" -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 deleted file mode 100644 index f59e3cda..00000000 --- a/ushadow/frontend/src/auth/OAuthCallback.tsx +++ /dev/null @@ -1,100 +0,0 @@ -/** - * 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 deleted file mode 100644 index 11bc00c8..00000000 --- a/ushadow/frontend/src/auth/ServiceTokenManager.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * 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 deleted file mode 100644 index dbcb2aa6..00000000 --- a/ushadow/frontend/src/auth/TokenManager.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * 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 deleted file mode 100644 index 368a2e26..00000000 --- a/ushadow/frontend/src/auth/config.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * 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 deleted file mode 100644 index b4828704..00000000 --- a/ushadow/frontend/src/contexts/KeycloakAuthContext.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/** - * 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) -}