diff --git a/server/okta_auth.py b/server/okta_auth.py new file mode 100644 index 0000000..4bbcc9d --- /dev/null +++ b/server/okta_auth.py @@ -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 `/.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() diff --git a/server/requirements.txt b/server/requirements.txt index 28ac028..fc4e9a5 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -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 pydantic==2.13.4 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 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