T10.1: Okta OIDC client dependency and config (wave 10)
This commit is contained in:
96
server/okta_auth.py
Normal file
96
server/okta_auth.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Okta OIDC client configuration — T10.1, wave 10 (D15).
|
||||||
|
|
||||||
|
This module owns the conversation with Okta and nothing else: it does not issue this
|
||||||
|
app's own session cookie, does not touch the database, and does not decide who may sign
|
||||||
|
in. `server/auth.py` keeps doing all of that, unchanged — a session is still a signed JWT
|
||||||
|
in an HttpOnly cookie, roles are still local, `get_current_user` still re-reads the
|
||||||
|
account on every request. Only how a person's identity gets confirmed changes: an Okta
|
||||||
|
authorization-code flow, in place of the local username/password check.
|
||||||
|
|
||||||
|
Access gating is Okta's job, not this module's. Only accounts assigned to this app
|
||||||
|
integration in Okta ever complete the flow at all, so there is no required-group or
|
||||||
|
claim check layered on top here — see D15 and wave-10.md for why that is a deliberate
|
||||||
|
difference from D13's design, not an oversight.
|
||||||
|
|
||||||
|
Unconfigured is a first-class state, same discipline as the LDAPS module this replaces:
|
||||||
|
with any of the four settings below missing, `is_configured()` is False and `describe()`
|
||||||
|
says so in the startup log, rather than the app discovering it later at the login button.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
log = logging.getLogger("wpsuite.okta")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from authlib.integrations.starlette_client import OAuth
|
||||||
|
HAVE_AUTHLIB = True
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
HAVE_AUTHLIB = False
|
||||||
|
OAuth = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
# ── configuration ─────────────────────────────────────────────────────────────
|
||||||
|
# The Okta *authorization server* issuer, e.g. https://primecontrols.okta.com/oauth2/default
|
||||||
|
# or a custom authorization server URL. Authlib discovers the rest (authorize/token/
|
||||||
|
# jwks endpoints) from `<issuer>/.well-known/openid-configuration` — nothing below is
|
||||||
|
# hand-entered except this base URL, the client credentials, and our own callback.
|
||||||
|
ISSUER = os.getenv("OKTA_ISSUER", "")
|
||||||
|
CLIENT_ID = os.getenv("OKTA_CLIENT_ID", "")
|
||||||
|
CLIENT_SECRET = os.getenv("OKTA_CLIENT_SECRET", "")
|
||||||
|
# Must exactly match a Sign-in redirect URI registered on the Okta app integration.
|
||||||
|
# e.g. https://wp.controls.dev/api/auth/okta/callback
|
||||||
|
REDIRECT_URI = os.getenv("OKTA_REDIRECT_URI", "")
|
||||||
|
|
||||||
|
# Standard OIDC identity scopes only. No group/role scopes: T10.2's note above explains
|
||||||
|
# why access gating and permissions both stay out of the token.
|
||||||
|
SCOPES = "openid profile email"
|
||||||
|
|
||||||
|
# Which claim in the ID token carries this person's AD sAMAccountName equivalent, for
|
||||||
|
# matching against the local `users` table (T10.3). Not yet confirmed by security —
|
||||||
|
# `preferred_username` is Okta's usual default for an AD-imported user, used here as a
|
||||||
|
# documented placeholder, NOT a verified answer. Override via env once security replies
|
||||||
|
# so the real value can drop in without a code change.
|
||||||
|
IDENTITY_CLAIM = os.getenv("OKTA_IDENTITY_CLAIM", "preferred_username")
|
||||||
|
|
||||||
|
|
||||||
|
def is_configured() -> bool:
|
||||||
|
"""Whether an OIDC flow could even be attempted. Deliberately does not touch the
|
||||||
|
network — that would be a `selftest()`, added when T10.2 needs one."""
|
||||||
|
return bool(HAVE_AUTHLIB and ISSUER and CLIENT_ID and CLIENT_SECRET and REDIRECT_URI)
|
||||||
|
|
||||||
|
|
||||||
|
def describe() -> str:
|
||||||
|
"""One line for the startup log, matching the LDAPS module's discipline: an
|
||||||
|
unconfigured deploy must be visible in `docker compose logs api`, not discovered at
|
||||||
|
the login button."""
|
||||||
|
if not HAVE_AUTHLIB:
|
||||||
|
return "Okta auth DISABLED — authlib is not installed. No one can sign in."
|
||||||
|
missing = [name for name, val in (
|
||||||
|
("OKTA_ISSUER", ISSUER), ("OKTA_CLIENT_ID", CLIENT_ID),
|
||||||
|
("OKTA_CLIENT_SECRET", CLIENT_SECRET), ("OKTA_REDIRECT_URI", REDIRECT_URI),
|
||||||
|
) if not val]
|
||||||
|
if missing:
|
||||||
|
return f"Okta auth DISABLED — missing {', '.join(missing)}. No one can sign in."
|
||||||
|
return (f"Okta auth enabled — issuer {ISSUER}, redirect {REDIRECT_URI}, "
|
||||||
|
f"identity claim {IDENTITY_CLAIM!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _build_oauth() -> Optional["OAuth"]:
|
||||||
|
"""Register the Okta client. Returns None when unconfigured so the caller (T10.2's
|
||||||
|
routes) can fail loudly instead of Authlib raising deep inside a request."""
|
||||||
|
if not is_configured():
|
||||||
|
return None
|
||||||
|
oauth = OAuth()
|
||||||
|
oauth.register(
|
||||||
|
name="okta",
|
||||||
|
client_id=CLIENT_ID,
|
||||||
|
client_secret=CLIENT_SECRET,
|
||||||
|
server_metadata_url=f"{ISSUER.rstrip('/')}/.well-known/openid-configuration",
|
||||||
|
client_kwargs={"scope": SCOPES},
|
||||||
|
)
|
||||||
|
return oauth
|
||||||
|
|
||||||
|
|
||||||
|
# Built once at import time, same as `SECRET_KEY` in auth.py — a missing/bad config is a
|
||||||
|
# deploy problem to catch at startup via `describe()`, not a per-request surprise.
|
||||||
|
oauth = _build_oauth()
|
||||||
@@ -17,6 +17,9 @@ pymssql==2.3.13 # read-only lookups against the Micron asset DB (SQL
|
|||||||
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
|
# MICRON_DB_URL to mssql+pyodbc://…?driver=ODBC+Driver+18+for+SQL+Server
|
||||||
pydantic==2.13.4
|
pydantic==2.13.4
|
||||||
python-dotenv==1.2.2
|
python-dotenv==1.2.2
|
||||||
bcrypt==5.0.0 # password hashing
|
bcrypt==5.0.0 # password hashing — still used until T10.4 removes the local
|
||||||
|
# password path; do not drop this before that task lands.
|
||||||
PyJWT==2.13.0 # signed session tokens
|
PyJWT==2.13.0 # signed session tokens
|
||||||
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
starlette==1.3.1 # pinned transitive (cookie / CORS handling — security-relevant)
|
||||||
|
Authlib==1.7.2 # Okta OIDC authorization-code flow (T10.1, wave 10 / D15)
|
||||||
|
httpx==0.28.1 # Authlib's OIDC client needs an HTTP client; explicit, not transitive
|
||||||
|
|||||||
Reference in New Issue
Block a user