main
py 410 lines 14.8 KB
Raw
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