Coverage for server / routes / auth0 / auth_routes.py: 92%
203 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-10-04 09:33 +0000
1"""
2Auth0 Authentication Routes
4Provides endpoints for Auth0 authenticated users:
5- POST /login - Exchange email/password for an Auth0 access token
6- GET /me - Returns the current user's profile from the Auth0 token
7- POST /forgot-password - Send password reset email via Auth0
8"""
10import logging
11import os
12import re
14import httpx
15from cachetools import TTLCache
16from dotenv import load_dotenv
17from fastapi import APIRouter, Depends, HTTPException, Request, Response, status
18from fastapi.responses import JSONResponse
19from jose import jwt
20from pydantic import BaseModel, Field, field_validator
22from server.authentication.auth0_bearer import Auth0Bearer
23from server.authentication.auth0_config import get_auth0_settings
24from server.utilities import refresh_single_flight
25from server.authentication.cookie_session import (
26 clear_auth_cookies,
27 get_refresh_token,
28 set_auth_cookies,
29 set_refresh_cookie,
30)
31from server.rate_limit import LOGIN_RATE_LIMIT, REFRESH_RATE_LIMIT, limiter
33load_dotenv()
35logger = logging.getLogger(__name__)
37router = APIRouter()
39EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$")
41# Per-email brute-force cap (S1 — EI-2934).
42# Threshold: 5 failures within a 15-minute window short-circuit the Auth0 call
43# and return the same generic 401 to avoid exposing a lockout oracle.
44_LOGIN_FAIL_CACHE: TTLCache = TTLCache(maxsize=4096, ttl=900) # ttl=15 min
45_LOGIN_FAIL_THRESHOLD = 5
48def _load_login_cap_exempt() -> set[str]:
49 """Emails exempt from the brute-force cap.
51 E2E/automation test accounts share published credentials, so a full suite
52 run (or a handful of negative-login tests) can trip the 5-fails/15-min cap
53 and self-lock the whole suite — with no way to self-clear (a successful login
54 resets the counter, but the cap blocks success). This env-driven allowlist
55 (comma-separated ``LOGIN_CAP_EXEMPT_EMAILS``; EMPTY in production) lets those
56 specific accounts skip BOTH the cap check and the failure increment.
57 Never add a real user here — it removes their brute-force protection.
58 """
59 return {
60 e.strip().lower()
61 for e in os.getenv("LOGIN_CAP_EXEMPT_EMAILS", "").split(",")
62 if e.strip()
63 }
66class LoginRequest(BaseModel):
67 email: str = Field(..., max_length=100)
68 password: str = Field(..., max_length=128)
70 @field_validator("email")
71 @classmethod
72 def validate_email(cls, v: str) -> str:
73 if not v or not v.strip():
74 raise ValueError("Email is required.")
75 normalized = v.strip().lower()
76 if not EMAIL_PATTERN.match(normalized):
77 raise ValueError("Invalid email format.")
78 return normalized
81class ForgotPasswordRequest(BaseModel):
82 email: str
84 @field_validator("email")
85 @classmethod
86 def validate_email(cls, v):
87 if not v or not v.strip():
88 raise ValueError("Email is required.")
89 if not EMAIL_PATTERN.match(v.strip()):
90 raise ValueError("Invalid email format.")
91 return v.strip().lower()
94@router.post(
95 "/login",
96 summary="Login with email and password",
97 description="Authenticate via Auth0 and receive an access token. Use the returned access_token in the Authorize button as: Bearer <token>",
98 responses={
99 200: {
100 "description": "Authenticated",
101 "content": {
102 "application/json": {
103 "example": {
104 # Not a real token, and deliberately not token-SHAPED either. The old
105 # placeholder began with the standard base64 of {"alg":"RS256","typ":"JWT"},
106 # which every secret scanner reads as a live credential — gitleaks blocked
107 # the commit that first added openapi.json to this repo over this line.
108 "access_token": "<access token>",
109 "token_type": "Bearer",
110 # The tenant's access-token lifetime, not a constant: it was cut from
111 # 86400 to 900 and this example still said 86400.
112 "expires_in": 900,
113 }
114 }
115 },
116 },
117 401: {"description": "Invalid credentials or wrong email/password"},
118 422: {"description": "Request validation failed (malformed body)"},
119 500: {"description": "Auth0 credentials not configured on server"},
120 },
121)
122@limiter.limit(LOGIN_RATE_LIMIT)
123async def login(
124 request: Request, fastapi_response: Response, credentials: LoginRequest
125):
126 """Exchange email/password for an Auth0 access token via ROPG."""
127 domain = os.getenv("AUTH0_DOMAIN", "")
128 client_id = os.getenv("AUTH0_CLIENT_ID", "")
129 client_secret = os.getenv("AUTH0_CLIENT_SECRET", "")
130 audience = os.getenv("AUTH0_API_IDENTIFIER", "https://api.eruditiontx.com")
132 if not all([domain, client_id, client_secret]):
133 raise HTTPException(
134 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
135 detail="Auth0 credentials not configured",
136 )
138 # S1 (EI-2934): per-email brute-force cap — short-circuit before Auth0 when
139 # the threshold is exceeded. Return the SAME generic 401 as a real bad-creds
140 # failure so there is no distinguishable lockout signal.
141 email_key = credentials.email # already normalized (strip + lower) by validator
142 cap_exempt = email_key in _load_login_cap_exempt()
143 fail_count = _LOGIN_FAIL_CACHE.get(email_key, 0)
144 if not cap_exempt and fail_count >= _LOGIN_FAIL_THRESHOLD:
145 logger.warning("Login cap exceeded for email hash (not logged for privacy)")
146 raise HTTPException(
147 status_code=status.HTTP_401_UNAUTHORIZED,
148 detail="Invalid email or password.",
149 )
151 try:
152 async with httpx.AsyncClient(timeout=10.0) as client:
153 response = await client.post(
154 f"https://{domain}/oauth/token",
155 data={
156 "grant_type": "password",
157 "username": credentials.email,
158 "password": credentials.password,
159 "audience": audience,
160 "client_id": client_id,
161 "client_secret": client_secret,
162 # offline_access → Auth0 returns a refresh_token (Phase 4).
163 "scope": "openid profile email offline_access",
164 },
165 headers={"Content-Type": "application/x-www-form-urlencoded"},
166 )
167 except httpx.RequestError as e:
168 logger.error(f"Auth0 ROPG request failed: {e}")
169 raise HTTPException(
170 status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
171 detail="Could not reach the authentication service. Please try again.",
172 )
174 if response.status_code != 200:
175 # Log Auth0's real reason server-side only; return a generic message so
176 # the response can't be used to enumerate accounts / probe policy.
177 try:
178 logger.warning(
179 "Auth0 login rejected: %s", response.json().get("error_description")
180 )
181 except Exception:
182 logger.warning("Auth0 login rejected (status %s)", response.status_code)
183 # Increment the per-email failure counter (skip for cap-exempt test
184 # accounts so their shared creds never accumulate toward the cap).
185 if not cap_exempt:
186 _LOGIN_FAIL_CACHE[email_key] = _LOGIN_FAIL_CACHE.get(email_key, 0) + 1
187 raise HTTPException(
188 status_code=status.HTTP_401_UNAUTHORIZED,
189 detail="Invalid email or password.",
190 )
192 # Guard against a 200 response that is not valid JSON (Auth0 contract violation).
193 try:
194 token_data = response.json()
195 except Exception:
196 logger.error("Auth0 ROPG returned 200 but body is not JSON")
197 raise HTTPException(
198 status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
199 detail="Authentication service returned an unexpected response. Please try again.",
200 )
202 access_token = token_data.get("access_token")
204 # Guard against a 200 with a missing access_token (Auth0 contract violation).
205 if not access_token:
206 logger.error("Auth0 ROPG returned 200 but access_token is absent")
207 raise HTTPException(
208 status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
209 detail="Authentication service returned an unexpected response. Please try again.",
210 )
212 # Reject non-teacher/student accounts before returning the token.
213 roles = _roles_from_access_token(access_token)
214 if not roles & {"teacher", "student"}:
215 raise HTTPException(
216 status_code=status.HTTP_401_UNAUTHORIZED,
217 detail="Invalid email or password.",
218 )
220 # Reject deactivated accounts. Auth0 authenticated the credentials, but a
221 # soft-deactivated user_collection doc must still block session creation.
222 await _check_account_status(credentials.email)
224 # Reset the per-email failure counter on a successful login.
225 _LOGIN_FAIL_CACHE.pop(email_key, None)
227 # Phase 1/4 (ADR-001): set the session cookies — httpOnly access_token +
228 # readable csrf_token, plus the httpOnly refresh_token (Path=/v1/auth).
229 csrf_token = set_auth_cookies(
230 fastapi_response, access_token, token_data.get("expires_in")
231 )
232 refresh_token = token_data.get("refresh_token")
233 if refresh_token:
234 set_refresh_cookie(fastapi_response, refresh_token)
236 # Phase 2 (ADR-001): include the role in the body so the cookie-based SPA can
237 # route immediately without a second /auth/me round-trip on first login.
238 login_role = "teacher" if "teacher" in roles else "student"
240 # CSRF-fix: return the csrf token in the BODY too. The SPA and API run on
241 # different hosts (frontend www / api apex; staff app is fully cross-domain),
242 # so the host-only csrf_token cookie is NOT readable via document.cookie. The
243 # SPA stores this body value and echoes it as X-CSRF-Token; the backend still
244 # double-submits it against the cookie (which the browser auto-sends).
245 return {
246 "access_token": access_token,
247 "token_type": token_data.get("token_type", "Bearer"),
248 "expires_in": token_data.get("expires_in"),
249 "role": login_role,
250 "csrf_token": csrf_token,
251 }
254def _roles_from_access_token(access_token: str) -> set:
255 """Read the Auth0 roles claim from an access token (unverified — the token
256 came straight from Auth0). Returns a set of role strings (empty on error)."""
257 try:
258 claims = jwt.get_unverified_claims(access_token)
259 roles_claim = get_auth0_settings().AUTH0_ROLES_CLAIM
260 raw_roles = claims.get(roles_claim, [])
261 if isinstance(raw_roles, str):
262 raw_roles = [
263 r.strip() for r in raw_roles.replace(",", " ").split() if r.strip()
264 ]
265 return set(raw_roles)
266 except Exception:
267 return set()
270def _email_from_access_token(access_token: str) -> str | None:
271 """Read the email claim from an access token (unverified). Returns None on error."""
272 try:
273 claims = jwt.get_unverified_claims(access_token)
274 return claims.get("email") or claims.get("https://eruditiontx.com/email")
275 except Exception:
276 return None
279async def _check_account_status(email: str) -> None:
280 """Raise HTTP 403 if the teacher/student user_collection doc has status='inactive'.
282 Looks up by email (case-insensitive, role restricted to teacher/student). If
283 no doc is found or the doc has no status field, the check passes — only an
284 explicit 'inactive' value blocks login. This keeps existing users unaffected.
286 Raises:
287 HTTPException 403 when the account is deactivated.
288 """
289 from server.models.users import User
291 collection = User.get_pymongo_collection()
292 doc = await collection.find_one(
293 {
294 "email": {"$regex": f"^{re.escape(email)}$", "$options": "i"},
295 "role": {"$in": ["teacher", "student"]},
296 },
297 {"status": 1},
298 )
299 if doc and doc.get("status") == "inactive":
300 raise HTTPException(
301 status_code=status.HTTP_403_FORBIDDEN,
302 detail="This account has been deactivated. Contact your administrator.",
303 )
306@router.post(
307 "/refresh",
308 summary="Rotate the session via the refresh-token cookie",
309 description="Reads the httpOnly refresh_token cookie, exchanges it with Auth0 "
310 "(grant_type=refresh_token), and rotates the access/csrf/refresh cookies.",
311)
312@limiter.limit(REFRESH_RATE_LIMIT)
313async def refresh(request: Request, fastapi_response: Response):
314 """BFF refresh (ADR-001 Phase 4). No Auth0Bearer dependency — the access
315 token is expected to be expired; the refresh cookie is the credential."""
316 refresh_token = get_refresh_token(request)
317 if not refresh_token:
318 raise HTTPException(
319 status_code=status.HTTP_401_UNAUTHORIZED, detail="No active session."
320 )
322 # EI-T430 — one rotation per refresh token, even when two tabs fire at once.
323 # The winner performs the real exchange; anyone racing it adopts that
324 # session instead of minting a second. Auth0's reuse leeway is untouched and
325 # still backs us up: if Redis is unreachable, claim() reports success and
326 # both callers proceed exactly as they did before.
327 is_winner = await refresh_single_flight.claim(refresh_token)
328 if not is_winner:
329 shared = await refresh_single_flight.await_result(refresh_token)
330 if shared is not None:
331 csrf_token = set_auth_cookies(
332 fastapi_response,
333 shared["access_token"],
334 shared.get("expires_in"),
335 csrf_token=shared.get("csrf_token"),
336 )
337 if shared.get("refresh_token"):
338 set_refresh_cookie(fastapi_response, shared["refresh_token"])
339 logger.info(
340 "Refresh joined an in-flight rotation; no second session issued."
341 )
342 return {
343 "role": shared.get("role"),
344 "expires_in": shared.get("expires_in"),
345 "csrf_token": csrf_token,
346 }
348 domain = os.getenv("AUTH0_DOMAIN", "")
349 client_id = os.getenv("AUTH0_CLIENT_ID", "")
350 client_secret = os.getenv("AUTH0_CLIENT_SECRET", "")
352 try:
353 async with httpx.AsyncClient(timeout=10.0) as client:
354 token_resp = await client.post(
355 f"https://{domain}/oauth/token",
356 data={
357 "grant_type": "refresh_token",
358 "client_id": client_id,
359 "client_secret": client_secret,
360 "refresh_token": refresh_token,
361 },
362 headers={"Content-Type": "application/x-www-form-urlencoded"},
363 )
364 except httpx.RequestError as e:
365 logger.error(f"Auth0 refresh request failed: {e}")
366 await refresh_single_flight.abandon(refresh_token)
367 raise HTTPException(
368 status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
369 detail="Could not reach the authentication service. Please try again.",
370 )
372 if token_resp.status_code != 200:
373 # Refresh token expired / revoked / rotated-away → end the session.
374 logger.info("Auth0 refresh rejected (status %s)", token_resp.status_code)
375 await refresh_single_flight.abandon(refresh_token)
376 expired = JSONResponse(
377 status_code=status.HTTP_401_UNAUTHORIZED,
378 content={"detail": "Session expired. Please sign in again."},
379 )
380 clear_auth_cookies(expired)
381 return expired
383 token_data = token_resp.json()
384 access_token = token_data.get("access_token")
386 roles = _roles_from_access_token(access_token)
387 if not roles & {"teacher", "student"}:
388 await refresh_single_flight.abandon(refresh_token)
389 rejected = JSONResponse(
390 status_code=status.HTTP_401_UNAUTHORIZED,
391 content={"detail": "Session expired. Please sign in again."},
392 )
393 clear_auth_cookies(rejected)
394 return rejected
396 # Reject deactivated accounts so a softly deactivated student's session
397 # cannot be silently renewed without a new login check.
398 email = _email_from_access_token(access_token)
399 if email:
400 try:
401 await _check_account_status(email)
402 except HTTPException:
403 # A deactivated account still consumed its claim; release it so the
404 # TTL does not stall a legitimate retry after reactivation.
405 await refresh_single_flight.abandon(refresh_token)
406 raise
408 csrf_token = set_auth_cookies(
409 fastapi_response, access_token, token_data.get("expires_in")
410 )
411 # Auth0 returns a new refresh_token when rotation is enabled; store it.
412 new_refresh = token_data.get("refresh_token")
413 if new_refresh:
414 set_refresh_cookie(fastapi_response, new_refresh)
416 # CSRF-fix: return the rotated csrf token so the SPA can update its stored
417 # value (the host-only cookie is not readable cross-host).
418 result = {
419 "role": "teacher" if "teacher" in roles else "student",
420 "expires_in": token_data.get("expires_in"),
421 "csrf_token": csrf_token,
422 }
424 # Hand this session to any sibling still waiting on the same refresh token,
425 # so it adopts this one rather than asking Auth0 for a second.
426 await refresh_single_flight.publish(
427 refresh_token,
428 {**result, "access_token": access_token, "refresh_token": new_refresh},
429 )
431 return result
434@router.post(
435 "/logout",
436 summary="Revoke the refresh token and clear the session cookies",
437 description="Revokes the refresh token at Auth0 and deletes the access/csrf/refresh cookies.",
438)
439async def logout(request: Request, fastapi_response: Response):
440 """BFF logout (ADR-001 Phase 4). Always clears cookies; revocation is
441 best-effort so a revoke outage never blocks logout."""
442 refresh_token = get_refresh_token(request)
443 if refresh_token:
444 domain = os.getenv("AUTH0_DOMAIN", "")
445 client_id = os.getenv("AUTH0_CLIENT_ID", "")
446 client_secret = os.getenv("AUTH0_CLIENT_SECRET", "")
447 try:
448 async with httpx.AsyncClient(timeout=10.0) as client:
449 await client.post(
450 f"https://{domain}/oauth/revoke",
451 json={
452 "client_id": client_id,
453 "client_secret": client_secret,
454 "token": refresh_token,
455 },
456 )
457 except Exception as e: # noqa: BLE001 — never fail logout on revoke error
458 logger.warning(f"Refresh-token revoke failed during logout: {e}")
460 clear_auth_cookies(fastapi_response)
461 return {"message": "Logged out."}
464@router.get(
465 "/me",
466 dependencies=[Depends(Auth0Bearer())],
467 summary="Get current user profile",
468 description="Returns the authenticated user's profile from the Auth0 token and MongoDB profile.",
469)
470async def get_current_user(request: Request):
471 """Return current user profile from Auth0 token claims + MongoDB profile."""
472 user_details = request.state.user_details
473 return {
474 "auth0_user_id": user_details.get("auth0_user_id"),
475 "mongodb_id": user_details.get("mongodb_id"),
476 "name": user_details.get("name"),
477 "email": user_details.get("email"),
478 "role": user_details.get("role"),
479 "auth_provider": user_details.get("auth_provider"),
480 # CSRF-fix: echo the csrf cookie (browser auto-sends it to the API) so the
481 # SPA can recover its X-CSRF-Token after a page reload, even though the
482 # host-only cookie is not readable cross-host via document.cookie.
483 "csrf_token": request.cookies.get("csrf_token", ""),
484 }
487FORGOT_PASSWORD_RESPONSE_MSG = (
488 "If an account exists with that email, a password reset link has been sent."
489)
492@router.post(
493 "/forgot-password",
494 status_code=status.HTTP_200_OK,
495 summary="Request a password reset email",
496 description="Sends a password reset email via Auth0. Always returns a generic message to prevent email enumeration.",
497 responses={
498 200: {"description": "Password reset email sent (or email does not exist)"},
499 400: {"description": "Invalid email format"},
500 429: {"description": "Too many requests — rate limited by Auth0"},
501 500: {"description": "Auth0 service error"},
502 },
503)
504async def forgot_password(payload: ForgotPasswordRequest) -> dict:
505 """
506 Send a password reset email via Auth0's change password endpoint.
508 This is a public endpoint — no authentication required.
509 Auth0 always returns 200 regardless of whether the email exists,
510 preventing email enumeration attacks.
512 Developer: Allan Ninal
513 """
514 domain = os.getenv("AUTH0_DOMAIN", "")
515 client_id = os.getenv("AUTH0_CLIENT_ID", "")
517 if not all([domain, client_id]):
518 raise HTTPException(
519 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
520 detail="Auth0 credentials not configured",
521 )
523 logger.info(f"Password reset requested for email: {payload.email}")
525 async with httpx.AsyncClient(timeout=10.0) as client:
526 response = await client.post(
527 f"https://{domain}/dbconnections/change_password",
528 json={
529 "client_id": client_id,
530 "email": payload.email,
531 "connection": "Username-Password-Authentication",
532 },
533 headers={"Content-Type": "application/json"},
534 )
536 if response.status_code == 429:
537 raise HTTPException(
538 status_code=status.HTTP_429_TOO_MANY_REQUESTS,
539 detail="Too many password reset requests. Please try again later.",
540 )
542 if response.status_code >= 500:
543 logger.error(
544 f"Auth0 forgot-password error: {response.status_code} {response.text}"
545 )
546 raise HTTPException(
547 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
548 detail="Password reset service is temporarily unavailable.",
549 )
551 return {"message": FORGOT_PASSWORD_RESPONSE_MSG}