| 1 | """SSO routes — settings management + Azure, Google & Cloudflare login flows.""" |
| 2 | |
| 3 | import os |
| 4 | from datetime import timedelta |
| 5 | from urllib.parse import quote |
| 6 | |
| 7 | from fastapi import APIRouter |
| 8 | from fastapi import HTTPException |
| 9 | from fastapi import Request |
| 10 | from fastapi import Security |
| 11 | from fastapi.responses import RedirectResponse |
| 12 | from loguru import logger |
| 13 | |
| 14 | from app.auth.models.sso import SSOAllowedEmailInput |
| 15 | from app.auth.models.sso import SSOAllowedEmailListResponse |
| 16 | from app.auth.models.sso import SSOAllowedEmailOut |
| 17 | from app.auth.models.sso import SSOConfigResponse |
| 18 | from app.auth.models.sso import SSOConfigUpdate |
| 19 | from app.auth.models.sso import SSOPublicStatusResponse |
| 20 | from app.auth.routes.totp import _create_temp_token |
| 21 | from app.auth.services.sso import add_allowed_email |
| 22 | from app.auth.services.sso import build_azure_auth_url |
| 23 | from app.auth.services.sso import build_google_auth_url |
| 24 | from app.auth.services.sso import delete_allowed_email |
| 25 | from app.auth.services.sso import exchange_azure_code |
| 26 | from app.auth.services.sso import exchange_google_code |
| 27 | from app.auth.services.sso import find_allowed_email |
| 28 | from app.auth.services.sso import find_user_by_email |
| 29 | from app.auth.services.sso import get_or_create_sso_user |
| 30 | from app.auth.services.sso import get_sso_config |
| 31 | from app.auth.services.sso import list_allowed_emails |
| 32 | from app.auth.services.sso import upsert_sso_config |
| 33 | from app.auth.services.sso import validate_cf_jwt |
| 34 | from app.auth.services.totp import is_2fa_enabled |
| 35 | from app.auth.utils import AuthHandler |
| 36 | |
| 37 | ACCESS_TOKEN_EXPIRE_MINUTES = int(os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES", "1440")) |
| 38 | |
| 39 | sso_router = APIRouter() |
| 40 | auth_handler = AuthHandler() |
| 41 | |
| 42 | |
| 43 | def _error_redirect(detail: str) -> RedirectResponse: |
| 44 | """Redirect to the login page with an error message in the query string.""" |
| 45 | return RedirectResponse(url=f"/login?error_message={quote(detail)}") |
| 46 | |
| 47 | |
| 48 | async def _resolve_sso_user(email: str): |
| 49 | """ |
| 50 | Resolve an SSO login to a user account. |
| 51 | - Existing users log in directly (no allowlist check). |
| 52 | - New users require an allowlist entry for auto-provisioning. |
| 53 | """ |
| 54 | user = await find_user_by_email(email) |
| 55 | if user: |
| 56 | return user |
| 57 | |
| 58 | # New user — require allowlist entry for provisioning |
| 59 | allowed = await find_allowed_email(email) |
| 60 | if allowed is None: |
| 61 | raise ValueError( |
| 62 | f"Email {email} is not authorized for SSO access. Contact your administrator.", |
| 63 | ) |
| 64 | |
| 65 | return await get_or_create_sso_user(email, role_id=allowed.role_id) |
| 66 | |
| 67 | |
| 68 | async def _issue_token_or_2fa(user, auth_handler) -> dict: |
| 69 | """ |
| 70 | Issue a full CoPilot JWT, or — if the user has 2FA enabled — a short-lived |
| 71 | temp token that the frontend must exchange via /auth/2fa/validate first. |
| 72 | Returns a dict with at minimum {token, is_2fa}. |
| 73 | """ |
| 74 | if await is_2fa_enabled(user.id): |
| 75 | temp_token = _create_temp_token(user.username) |
| 76 | return {"token": temp_token, "is_2fa": True} |
| 77 | |
| 78 | expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) |
| 79 | token = await auth_handler.encode_token(user.username, expires) |
| 80 | return {"token": token, "is_2fa": False} |
| 81 | |
| 82 | |
| 83 | # ── Public: SSO status (used by login page) ────────────────────────────────── |
| 84 | |
| 85 | |
| 86 | @sso_router.get("/sso/status", response_model=SSOPublicStatusResponse) |
| 87 | async def sso_public_status(): |
| 88 | """ |
| 89 | Public endpoint — returns which SSO providers are active. |
| 90 | The login page uses this to decide whether to show SSO buttons. |
| 91 | """ |
| 92 | cfg = await get_sso_config() |
| 93 | if cfg is None or not cfg.sso_enabled: |
| 94 | return SSOPublicStatusResponse( |
| 95 | sso_enabled=False, |
| 96 | azure_enabled=False, |
| 97 | cf_enabled=False, |
| 98 | ) |
| 99 | |
| 100 | return SSOPublicStatusResponse( |
| 101 | sso_enabled=True, |
| 102 | azure_enabled=cfg.azure_enabled, |
| 103 | google_enabled=cfg.google_enabled, |
| 104 | cf_enabled=cfg.cf_enabled, |
| 105 | ) |
| 106 | |
| 107 | |
| 108 | # ── Admin: SSO settings CRUD ───────────────────────────────────────────────── |
| 109 | |
| 110 | |
| 111 | @sso_router.get( |
| 112 | "/sso/settings", |
| 113 | response_model=SSOConfigResponse, |
| 114 | dependencies=[Security(AuthHandler().require_any_scope("admin"))], |
| 115 | ) |
| 116 | async def get_sso_settings(): |
| 117 | """Retrieve current SSO configuration. Admin only.""" |
| 118 | cfg = await get_sso_config() |
| 119 | if cfg is None: |
| 120 | return SSOConfigResponse( |
| 121 | sso_enabled=False, |
| 122 | azure_enabled=False, |
| 123 | google_enabled=False, |
| 124 | cf_enabled=False, |
| 125 | message="No SSO configuration found", |
| 126 | ) |
| 127 | return SSOConfigResponse( |
| 128 | sso_enabled=cfg.sso_enabled, |
| 129 | azure_enabled=cfg.azure_enabled, |
| 130 | azure_tenant_id=cfg.azure_tenant_id, |
| 131 | azure_client_id=cfg.azure_client_id, |
| 132 | azure_client_secret_set=bool(cfg.azure_client_secret), |
| 133 | azure_redirect_uri=cfg.azure_redirect_uri, |
| 134 | google_enabled=cfg.google_enabled, |
| 135 | google_client_id=cfg.google_client_id, |
| 136 | google_client_secret_set=bool(cfg.google_client_secret), |
| 137 | google_redirect_uri=cfg.google_redirect_uri, |
| 138 | cf_enabled=cfg.cf_enabled, |
| 139 | cf_team_domain=cfg.cf_team_domain, |
| 140 | cf_audience=cfg.cf_audience, |
| 141 | ) |
| 142 | |
| 143 | |
| 144 | @sso_router.put( |
| 145 | "/sso/settings", |
| 146 | response_model=SSOConfigResponse, |
| 147 | dependencies=[Security(AuthHandler().require_any_scope("admin"))], |
| 148 | ) |
| 149 | async def update_sso_settings(body: SSOConfigUpdate): |
| 150 | """Update SSO configuration. Admin only.""" |
| 151 | data = body.model_dump(exclude_none=False) |
| 152 | |
| 153 | # Don't overwrite secrets if empty/None |
| 154 | if not data.get("azure_client_secret"): |
| 155 | data.pop("azure_client_secret", None) |
| 156 | if not data.get("google_client_secret"): |
| 157 | data.pop("google_client_secret", None) |
| 158 | |
| 159 | cfg = await upsert_sso_config(data) |
| 160 | logger.info( |
| 161 | f"SSO config updated: sso_enabled={cfg.sso_enabled}, " |
| 162 | f"azure={cfg.azure_enabled}, google={cfg.google_enabled}, cf={cfg.cf_enabled}", |
| 163 | ) |
| 164 | |
| 165 | return SSOConfigResponse( |
| 166 | sso_enabled=cfg.sso_enabled, |
| 167 | azure_enabled=cfg.azure_enabled, |
| 168 | azure_tenant_id=cfg.azure_tenant_id, |
| 169 | azure_client_id=cfg.azure_client_id, |
| 170 | azure_client_secret_set=bool(cfg.azure_client_secret), |
| 171 | azure_redirect_uri=cfg.azure_redirect_uri, |
| 172 | google_enabled=cfg.google_enabled, |
| 173 | google_client_id=cfg.google_client_id, |
| 174 | google_client_secret_set=bool(cfg.google_client_secret), |
| 175 | google_redirect_uri=cfg.google_redirect_uri, |
| 176 | cf_enabled=cfg.cf_enabled, |
| 177 | cf_team_domain=cfg.cf_team_domain, |
| 178 | cf_audience=cfg.cf_audience, |
| 179 | message="SSO configuration updated successfully", |
| 180 | ) |
| 181 | |
| 182 | |
| 183 | # ── Admin: Allowed emails CRUD ─────────────────────────────────────────────── |
| 184 | |
| 185 | |
| 186 | @sso_router.get( |
| 187 | "/sso/allowed-emails", |
| 188 | response_model=SSOAllowedEmailListResponse, |
| 189 | dependencies=[Security(AuthHandler().require_any_scope("admin"))], |
| 190 | ) |
| 191 | async def get_allowed_emails(): |
| 192 | """List all SSO-allowed emails. Admin only.""" |
| 193 | emails = await list_allowed_emails() |
| 194 | return SSOAllowedEmailListResponse( |
| 195 | emails=[ |
| 196 | SSOAllowedEmailOut( |
| 197 | id=e.id, |
| 198 | email=e.email, |
| 199 | role_id=e.role_id, |
| 200 | created_at=e.created_at, |
| 201 | ) |
| 202 | for e in emails |
| 203 | ], |
| 204 | ) |
| 205 | |
| 206 | |
| 207 | @sso_router.post( |
| 208 | "/sso/allowed-emails", |
| 209 | status_code=201, |
| 210 | dependencies=[Security(AuthHandler().require_any_scope("admin"))], |
| 211 | ) |
| 212 | async def create_allowed_email(body: SSOAllowedEmailInput): |
| 213 | """Add an email to the SSO allowlist. Admin only.""" |
| 214 | try: |
| 215 | entry = await add_allowed_email(body.email, body.role_id) |
| 216 | except ValueError as e: |
| 217 | raise HTTPException(status_code=400, detail=str(e)) |
| 218 | return { |
| 219 | "message": f"Email {body.email} added to SSO allowlist", |
| 220 | "success": True, |
| 221 | "id": entry.id, |
| 222 | } |
| 223 | |
| 224 | |
| 225 | @sso_router.delete( |
| 226 | "/sso/allowed-emails/{email_id}", |
| 227 | dependencies=[Security(AuthHandler().require_any_scope("admin"))], |
| 228 | ) |
| 229 | async def remove_allowed_email(email_id: int): |
| 230 | """Remove an email from the SSO allowlist. Admin only.""" |
| 231 | ok = await delete_allowed_email(email_id) |
| 232 | if not ok: |
| 233 | raise HTTPException(status_code=404, detail="Email entry not found") |
| 234 | return {"message": "Email removed from SSO allowlist", "success": True} |
| 235 | |
| 236 | |
| 237 | # ── Azure Entra ID: OAuth2 flow ────────────────────────────────────────────── |
| 238 | |
| 239 | |
| 240 | @sso_router.get("/sso/azure/login") |
| 241 | async def azure_login(): |
| 242 | """Redirect user to Azure Entra ID authorization page.""" |
| 243 | cfg = await get_sso_config() |
| 244 | if cfg is None or not cfg.sso_enabled or not cfg.azure_enabled: |
| 245 | raise HTTPException(status_code=400, detail="Azure SSO is not enabled") |
| 246 | url = build_azure_auth_url(cfg) |
| 247 | return RedirectResponse(url=url) |
| 248 | |
| 249 | |
| 250 | @sso_router.get("/sso/azure/callback") |
| 251 | async def azure_callback(code: str = None, state: str = None, error: str = None): |
| 252 | """ |
| 253 | Azure Entra ID OAuth2 callback. |
| 254 | Exchanges the authorization code for tokens, validates the ID token, |
| 255 | checks the email allowlist, and returns a CoPilot JWT. |
| 256 | """ |
| 257 | if error: |
| 258 | return _error_redirect(f"Azure auth error: {error}") |
| 259 | if not code or not state: |
| 260 | return _error_redirect("Missing code or state parameter") |
| 261 | |
| 262 | cfg = await get_sso_config() |
| 263 | if cfg is None or not cfg.sso_enabled or not cfg.azure_enabled: |
| 264 | return _error_redirect("Azure SSO is not enabled") |
| 265 | |
| 266 | try: |
| 267 | claims = await exchange_azure_code(code, state, cfg) |
| 268 | except ValueError as e: |
| 269 | return _error_redirect(str(e)) |
| 270 | except Exception as e: |
| 271 | logger.error(f"Azure SSO error: {e}") |
| 272 | return _error_redirect("Azure authentication failed") |
| 273 | |
| 274 | email = claims.get("email") or claims.get("preferred_username") |
| 275 | if not email: |
| 276 | return _error_redirect("No email claim in Azure ID token") |
| 277 | |
| 278 | if not claims.get("email_verified", True): |
| 279 | return _error_redirect("Azure account email is not verified") |
| 280 | |
| 281 | # Existing users log in directly; new users require allowlist entry |
| 282 | try: |
| 283 | user = await _resolve_sso_user(email.lower()) |
| 284 | except ValueError as e: |
| 285 | return _error_redirect(str(e)) |
| 286 | |
| 287 | # Issue token (full or 2FA-pending) |
| 288 | result = await _issue_token_or_2fa(user, auth_handler) |
| 289 | logger.info(f"SSO Azure login: {user.username} ({email}), 2fa={result['is_2fa']}") |
| 290 | |
| 291 | redirect_url = f"/sso-callback#token={result['token']}" |
| 292 | if result["is_2fa"]: |
| 293 | redirect_url += "&requires_2fa=true" |
| 294 | return RedirectResponse(url=redirect_url) |
| 295 | |
| 296 | |
| 297 | # ── Google OAuth2: authorization code flow ─────────────────────────────────── |
| 298 | |
| 299 | |
| 300 | @sso_router.get("/sso/google/login") |
| 301 | async def google_login(): |
| 302 | """Redirect user to Google authorization page.""" |
| 303 | cfg = await get_sso_config() |
| 304 | if cfg is None or not cfg.sso_enabled or not cfg.google_enabled: |
| 305 | raise HTTPException(status_code=400, detail="Google SSO is not enabled") |
| 306 | url = build_google_auth_url(cfg) |
| 307 | return RedirectResponse(url=url) |
| 308 | |
| 309 | |
| 310 | @sso_router.get("/sso/google/callback") |
| 311 | async def google_callback(code: str = None, state: str = None, error: str = None): |
| 312 | """ |
| 313 | Google OAuth2 callback. |
| 314 | Exchanges the authorization code for tokens, validates the ID token, |
| 315 | checks the email allowlist, and returns a CoPilot JWT. |
| 316 | """ |
| 317 | if error: |
| 318 | return _error_redirect(f"Google auth error: {error}") |
| 319 | if not code or not state: |
| 320 | return _error_redirect("Missing code or state parameter") |
| 321 | |
| 322 | cfg = await get_sso_config() |
| 323 | if cfg is None or not cfg.sso_enabled or not cfg.google_enabled: |
| 324 | return _error_redirect("Google SSO is not enabled") |
| 325 | |
| 326 | try: |
| 327 | claims = await exchange_google_code(code, state, cfg) |
| 328 | except ValueError as e: |
| 329 | return _error_redirect(str(e)) |
| 330 | except Exception as e: |
| 331 | logger.error(f"Google SSO error: {e}") |
| 332 | return _error_redirect("Google authentication failed") |
| 333 | |
| 334 | email = claims.get("email") |
| 335 | if not email: |
| 336 | return _error_redirect("No email claim in Google ID token") |
| 337 | |
| 338 | if not claims.get("email_verified", False): |
| 339 | return _error_redirect("Google account email is not verified") |
| 340 | |
| 341 | # Existing users log in directly; new users require allowlist entry |
| 342 | try: |
| 343 | user = await _resolve_sso_user(email.lower()) |
| 344 | except ValueError as e: |
| 345 | return _error_redirect(str(e)) |
| 346 | |
| 347 | # Issue token (full or 2FA-pending) |
| 348 | result = await _issue_token_or_2fa(user, auth_handler) |
| 349 | logger.info(f"SSO Google login: {user.username} ({email}), 2fa={result['is_2fa']}") |
| 350 | |
| 351 | redirect_url = f"/sso-callback#token={result['token']}" |
| 352 | if result["is_2fa"]: |
| 353 | redirect_url += "&requires_2fa=true" |
| 354 | return RedirectResponse(url=redirect_url) |
| 355 | |
| 356 | |
| 357 | # ── Cloudflare Access: JWT validation flow ─────────────────────────────────── |
| 358 | |
| 359 | |
| 360 | @sso_router.post("/sso/cloudflare/verify") |
| 361 | async def cloudflare_verify(request: Request): |
| 362 | """ |
| 363 | Validate the Cf-Access-Jwt-Assertion header from Cloudflare Access. |
| 364 | Returns a CoPilot JWT if the email is in the allowlist. |
| 365 | """ |
| 366 | cfg = await get_sso_config() |
| 367 | if cfg is None or not cfg.sso_enabled or not cfg.cf_enabled: |
| 368 | raise HTTPException(status_code=400, detail="Cloudflare Access SSO is not enabled") |
| 369 | |
| 370 | # Extract JWT from header (preferred) or cookie |
| 371 | cf_token = request.headers.get("Cf-Access-Jwt-Assertion") |
| 372 | if not cf_token: |
| 373 | cf_token = request.cookies.get("CF_Authorization") |
| 374 | if not cf_token: |
| 375 | raise HTTPException( |
| 376 | status_code=401, |
| 377 | detail="No Cloudflare Access JWT found in request headers or cookies", |
| 378 | ) |
| 379 | |
| 380 | try: |
| 381 | claims = await validate_cf_jwt(cf_token, cfg) |
| 382 | except ValueError as e: |
| 383 | raise HTTPException(status_code=401, detail=str(e)) |
| 384 | except Exception as e: |
| 385 | logger.error(f"Cloudflare Access SSO error: {e}") |
| 386 | raise HTTPException(status_code=500, detail="Cloudflare authentication failed") |
| 387 | |
| 388 | email = claims.get("email") |
| 389 | if not email: |
| 390 | raise HTTPException(status_code=400, detail="No email claim in Cloudflare JWT") |
| 391 | |
| 392 | # Existing users log in directly; new users require allowlist entry |
| 393 | try: |
| 394 | user = await _resolve_sso_user(email.lower()) |
| 395 | except ValueError as e: |
| 396 | raise HTTPException(status_code=403, detail=str(e)) |
| 397 | |
| 398 | # Issue token (full or 2FA-pending) |
| 399 | result = await _issue_token_or_2fa(user, auth_handler) |
| 400 | logger.info(f"SSO Cloudflare login: {user.username} ({email}), 2fa={result['is_2fa']}") |
| 401 | |
| 402 | response = { |
| 403 | "access_token": result["token"], |
| 404 | "token_type": "bearer", |
| 405 | "message": "Cloudflare Access authentication successful", |
| 406 | "success": True, |
| 407 | } |
| 408 | if result["is_2fa"]: |
| 409 | response["requires_2fa"] = True |
| 410 | return response |