T10.2: Okta login-redirect and callback routes

Adds GET /api/auth/okta/login (redirect to Okta's authorize endpoint) and
GET /api/auth/okta/callback (exchange code, validate ID token, pull the
identity claim) to server/app.py, using the oauth.okta client from
okta_auth.py (T10.1).

Access gating is Okta's job, not this route's: only accounts assigned to
the app integration in Okta ever reach the callback, so there is no
app-side group/claim check layered on top (D15, wave-10.md T10.2).

Stops at NotImplementedError once the identity claim is in hand. Matching
that claim to a local account and issuing the session cookie is T10.3, kept
separate per the one-task-per-PR rule.

wave-10.md T10.2 / D15
This commit is contained in:
2026-09-03 10:11:49 -07:00
parent 0ee35ae4ed
commit a9e5ee3892

View File

@@ -9,6 +9,7 @@ Run (prod): gunicorn -k uvicorn.workers.UvicornWorker -b 127.0.0.1:8000 serve
Interactive docs: http://<host>/api/docs Interactive docs: http://<host>/api/docs
""" """
import base64 import base64
import logging
import os import os
import re import re
import uuid import uuid
@@ -17,6 +18,7 @@ from time import monotonic
from typing import Any, Optional from typing import Any, Optional
from urllib.parse import urlparse from urllib.parse import urlparse
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
@@ -26,7 +28,9 @@ from sqlalchemy import select, delete, func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .db import Base, engine, get_db from .db import Base, engine, get_db
from . import models, auth, notify, assets_db from . import models, auth, notify, assets_db, okta_auth
log = logging.getLogger("wpsuite.app")
# Schema management: # Schema management:
# • Local dev (SQLite) auto-creates tables for a zero-config run. # • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -721,6 +725,41 @@ def logout(response: Response):
return {"ok": True} return {"ok": True}
# ── Okta OIDC sign-in (T10.2, wave 10 / D15) ────────────────────────────────────
# Access gating is Okta's job: only accounts assigned to this app integration in Okta
# can complete authorize_redirect at all. No app-side group/claim check is layered on
# top here — see okta_auth.py's docstring and wave-10.md T10.2 for why.
@app.get("/api/auth/okta/login")
async def okta_login(request: Request):
"""Send the browser to Okta's authorize endpoint."""
if not okta_auth.oauth:
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
return await okta_auth.oauth.okta.authorize_redirect(request, okta_auth.REDIRECT_URI)
@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."""
if not okta_auth.oauth:
raise HTTPException(status_code=503, detail="Sign-in is temporarily unavailable. Contact IT.")
try:
token = await okta_auth.oauth.okta.authorize_access_token(request)
except OAuthError as exc:
log.warning("Okta callback rejected: %s", exc)
raise HTTPException(status_code=401, detail="Sign-in was not completed.")
claims = token.get("userinfo") or {}
identity = (claims.get(okta_auth.IDENTITY_CLAIM) or "").strip()
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")
# ── Self-service password reset (needs email switched on) ────────────────────── # ── Self-service password reset (needs email switched on) ──────────────────────
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120")) RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to # In-process throttle: one reset mail per (account, client) per cooldown. Enough to