Files
Project-SDE-WP-Suite/server/okta_auth.py

97 lines
4.7 KiB
Python

"""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()