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