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
"""
import base64
import logging
import os
import re
import uuid
@@ -17,6 +18,7 @@ from time import monotonic
from typing import Any, Optional
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
@@ -26,7 +28,9 @@ from sqlalchemy import select, delete, func
from sqlalchemy.orm import Session
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:
# • Local dev (SQLite) auto-creates tables for a zero-config run.
@@ -721,6 +725,41 @@ def logout(response: Response):
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) ──────────────────────
RESET_COOLDOWN_SECONDS = int(os.getenv("AUTH_RESET_COOLDOWN_SECONDS", "120"))
# In-process throttle: one reset mail per (account, client) per cooldown. Enough to