From 7ed3cbec4cd713160b35e0fad4a8e8881c377059 Mon Sep 17 00:00:00 2001 From: Matt Mabrey Date: Thu, 3 Sep 2026 10:16:50 -0700 Subject: [PATCH] T10.3: identity matching and JIT provisioning for Okta sign-in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit okta_callback() now completes the sign-in instead of stopping at the claim: - Matches the OKTA_IDENTITY_CLAIM value to a local account via auth.find_user() (username or email, case-insensitive) — the same lookup login() already uses, so an account whose username mirrors its AD identity needs no migration. - No match: JIT-provisions a new account at the lowest-privilege role (project_user), no project membership, no password hash. Access beyond that is still granted locally by an admin/project super user, same as any account created by hand via create_user(). Logs a user_created audit event (via: okta_jit) for parity with that route. - Match found but is_active is False: blocked with the same 403 'Account is disabled' login() raises today. Okta granting the challenge does not override an account this app has disabled locally (D15: 'roles stay local'). - On success: issues the same session cookie login() does (auth.create_ token / auth.set_session_cookie), then redirects the browser to /index.html — this route is reached by a full-page navigation from Okta's redirect, not a fetch call, so a redirect is required rather than the JSON body login() returns. Verified with a fake Okta client against a throwaway SQLite DB: new identity provisions correctly (role/email/name/no-password), a repeat sign-in matches the existing row without duplicating it or touching a role an admin has since changed, a locally-disabled account is blocked despite a valid Okta claim, and a missing identity claim is rejected before touching the database. wave-10.md T10.3 / D15 --- server/app.py | 50 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 8 deletions(-) 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) ──────────────────────