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:
@@ -21,7 +21,7 @@ from urllib.parse import urlparse
|
|||||||
from authlib.integrations.base_client import OAuthError
|
from authlib.integrations.base_client import OAuthError
|
||||||
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
|
from fastapi import FastAPI, Depends, HTTPException, Query, Request, Response, BackgroundTasks
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import JSONResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from sqlalchemy import select, delete, func
|
from sqlalchemy import select, delete, func
|
||||||
@@ -739,10 +739,10 @@ async def okta_login(request: Request):
|
|||||||
|
|
||||||
|
|
||||||
@app.get("/api/auth/okta/callback")
|
@app.get("/api/auth/okta/callback")
|
||||||
async def okta_callback(request: Request, response: Response, db: Session = Depends(get_db)):
|
async def okta_callback(request: Request, db: Session = Depends(get_db)):
|
||||||
"""Exchange the authorization code for tokens and validate the ID token. Identity
|
"""Exchange the authorization code for tokens, validate the ID token, and sign the
|
||||||
matching and session issuance are T10.3 — this route stops once the claim is in
|
person in. T10.3: matches the identity claim to a local account, or JIT-provisions
|
||||||
hand, same one-task-per-PR split the wave file records."""
|
one, then issues the same session cookie login() does today."""
|
||||||
if not okta_auth.oauth:
|
if not okta_auth.oauth:
|
||||||
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
|
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
|
||||||
try:
|
try:
|
||||||
@@ -755,9 +755,43 @@ async def okta_callback(request: Request, response: Response, db: Session = Depe
|
|||||||
if not identity:
|
if not identity:
|
||||||
log.error("Okta ID token had no %r claim — check OKTA_IDENTITY_CLAIM", okta_auth.IDENTITY_CLAIM)
|
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.")
|
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.
|
user = auth.find_user(db, identity)
|
||||||
raise NotImplementedError("T10.3: identity matching and JIT provisioning")
|
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) ──────────────────────
|
# ── Self-service password reset (needs email switched on) ──────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user