T10.3: identity matching and JIT provisioning for Okta sign-in

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
This commit is contained in:
2026-09-03 10:16:50 -07:00
parent a9e5ee3892
commit 7ed3cbec4c

View File

@@ -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) ──────────────────────