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

1""" 

2Auth0 Authentication Routes 

3 

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""" 

9 

10import logging 

11import os 

12import re 

13 

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 

21 

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 

32 

33load_dotenv() 

34 

35logger = logging.getLogger(__name__) 

36 

37router = APIRouter() 

38 

39EMAIL_PATTERN = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$") 

40 

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 

46 

47 

48def _load_login_cap_exempt() -> set[str]: 

49 """Emails exempt from the brute-force cap. 

50 

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 } 

64 

65 

66class LoginRequest(BaseModel): 

67 email: str = Field(..., max_length=100) 

68 password: str = Field(..., max_length=128) 

69 

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 

79 

80 

81class ForgotPasswordRequest(BaseModel): 

82 email: str 

83 

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() 

92 

93 

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") 

131 

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 ) 

137 

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 ) 

150 

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 ) 

173 

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 ) 

191 

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 ) 

201 

202 access_token = token_data.get("access_token") 

203 

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 ) 

211 

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 ) 

219 

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) 

223 

224 # Reset the per-email failure counter on a successful login. 

225 _LOGIN_FAIL_CACHE.pop(email_key, None) 

226 

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) 

235 

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" 

239 

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 } 

252 

253 

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() 

268 

269 

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 

277 

278 

279async def _check_account_status(email: str) -> None: 

280 """Raise HTTP 403 if the teacher/student user_collection doc has status='inactive'. 

281 

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. 

285 

286 Raises: 

287 HTTPException 403 when the account is deactivated. 

288 """ 

289 from server.models.users import User 

290 

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 ) 

304 

305 

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 ) 

321 

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 } 

347 

348 domain = os.getenv("AUTH0_DOMAIN", "") 

349 client_id = os.getenv("AUTH0_CLIENT_ID", "") 

350 client_secret = os.getenv("AUTH0_CLIENT_SECRET", "") 

351 

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 ) 

371 

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 

382 

383 token_data = token_resp.json() 

384 access_token = token_data.get("access_token") 

385 

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 

395 

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 

407 

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) 

415 

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 } 

423 

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 ) 

430 

431 return result 

432 

433 

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}") 

459 

460 clear_auth_cookies(fastapi_response) 

461 return {"message": "Logged out."} 

462 

463 

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 } 

485 

486 

487FORGOT_PASSWORD_RESPONSE_MSG = ( 

488 "If an account exists with that email, a password reset link has been sent." 

489) 

490 

491 

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. 

507 

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. 

511 

512 Developer: Allan Ninal 

513 """ 

514 domain = os.getenv("AUTH0_DOMAIN", "") 

515 client_id = os.getenv("AUTH0_CLIENT_ID", "") 

516 

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 ) 

522 

523 logger.info(f"Password reset requested for email: {payload.email}") 

524 

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 ) 

535 

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 ) 

541 

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 ) 

550 

551 return {"message": FORGOT_PASSWORD_RESPONSE_MSG}