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()
|
||||
Reference in New Issue
Block a user