diff --git a/backend/app/auth.py b/backend/app/auth.py index 116227d..c35ca38 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -867,3 +867,342 @@ def check_project_access( ) return project + + +# --- OIDC Configuration Service --- + + +class OIDCConfig: + """OIDC configuration data class.""" + + def __init__( + self, + enabled: bool = False, + issuer_url: str = "", + client_id: str = "", + client_secret: str = "", + scopes: list[str] = None, + auto_create_users: bool = True, + admin_group: str = "", # Group/role that grants admin access + ): + self.enabled = enabled + self.issuer_url = issuer_url.rstrip("/") if issuer_url else "" + self.client_id = client_id + self.client_secret = client_secret + self.scopes = scopes or ["openid", "profile", "email"] + self.auto_create_users = auto_create_users + self.admin_group = admin_group + + @property + def discovery_url(self) -> str: + """Get the OIDC discovery URL.""" + if not self.issuer_url: + return "" + return f"{self.issuer_url}/.well-known/openid-configuration" + + def to_dict(self) -> dict: + """Convert to dictionary for storage.""" + return { + "enabled": self.enabled, + "issuer_url": self.issuer_url, + "client_id": self.client_id, + "client_secret": self.client_secret, + "scopes": self.scopes, + "auto_create_users": self.auto_create_users, + "admin_group": self.admin_group, + } + + @classmethod + def from_dict(cls, data: dict) -> "OIDCConfig": + """Create from dictionary.""" + return cls( + enabled=data.get("enabled", False), + issuer_url=data.get("issuer_url", ""), + client_id=data.get("client_id", ""), + client_secret=data.get("client_secret", ""), + scopes=data.get("scopes", ["openid", "profile", "email"]), + auto_create_users=data.get("auto_create_users", True), + admin_group=data.get("admin_group", ""), + ) + + +class OIDCConfigService: + """Service for managing OIDC configuration.""" + + OIDC_CONFIG_KEY = "oidc_config" + + def __init__(self, db: Session): + self.db = db + + def get_config(self) -> OIDCConfig: + """Get the current OIDC configuration.""" + from .models import AuthSettings + import json + + setting = ( + self.db.query(AuthSettings) + .filter(AuthSettings.key == self.OIDC_CONFIG_KEY) + .first() + ) + + if not setting: + return OIDCConfig() + + try: + data = json.loads(setting.value) + return OIDCConfig.from_dict(data) + except (json.JSONDecodeError, KeyError): + return OIDCConfig() + + def save_config(self, config: OIDCConfig) -> None: + """Save OIDC configuration.""" + from .models import AuthSettings + import json + + setting = ( + self.db.query(AuthSettings) + .filter(AuthSettings.key == self.OIDC_CONFIG_KEY) + .first() + ) + + if setting: + setting.value = json.dumps(config.to_dict()) + setting.updated_at = datetime.now(timezone.utc) + else: + setting = AuthSettings( + key=self.OIDC_CONFIG_KEY, + value=json.dumps(config.to_dict()), + ) + self.db.add(setting) + + self.db.commit() + + def is_enabled(self) -> bool: + """Check if OIDC is enabled.""" + config = self.get_config() + return config.enabled and bool(config.issuer_url) and bool(config.client_id) + + +def get_oidc_config_service(db: Session = Depends(get_db)) -> OIDCConfigService: + """Get an OIDCConfigService instance.""" + return OIDCConfigService(db) + + +# --- OIDC Authentication Flow --- + + +class OIDCService: + """Service for OIDC authentication flow.""" + + def __init__(self, db: Session, config: OIDCConfig): + self.db = db + self.config = config + self._discovery_doc: Optional[dict] = None + + def get_discovery_document(self) -> Optional[dict]: + """Fetch and cache the OIDC discovery document.""" + if self._discovery_doc: + return self._discovery_doc + + if not self.config.discovery_url: + return None + + try: + import httpx + + response = httpx.get(self.config.discovery_url, timeout=10.0) + response.raise_for_status() + self._discovery_doc = response.json() + return self._discovery_doc + except Exception as e: + logger.error(f"Failed to fetch OIDC discovery document: {e}") + return None + + def get_authorization_url(self, redirect_uri: str, state: str) -> Optional[str]: + """Generate the OIDC authorization URL.""" + discovery = self.get_discovery_document() + if not discovery: + return None + + auth_endpoint = discovery.get("authorization_endpoint") + if not auth_endpoint: + logger.error("No authorization_endpoint in discovery document") + return None + + import urllib.parse + + params = { + "client_id": self.config.client_id, + "response_type": "code", + "scope": " ".join(self.config.scopes), + "redirect_uri": redirect_uri, + "state": state, + } + + return f"{auth_endpoint}?{urllib.parse.urlencode(params)}" + + def exchange_code_for_tokens( + self, code: str, redirect_uri: str + ) -> Optional[dict]: + """Exchange authorization code for tokens.""" + discovery = self.get_discovery_document() + if not discovery: + return None + + token_endpoint = discovery.get("token_endpoint") + if not token_endpoint: + logger.error("No token_endpoint in discovery document") + return None + + try: + import httpx + + response = httpx.post( + token_endpoint, + data={ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": redirect_uri, + "client_id": self.config.client_id, + "client_secret": self.config.client_secret, + }, + timeout=10.0, + ) + response.raise_for_status() + return response.json() + except Exception as e: + logger.error(f"Failed to exchange code for tokens: {e}") + return None + + def validate_id_token(self, id_token: str) -> Optional[dict]: + """Validate and decode the ID token.""" + discovery = self.get_discovery_document() + if not discovery: + return None + + try: + from jose import jwt, JWTError + import httpx + + # Get JWKS + jwks_uri = discovery.get("jwks_uri") + if not jwks_uri: + logger.error("No jwks_uri in discovery document") + return None + + response = httpx.get(jwks_uri, timeout=10.0) + response.raise_for_status() + jwks = response.json() + + # Get the key ID from the token header + unverified_header = jwt.get_unverified_header(id_token) + kid = unverified_header.get("kid") + + # Find the matching key + rsa_key = None + for key in jwks.get("keys", []): + if key.get("kid") == kid: + rsa_key = key + break + + if not rsa_key: + logger.error(f"No matching key found in JWKS for kid: {kid}") + return None + + # Decode and validate the token + payload = jwt.decode( + id_token, + rsa_key, + algorithms=["RS256"], + audience=self.config.client_id, + issuer=self.config.issuer_url, + ) + + return payload + + except JWTError as e: + logger.error(f"ID token validation failed: {e}") + return None + except Exception as e: + logger.error(f"Error validating ID token: {e}") + return None + + def get_or_create_user(self, id_token_claims: dict) -> Optional[User]: + """Get or create a user from ID token claims.""" + # Extract user info from claims + subject = id_token_claims.get("sub") + email = id_token_claims.get("email") + name = id_token_claims.get("name") or id_token_claims.get("preferred_username") + + if not subject: + logger.error("No 'sub' claim in ID token") + return None + + # Try to find existing user by OIDC subject + user = ( + self.db.query(User) + .filter( + User.oidc_subject == subject, + User.oidc_issuer == self.config.issuer_url, + ) + .first() + ) + + if user: + # Update last login + user.last_login = datetime.now(timezone.utc) + self.db.commit() + return user + + # Try to find by email and link accounts + if email: + user = self.db.query(User).filter(User.email == email).first() + if user: + # Link OIDC identity to existing user + user.oidc_subject = subject + user.oidc_issuer = self.config.issuer_url + user.last_login = datetime.now(timezone.utc) + self.db.commit() + logger.info(f"Linked OIDC identity to existing user: {user.username}") + return user + + # Create new user if auto-creation is enabled + if not self.config.auto_create_users: + logger.warning(f"Auto-creation disabled, rejecting new OIDC user: {subject}") + return None + + # Determine username (use email prefix or subject) + username = email.split("@")[0] if email else subject + + # Check for username collision + existing = self.db.query(User).filter(User.username == username).first() + if existing: + # Append part of subject to make unique + username = f"{username}_{subject[:8]}" + + # Check if user should be admin based on groups/roles + is_admin = False + if self.config.admin_group: + groups = id_token_claims.get("groups", []) + roles = id_token_claims.get("roles", []) + is_admin = ( + self.config.admin_group in groups + or self.config.admin_group in roles + ) + + # Create the user + user = User( + username=username, + email=email, + password_hash=None, # OIDC users don't have passwords + oidc_subject=subject, + oidc_issuer=self.config.issuer_url, + is_admin=is_admin, + must_change_password=False, + ) + self.db.add(user) + self.db.commit() + self.db.refresh(user) + + logger.info(f"Created new OIDC user: {username} (admin={is_admin})") + return user diff --git a/backend/app/routes.py b/backend/app/routes.py index ccb2cf2..5c9e821 100644 --- a/backend/app/routes.py +++ b/backend/app/routes.py @@ -112,6 +112,10 @@ from .schemas import ( AccessPermissionCreate, AccessPermissionUpdate, AccessPermissionResponse, + OIDCConfigResponse, + OIDCConfigUpdate, + OIDCStatusResponse, + OIDCLoginResponse, ) from .metadata import extract_metadata from .config import get_settings @@ -631,6 +635,276 @@ def delete_api_key( return {"message": "API key deleted successfully"} +# --- OIDC Configuration Routes --- + + +@router.get("/api/v1/auth/oidc/status") +def get_oidc_status( + db: Session = Depends(get_db), +): + """ + Get OIDC status (public endpoint). + Returns whether OIDC is enabled and the issuer URL if so. + """ + from .auth import OIDCConfigService + from .schemas import OIDCStatusResponse + + oidc_service = OIDCConfigService(db) + config = oidc_service.get_config() + + if config.enabled and config.issuer_url and config.client_id: + return OIDCStatusResponse(enabled=True, issuer_url=config.issuer_url) + return OIDCStatusResponse(enabled=False) + + +@router.get("/api/v1/auth/oidc/config") +def get_oidc_config( + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """ + Get OIDC configuration (admin only). + Client secret is not exposed. + """ + from .auth import OIDCConfigService + from .schemas import OIDCConfigResponse + + oidc_service = OIDCConfigService(db) + config = oidc_service.get_config() + + return OIDCConfigResponse( + enabled=config.enabled, + issuer_url=config.issuer_url, + client_id=config.client_id, + has_client_secret=bool(config.client_secret), + scopes=config.scopes, + auto_create_users=config.auto_create_users, + admin_group=config.admin_group, + ) + + +@router.put("/api/v1/auth/oidc/config") +def update_oidc_config( + config_update: "OIDCConfigUpdate", + request: Request, + current_user: User = Depends(require_admin), + db: Session = Depends(get_db), +): + """ + Update OIDC configuration (admin only). + """ + from .auth import OIDCConfigService, OIDCConfig + from .schemas import OIDCConfigUpdate, OIDCConfigResponse + + oidc_service = OIDCConfigService(db) + current_config = oidc_service.get_config() + + # Update only provided fields + new_config = OIDCConfig( + enabled=config_update.enabled if config_update.enabled is not None else current_config.enabled, + issuer_url=config_update.issuer_url if config_update.issuer_url is not None else current_config.issuer_url, + client_id=config_update.client_id if config_update.client_id is not None else current_config.client_id, + client_secret=config_update.client_secret if config_update.client_secret is not None else current_config.client_secret, + scopes=config_update.scopes if config_update.scopes is not None else current_config.scopes, + auto_create_users=config_update.auto_create_users if config_update.auto_create_users is not None else current_config.auto_create_users, + admin_group=config_update.admin_group if config_update.admin_group is not None else current_config.admin_group, + ) + + oidc_service.save_config(new_config) + + # Log audit + _log_audit( + db, + "auth.oidc_config_update", + "oidc_config", + current_user.username, + request, + {"enabled": new_config.enabled, "issuer_url": new_config.issuer_url}, + ) + + return OIDCConfigResponse( + enabled=new_config.enabled, + issuer_url=new_config.issuer_url, + client_id=new_config.client_id, + has_client_secret=bool(new_config.client_secret), + scopes=new_config.scopes, + auto_create_users=new_config.auto_create_users, + admin_group=new_config.admin_group, + ) + + +@router.get("/api/v1/auth/oidc/login") +def oidc_login( + request: Request, + redirect_uri: Optional[str] = Query(None, description="Override redirect URI"), + db: Session = Depends(get_db), +): + """ + Initiate OIDC login flow. + Redirects to the OIDC provider's authorization endpoint. + """ + from .auth import OIDCConfigService, OIDCService + + oidc_config_service = OIDCConfigService(db) + config = oidc_config_service.get_config() + + if not config.enabled or not config.issuer_url or not config.client_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="OIDC is not configured", + ) + + # Generate state for CSRF protection + state = secrets.token_urlsafe(32) + + # Determine redirect URI + if not redirect_uri: + # Use the request's base URL + base_url = str(request.base_url).rstrip("/") + redirect_uri = f"{base_url}/api/v1/auth/oidc/callback" + + # Store state in session (using a simple cookie for now) + oidc_service = OIDCService(db, config) + auth_url = oidc_service.get_authorization_url(redirect_uri, state) + + if not auth_url: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Failed to generate authorization URL", + ) + + # Return redirect response with state cookie + response = RedirectResponse(url=auth_url, status_code=status.HTTP_302_FOUND) + response.set_cookie( + key="oidc_state", + value=state, + httponly=True, + secure=request.url.scheme == "https", + samesite="lax", + max_age=600, # 10 minutes + ) + response.set_cookie( + key="oidc_redirect_uri", + value=redirect_uri, + httponly=True, + secure=request.url.scheme == "https", + samesite="lax", + max_age=600, + ) + + return response + + +@router.get("/api/v1/auth/oidc/callback") +def oidc_callback( + request: Request, + code: str = Query(..., description="Authorization code"), + state: str = Query(..., description="State parameter"), + oidc_state: Optional[str] = Cookie(None), + oidc_redirect_uri: Optional[str] = Cookie(None), + db: Session = Depends(get_db), +): + """ + Handle OIDC callback after user authenticates. + Exchanges the authorization code for tokens and creates a session. + """ + from .auth import OIDCConfigService, OIDCService, AuthService + + # Verify state + if not oidc_state or state != oidc_state: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid state parameter", + ) + + oidc_config_service = OIDCConfigService(db) + config = oidc_config_service.get_config() + + if not config.enabled: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="OIDC is not configured", + ) + + # Determine redirect URI (must match what was used in login) + if not oidc_redirect_uri: + base_url = str(request.base_url).rstrip("/") + oidc_redirect_uri = f"{base_url}/api/v1/auth/oidc/callback" + + oidc_service = OIDCService(db, config) + + # Exchange code for tokens + tokens = oidc_service.exchange_code_for_tokens(code, oidc_redirect_uri) + if not tokens: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Failed to exchange authorization code", + ) + + id_token = tokens.get("id_token") + if not id_token: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No ID token in response", + ) + + # Validate ID token + claims = oidc_service.validate_id_token(id_token) + if not claims: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid ID token", + ) + + # Get or create user + user = oidc_service.get_or_create_user(claims) + if not user: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User creation not allowed", + ) + + if not user.is_active: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="User account is disabled", + ) + + # Create session + auth_service = AuthService(db) + session, token = auth_service.create_session( + user, + user_agent=request.headers.get("User-Agent"), + ip_address=request.client.host if request.client else None, + ) + + # Log audit + _log_audit( + db, + "auth.oidc_login", + f"user:{user.id}", + user.username, + request, + {"oidc_subject": claims.get("sub")}, + ) + + # Redirect to frontend with session cookie + response = RedirectResponse(url="/", status_code=status.HTTP_302_FOUND) + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + httponly=True, + secure=request.url.scheme == "https", + samesite="lax", + max_age=SESSION_DURATION_HOURS * 3600, + ) + # Clear OIDC state cookies + response.delete_cookie("oidc_state") + response.delete_cookie("oidc_redirect_uri") + + return response + + # --- Admin User Management Routes --- diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b81b027..1b53d7d 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -784,6 +784,40 @@ class APIKeyCreateResponse(BaseModel): expires_at: Optional[datetime] +# OIDC Configuration schemas +class OIDCConfigResponse(BaseModel): + """OIDC configuration response (hides client secret)""" + enabled: bool + issuer_url: str + client_id: str + has_client_secret: bool # True if secret is configured, but don't expose it + scopes: List[str] + auto_create_users: bool + admin_group: str + + +class OIDCConfigUpdate(BaseModel): + """Update OIDC configuration""" + enabled: Optional[bool] = None + issuer_url: Optional[str] = None + client_id: Optional[str] = None + client_secret: Optional[str] = None # Only set if changing + scopes: Optional[List[str]] = None + auto_create_users: Optional[bool] = None + admin_group: Optional[str] = None + + +class OIDCStatusResponse(BaseModel): + """Public OIDC status response""" + enabled: bool + issuer_url: Optional[str] = None # Only included if enabled + + +class OIDCLoginResponse(BaseModel): + """OIDC login initiation response""" + authorization_url: str + + # Access Permission schemas class AccessPermissionCreate(BaseModel): """Grant access to a user for a project""" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 995f167..49f820e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -9,6 +9,7 @@ import LoginPage from './pages/LoginPage'; import ChangePasswordPage from './pages/ChangePasswordPage'; import APIKeysPage from './pages/APIKeysPage'; import AdminUsersPage from './pages/AdminUsersPage'; +import AdminOIDCPage from './pages/AdminOIDCPage'; // Component that checks if user must change password function RequirePasswordChange({ children }: { children: React.ReactNode }) { @@ -42,6 +43,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0a417a2..a2b5b51 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -29,6 +29,9 @@ import { AccessPermissionCreate, AccessPermissionUpdate, AccessLevel, + OIDCConfig, + OIDCConfigUpdate, + OIDCStatus, } from './types'; const API_BASE = '/api/v1'; @@ -408,3 +411,35 @@ export async function revokeProjectAccess(projectName: string, username: string) throw new Error(error.detail || `HTTP ${response.status}`); } } + +// OIDC API +export async function getOIDCStatus(): Promise { + const response = await fetch(`${API_BASE}/auth/oidc/status`); + return handleResponse(response); +} + +export async function getOIDCConfig(): Promise { + const response = await fetch(`${API_BASE}/auth/oidc/config`, { + credentials: 'include', + }); + return handleResponse(response); +} + +export async function updateOIDCConfig(data: OIDCConfigUpdate): Promise { + const response = await fetch(`${API_BASE}/auth/oidc/config`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data), + credentials: 'include', + }); + return handleResponse(response); +} + +export function getOIDCLoginUrl(returnTo?: string): string { + const params = new URLSearchParams(); + if (returnTo) { + params.set('return_to', returnTo); + } + const query = params.toString(); + return `${API_BASE}/auth/oidc/login${query ? `?${query}` : ''}`; +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index 8b75049..73b5f7b 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -129,19 +129,31 @@ function Layout({ children }: LayoutProps) { API Keys {user.is_admin && ( - setShowUserMenu(false)} - > - - - - - - - User Management - + <> + setShowUserMenu(false)} + > + + + + + + + User Management + + setShowUserMenu(false)} + > + + + + SSO Configuration + + )}
+ + )} + + {loading ? ( +
+
+
+ Loading configuration... +
+
+ ) : ( +
+
+

Status

+
+ +

+ When enabled, users can sign in using your organization's identity provider. +

+
+
+ +
+

Provider Configuration

+ +
+ + setIssuerUrl(e.target.value)} + placeholder="https://your-provider.com" + disabled={isSaving} + /> +

+ The base URL of your OIDC provider. Discovery document will be fetched from /.well-known/openid-configuration. +

+
+ +
+
+ + setClientId(e.target.value)} + placeholder="your-client-id" + disabled={isSaving} + /> +
+ +
+ + setClientSecret(e.target.value)} + placeholder={config?.has_client_secret ? 'Leave blank to keep current' : 'Enter client secret'} + disabled={isSaving} + /> +
+
+ +
+ + setScopes(e.target.value)} + placeholder="openid profile email" + disabled={isSaving} + /> +

+ Space-separated list of OIDC scopes to request. Common scopes: openid, profile, email, groups. +

+
+
+ +
+

User Provisioning

+ +
+ +

+ When enabled, new users will be created automatically when they sign in via OIDC for the first time. +

+
+ +
+ + setAdminGroup(e.target.value)} + placeholder="admin, orchard-admins" + disabled={isSaving} + /> +

+ Users in this group (from the groups claim) will be granted admin privileges. Leave blank to disable automatic admin assignment. +

+
+
+ +
+ + +
+
+ )} + +
+

Callback URL

+

Configure your identity provider with the following callback URL:

+ + {window.location.origin}/api/v1/auth/oidc/callback + +
+ + ); +} + +export default AdminOIDCPage; diff --git a/frontend/src/pages/LoginPage.css b/frontend/src/pages/LoginPage.css index bb9902e..f59a055 100644 --- a/frontend/src/pages/LoginPage.css +++ b/frontend/src/pages/LoginPage.css @@ -214,6 +214,62 @@ font-size: 0.8125rem; } +/* SSO Divider */ +.login-divider { + display: flex; + align-items: center; + gap: 16px; + margin: 24px 0; +} + +.login-divider::before, +.login-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--border-primary); +} + +.login-divider span { + font-size: 0.8125rem; + color: var(--text-muted); + text-transform: lowercase; +} + +/* SSO Button */ +.login-sso-button { + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + width: 100%; + padding: 14px 20px; + background: var(--bg-tertiary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-md); + font-size: 0.9375rem; + font-weight: 500; + color: var(--text-primary); + text-decoration: none; + cursor: pointer; + transition: all var(--transition-fast); +} + +.login-sso-button:hover { + background: var(--bg-hover); + border-color: var(--border-secondary); + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.login-sso-button:active { + transform: translateY(0); +} + +.login-sso-button svg { + color: var(--accent-primary); +} + /* Responsive adjustments */ @media (max-width: 480px) { .login-card { diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 225a731..abe90de 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,6 +1,8 @@ import { useState, useEffect } from 'react'; -import { useNavigate, useLocation } from 'react-router-dom'; +import { useNavigate, useLocation, useSearchParams } from 'react-router-dom'; import { useAuth } from '../contexts/AuthContext'; +import { getOIDCStatus, getOIDCLoginUrl } from '../api'; +import { OIDCStatus } from '../types'; import './LoginPage.css'; function LoginPage() { @@ -8,14 +10,37 @@ function LoginPage() { const [password, setPassword] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const [oidcStatus, setOidcStatus] = useState(null); + const [searchParams] = useSearchParams(); - const { user, login, loading: authLoading } = useAuth(); + const { user, login, loading: authLoading, refreshUser } = useAuth(); const navigate = useNavigate(); const location = useLocation(); // Get the return URL from location state, default to home const from = (location.state as { from?: string })?.from || '/'; + // Load OIDC status on mount + useEffect(() => { + getOIDCStatus() + .then(setOidcStatus) + .catch(() => setOidcStatus({ enabled: false })); + }, []); + + // Handle SSO callback - check for oidc_success or oidc_error params + useEffect(() => { + const oidcSuccess = searchParams.get('oidc_success'); + const oidcError = searchParams.get('oidc_error'); + + if (oidcSuccess === 'true') { + refreshUser().then(() => { + navigate(from, { replace: true }); + }); + } else if (oidcError) { + setError(decodeURIComponent(oidcError)); + } + }, [searchParams, refreshUser, navigate, from]); + // Redirect if already logged in useEffect(() => { if (user && !authLoading) { @@ -129,6 +154,25 @@ function LoginPage() { )} + + {oidcStatus?.enabled && ( + <> +
+ or +
+ + + + + + + Sign in with SSO + + + )}
diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 389601c..f1c2bd3 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -329,3 +329,29 @@ export interface CurrentUser extends User { [projectId: string]: AccessLevel; }; } + +// OIDC types +export interface OIDCConfig { + enabled: boolean; + issuer_url: string; + client_id: string; + has_client_secret: boolean; + scopes: string[]; + auto_create_users: boolean; + admin_group: string; +} + +export interface OIDCConfigUpdate { + enabled?: boolean; + issuer_url?: string; + client_id?: string; + client_secret?: string; + scopes?: string[]; + auto_create_users?: boolean; + admin_group?: string; +} + +export interface OIDCStatus { + enabled: boolean; + issuer_url?: string; +}