diff --git a/server/app.py b/server/app.py index 209b75d..58c1334 100644 --- a/server/app.py +++ b/server/app.py @@ -21,7 +21,7 @@ from urllib.parse import urlparse from authlib.integrations.base_client import OAuthError from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, ConfigDict, Field from sqlalchemy import select, delete, func @@ -739,10 +739,10 @@ async def okta_login(request: Request): @app.get("/api/auth/okta/callback") -async def okta_callback(request: Request, response: Response, db: Session = Depends(get_db)): - """Exchange the authorization code for tokens and validate the ID token. Identity - matching and session issuance are T10.3 — this route stops once the claim is in - hand, same one-task-per-PR split the wave file records.""" +async def okta_callback(request: Request, db: Session = Depends(get_db)): + """Exchange the authorization code for tokens, validate the ID token, and sign the + person in. T10.3: matches the identity claim to a local account, or JIT-provisions + one, then issues the same session cookie login() does today.""" if not okta_auth.oauth: raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.") try: @@ -755,9 +755,43 @@ async def okta_callback(request: Request, response: Response, db: Session = Depe if not identity: log.error("Okta ID token had no %r claim — check OKTA_IDENTITY_CLAIM", okta_auth.IDENTITY_CLAIM) raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.") - # T10.3 picks up here: match `identity` to a local account (or JIT-provision one), - # then auth.create_token / auth.set_session_cookie the same way login() does today. - raise NotImplementedError("T10.3: identity matching and JIT provisioning") + + user = auth.find_user(db, identity) + if user is None: + # JIT provisioning (D15). Okta is the only gate on WHO can reach this route + # at all — this app still decides what a first-time sign-in may do. A new + # account gets the lowest-privilege role and no project membership; an admin + # or project super user grants access afterward, same as any account created + # by hand today (create_user() above). No password is ever set — Okta is the + # only credential (D15's "no stored password"). + user = models.User( + id=gen_id("user"), + username=identity, + email=(claims.get("email") or "").strip(), + full_name=(claims.get("name") or "").strip(), + password_hash="", + role=auth.ROLE_PROJECT_USER, + ) + db.add(user) + db.flush() + log_event(db, user.username, "user_created", "user", user.id, summary=user.username, + detail={"role": user.role, "via": "okta_jit"}) + elif not user.is_active: + # Deprovisioning stays local (D15's "roles stay local"): Okta letting someone + # through does not override an account this app has disabled. Same rule and + # same message login() enforces today. + raise HTTPException(status_code=403, detail="Account is disabled") + + user.failed_attempts = 0 + user.locked_until = None + user.last_login_at = models.utcnow() + db.commit() + db.refresh(user) + + tok = auth.create_token(user) + redirect = RedirectResponse(url="/index.html", status_code=303) + auth.set_session_cookie(redirect, request, tok) + return redirect # ── Self-service password reset (needs email switched on) ──────────────────────