Files
Project-SDE-WP-Suite/server/okta_fake.py
Matt Mabrey 290c9b078c T10.7 - a fake-OIDC-provider test seam, mirroring ldap_fake.py
Only two calls actually touch the network: Authlib's authorize_redirect and
authorize_access_token. server/okta_fake.py stands in for both, dispatched from
okta_auth._build_oauth() before the real Okta config is even considered, and
production-refusing the same way ldap_fake.is_active() does - a non-SQLite
DATABASE_URL means production, full stop, no matter what WP_OKTA_FAKE_DIRECTORY
says. Everything this app itself decides stays real: the ?next= open-redirect
guard, the disabled-account check, JIT provisioning, and which claim carries
identity all run unmodified in app.py's okta_login()/okta_callback().

The fake needed one thing ldap_fake.py never did: something to actually redirect
the browser to and back, since Okta's real flow leaves the site and LDAP's never
did. Two routes stand in for Okta's own sign-in screen - a plain picker listing
whatever WP_OKTA_FAKE_DIRECTORY defines, and a consent step that hands back an
authorization code (or an error) at okta_callback, exactly the shape a real Okta
redirect would carry. Both are registered in app.py only when the fake is active
at import time, so in production they do not exist at all, not merely refuse a
request - confirmed by starting the app with the env var unset and checking
app.routes directly.

tests/browser_check.py's start_server() takes an optional extra_env now (no
existing caller passes a third positional arg, so none of the ~40 files that
import it needed touching) and sets WP_OKTA_FAKE_DIRECTORY unconditionally,
same reasoning the LDAP predecessor used: almost nothing signs in (seed() mints
tokens directly), but the one check that does should not fail mysteriously.

tests/url_state_check.py scenario 2, SKIPPED since T10.4, is un-skipped and now
drives the real round trip: login.html's own button, the fake picker page, the
fake consent redirect, okta_callback(). Carries forward the LDAP predecessor's
own bug fix too - asserting the app actually LEFT login.html, not just that
wp-creation-index.html appears somewhere in the URL (which the ?next= parameter
alone would satisfy).

tests/okta_auth_check.py is new, mirroring ldap_auth_check.py's two-layer shape:
guards that need no server (the production refusal, single-use/replay on the
authorization code), then a real running app for sign-in itself - an existing
admin surviving unchanged, JIT provisioning at the lowest role, a disabled
account refused despite Okta approving it, an unsolicited callback hit refused
without a 500, a tampered state refused, a denied consent refused, an unknown
identity refused BY THE SERVER (not just absent from the picker), a same-site
next= surviving and an off-site one ignored, and OKTA_IDENTITY_CLAIM genuinely
working under a non-default claim name. 22/22.

One thing this could not verify in this environment: url_state_check.py and
browser_check.py both need a headless Edge/Chrome via cdp.py, and this sandbox
has neither installed and no way to install one (no sudo). Confirmed the failure
is the tests' own designed-for exit 2 ("no headless-capable browser found; set
WP_BROWSER"), not a crash, and separately confirmed start_server() itself boots
cleanly with the fake wired in - health check, the picker page rendering with
the seeded identities, login.html all responding correctly - so the only gap is
the DOM-level click-through, not the server-side mechanism url_state_check
exercises (which okta_auth_check.py covers directly via HTTP instead).

wave-10.md's T10.7 bullet records the shape of what got built and the 22/22
result.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 12:11:24 -07:00

191 lines
8.4 KiB
Python

"""A fake Okta, for tests only — T10.7.
`server/okta_auth.py` normally hands the browser off to a real Okta authorize
endpoint and exchanges the code with Okta's token endpoint over the network
(Authlib discovers both from `<issuer>/.well-known/openid-configuration`). Tests
cannot reach any of that: there is no live Okta tenant in CI, and the browser
checks launch the app as a SUBPROCESS (`start_server` in tests/browser_check.py),
so a monkeypatch in the test process would never reach the code doing the
authenticating. The seam has to be configurable from the ENVIRONMENT — same
discipline as `server/ldap_fake.py`, D13/T10.7's LDAP predecessor.
Set `WP_OKTA_FAKE_DIRECTORY` to a JSON object and this module stands in for the
whole round trip — the authorize redirect, a stand-in "sign in at Okta" screen,
and the token exchange — with no network call anywhere:
{"root": {"email": "root@example.test", "name": "Root Person"}}
The key is the identity value a real Okta ID token would carry in whichever
claim `OKTA_IDENTITY_CLAIM` names (default `preferred_username`) — the fake
reads `okta_auth.IDENTITY_CLAIM` at request time, so it exercises whatever claim
name is actually configured rather than a hard-coded one.
WHAT THIS DOES AND DOES NOT REPLACE
Only the OAuth-protocol plumbing that talks to Okta over the network is faked —
`authorize_redirect` and `authorize_access_token`, both owned by Authlib, a
third-party library, not this app's security logic. Everything this app itself
decides stays real and untouched in `app.py`'s `okta_login()`/`okta_callback()`:
the `?next=` open-redirect guard (`_safe_next_path`), the disabled-account
check, JIT provisioning, and which claim carries identity. A fake run exercises
the actual code for all of that, not a re-implementation of it — the same
boundary `ldap_fake.py` drew around the anonymous-bind guard.
THE PRODUCTION GUARD IS THE POINT OF THIS FILE.
An environment variable that lets anyone "sign in" as any identity by visiting a
picker page is exactly the kind of thing that must never be reachable outside a
test process — D16 leaves no local password fallback and no break-glass, so a
fake provider silently active in production would be a total authentication
bypass with a friendlier UI than most. `is_active()` refuses whenever a real
database is configured, using the same test `auth._load_secret` and
`ldap_fake.is_active()` already use: a non-SQLite `DATABASE_URL` means
production, full stop. `okta_auth.describe()` also shouts when the fake is
live, and `app.py` registers the picker/consent routes only when the fake is
active at import time — in production they do not exist, not merely refuse.
"""
import json
import logging
import os
import secrets
import time
from html import escape
from typing import Optional
from urllib.parse import quote
from starlette.responses import RedirectResponse
log = logging.getLogger("wpsuite.okta.fake")
ENV_VAR = "WP_OKTA_FAKE_DIRECTORY"
# One-time authorization codes, in-process only. The login and the callback that
# redeems the code both happen inside the SAME uvicorn process within one test
# run, so this needs no more durability than that — the server restart every
# check does between runs clears it for free. Not a cache: entries are popped on
# first use (below) and expire on their own otherwise.
_CODE_TTL_SECONDS = 120
_PENDING_CODES: dict = {}
def _raw() -> str:
return os.getenv(ENV_VAR, "").strip()
def is_active() -> bool:
"""Whether the fake should answer. False in anything resembling production."""
if not _raw():
return False
# Imported lazily: server.db reads DATABASE_URL at import, and this module is
# imported from okta_auth (and app.py), which must stay importable on its own.
from .db import DATABASE_URL
if not str(DATABASE_URL).startswith("sqlite"):
log.error(
"%s is set but a non-SQLite DATABASE_URL is configured. REFUSING to use "
"the fake Okta provider — this looks like production, and D16 leaves no "
"other way in, so honouring it would be an authentication bypass. "
"Unset %s.", ENV_VAR, ENV_VAR)
return False
return True
def directory() -> dict:
try:
data = json.loads(_raw())
if not isinstance(data, dict):
raise ValueError("top level must be an object")
return data
except Exception as exc: # noqa: BLE001 — a malformed fake must not look auth-shaped
log.error("%s is not valid JSON (%s); the fake directory is empty", ENV_VAR, exc)
return {}
def new_code(claims: dict) -> str:
code = secrets.token_urlsafe(24)
_PENDING_CODES[code] = {"claims": claims, "expires": time.time() + _CODE_TTL_SECONDS}
return code
def consume_code(code: str) -> Optional[dict]:
"""Pop and return the claims for a code, or None if unknown/expired/reused.
Popping makes the code single-use, matching a real authorization code."""
entry = _PENDING_CODES.pop(code, None)
if not entry or entry["expires"] < time.time():
return None
return entry["claims"]
def picker_page(state: str, redirect_uri: str) -> str:
"""The fake's stand-in for Okta's own sign-in screen — a plain list of the
identities `WP_OKTA_FAKE_DIRECTORY` defines, so a browser check can click
through a real page rather than skip the round trip with a minted cookie.
Deliberately plain: nothing here is styled to resemble a real Okta page."""
from . import okta_auth
rows = []
for username in directory():
href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
f"&redirect_uri={quote(redirect_uri, safe='')}&username={quote(username)}")
rows.append(
f'<li><a id="okta-fake-identity-{escape(username)}" href="{escape(href, quote=True)}">'
f'Continue as {escape(username)}</a></li>')
deny_href = (f"/api/auth/okta/_fake_provider/consent?state={quote(state)}"
f"&redirect_uri={quote(redirect_uri, safe='')}&deny=1")
deny_href = escape(deny_href, quote=True)
return (
"<!doctype html><title>FAKE Okta — tests only</title>"
"<h1>*** FAKE OKTA PROVIDER — TESTS ONLY ***</h1>"
f"<p>Identity claim in use: <code>{escape(okta_auth.IDENTITY_CLAIM)}</code></p>"
"<ul>" + "".join(rows) + "</ul>"
f'<p><a id="okta-fake-deny" href="{deny_href}">Deny access</a></p>'
)
class _FakeOktaClient:
"""Stands in for Authlib's `oauth.okta` — the same two methods `app.py`
calls, the same async signatures, zero network calls."""
async def authorize_redirect(self, request, redirect_uri):
state = secrets.token_urlsafe(24)
# The only session write this fake makes. authorize_access_token below is
# the only read — mirrors exactly what real Authlib does with `state`,
# which is what T10.2's missing-SessionMiddleware bug was about: this
# round trip is a genuine test of the same plumbing.
request.session["_okta_fake_state"] = state
target = redirect_uri or "/api/auth/okta/callback"
url = (f"/api/auth/okta/_fake_provider?state={quote(state)}"
f"&redirect_uri={quote(target, safe='')}")
return RedirectResponse(url=url, status_code=302)
async def authorize_access_token(self, request):
from authlib.integrations.base_client import OAuthError
expected = request.session.pop("_okta_fake_state", None)
given = request.query_params.get("state", "")
if not expected or given != expected:
raise OAuthError(
error="invalid_state",
description="fake Okta: state did not match the session (T10.7 seam)")
err = request.query_params.get("error")
if err:
raise OAuthError(
error=err,
description=request.query_params.get("error_description", "denied"))
code = request.query_params.get("code", "")
claims = consume_code(code)
if claims is None:
raise OAuthError(error="invalid_grant",
description="fake Okta: unknown or expired code")
return {"userinfo": claims}
class FakeOAuth:
"""Stands in for Authlib's `OAuth()` registry. The real one exposes each
registered client as an attribute by name; `app.py` only ever touches
`.okta`, so that is the only attribute this needs."""
def __init__(self):
self.okta = _FakeOktaClient()
def build() -> "FakeOAuth":
return FakeOAuth()