Files
Project-SDE-WP-Suite/tests/okta_auth_check.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

328 lines
15 KiB
Python

#!/usr/bin/env python3
"""Does Okta sign-in hold its guarantees? — D15/D16 / T10.7.
The Okta equivalent of tests/ldap_auth_check.py (D13's predecessor). Covers the
things that would still let the app *look* fine while quietly failing:
1. The fake provider must be impossible to select against a real database.
2. A one-time authorization code cannot be redeemed twice (replay).
3. An unsolicited hit on the callback (no real login ever started) is refused,
not a 500 — and creates no account.
4. A denied ("Cancel") consent is refused cleanly (?error=cancelled) and
creates no account.
5. An unknown fake identity at consent is refused and creates no account.
6. A correct sign-in works, and ?next= carries through to the real target —
but only when it is a same-site path; an off-site next= is ignored.
7. A disabled local account is refused even though Okta itself approved it —
deprovisioning stays local (D15).
8. An unrecognized identity is JIT-provisioned at the lowest role.
9. An existing admin signs in and is STILL an admin, with their locally-set
name intact — Okta never overwrites what this app already knows.
10. OKTA_IDENTITY_CLAIM is genuinely configurable: sign-in still works with a
non-default claim name, proving T10.3's "no hard-coded claim" promise.
Self-contained: throwaway SQLite + its own uvicorn. No browser, no live Okta —
server/okta_fake.py stands in for the provider. Two layers, like its LDAP
predecessor: guards that need no server (direct calls into okta_fake), then
sign-in checks against a real running app.
Exit 0 all passed, 1 a failure, 2 could not run.
"""
import json
import os
import re
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.request
from http.cookiejar import CookieJar
from urllib.parse import quote, urlparse
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from cdp import free_port # noqa: E402
_PASS, _FAIL = [], []
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def chk(label, ok, detail=""):
(_PASS if ok else _FAIL).append(label)
print(f" {'PASS' if ok else 'FAIL'} {label}" + ("" if ok else f" {detail}"))
def users_in(db_path):
"""Read the users table straight out of the given file. Deliberately NOT
via server.db.SessionLocal — that engine binds from DATABASE_URL at import,
so setting the env var later keeps reading whichever file came first. The
LDAP predecessor lost two assertions to exactly this before it was noticed."""
import sqlite3
con = sqlite3.connect(db_path)
try:
try:
return {r[0]: (r[1], r[2], bool(r[3]))
for r in con.execute(
"select username, role, full_name, is_active from users")}
except sqlite3.OperationalError:
return {}
finally:
con.close()
def start(port, db_path, fake, identity_claim=None):
env = dict(os.environ)
env["DATABASE_URL"] = "sqlite:///" + db_path.replace("\\", "/")
env["AUTH_SECRET_KEY"] = "okta-auth-check-not-for-production"
env["WP_OKTA_FAKE_DIRECTORY"] = json.dumps(fake)
if identity_claim:
env["OKTA_IDENTITY_CLAIM"] = identity_claim
else:
env.pop("OKTA_IDENTITY_CLAIM", None)
proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "server.app:app", "--host", "127.0.0.1",
"--port", str(port), "--log-level", "warning"],
env=env, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, cwd=ROOT)
for _ in range(160):
try:
with urllib.request.urlopen(f"http://127.0.0.1:{port}/api/health", timeout=1):
return proc
except Exception:
if proc.poll() is not None:
return None
time.sleep(0.25)
proc.kill()
return None
def opener():
return urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(CookieJar()))
def fetch(op, url):
"""GET, auto-following redirects (urllib's default) — matches what a real
browser does across the login -> fake-provider -> callback -> target chain.
Returns (final_url, status, body)."""
try:
with op.open(url, timeout=10) as r:
return r.geturl(), r.status, r.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.geturl(), e.code, e.read().decode("utf-8", "replace")
def sign_in(base, op, username, next_path=None):
"""Drive the real round trip: /api/auth/okta/login -> the fake provider's
picker page -> consent as `username` -> okta_callback(). Every hop except
the fake provider itself is the app's own unmodified code."""
login_url = base + "/api/auth/okta/login"
if next_path:
login_url += "?next=" + quote(next_path, safe="")
final_url, status, body = fetch(op, login_url)
if "_fake_provider" not in final_url:
return final_url, status, body # never reached the fake at all
m = re.search(
r'id="okta-fake-identity-%s" href="([^"]+)"' % re.escape(username), body)
if not m:
return final_url, status, body # identity not offered
consent_url = base + m.group(1).replace("&amp;", "&")
return fetch(op, consent_url)
def deny(base, op):
_, _, body = fetch(op, base + "/api/auth/okta/login")
m = re.search(r'id="okta-fake-deny" href="([^"]+)"', body)
if not m:
return None, None, body
return fetch(op, base + m.group(1).replace("&amp;", "&"))
def consent_as(base, op, username, state_override=None):
"""Reach the consent endpoint directly with an arbitrary `username` (which
need not be one the picker actually offered) and, optionally, a `state`
that does not match the one the login step stashed in the session — so
'unknown identity' and 'tampered state' can be tested as the server's own
refusal, not merely as absence from the picker's list."""
_, _, body = fetch(op, base + "/api/auth/okta/login")
m = re.search(r'id="okta-fake-deny" href="([^"]+)"', body)
if not m:
return None, None, body
href = m.group(1).replace("&amp;", "&").replace("deny=1", "username=" + quote(username))
if state_override is not None:
href = re.sub(r"state=[^&]*", "state=" + quote(state_override), href)
return fetch(op, base + href)
def main():
print("1. the guards that do not need a server")
os.environ["DATABASE_URL"] = "sqlite:///./_oktacheck_unit.db"
os.environ["WP_OKTA_FAKE_DIRECTORY"] = json.dumps({"root": {"email": "r@x.test", "name": "R"}})
from server import okta_fake
chk("the fake is active against a SQLite database", okta_fake.is_active())
out = subprocess.run(
[sys.executable, "-c",
"from server import okta_fake; print(okta_fake.is_active())"],
cwd=ROOT, capture_output=True, text=True,
env={**os.environ,
"DATABASE_URL": "postgresql+psycopg://u:p@localhost:5432/db",
"AUTH_SECRET_KEY": "x",
"WP_OKTA_FAKE_DIRECTORY": json.dumps({"root": {"email": "r@x.test"}})})
chk("the fake provider REFUSES to work against a non-SQLite database",
out.stdout.strip() == "False", out.stdout.strip() or out.stderr[-200:])
code = okta_fake.new_code({"preferred_username": "root"})
first = okta_fake.consume_code(code)
second = okta_fake.consume_code(code)
chk("a fresh code redeems once", first == {"preferred_username": "root"}, first)
chk("...and a REPLAYED code is refused the second time", second is None, second)
print("\n2. sign-in, against a server")
db_fd, db_path = tempfile.mkstemp(suffix=".db"); os.close(db_fd)
port = free_port()
fake = {"root": {"email": "root@example.test", "name": "Root Person"},
"newperson": {"email": "newperson@example.test", "name": "New Person"}}
server = start(port, db_path, fake)
if server is None:
print("the test server would not start.")
return 2
base = f"http://127.0.0.1:{port}"
try:
# Seed one pre-existing admin and one pre-existing but disabled account,
# the same way manage_users.py / the admin console would have left them.
import sqlalchemy as _sa
from server.db import Base
from server import models # noqa: F401
eng = _sa.create_engine("sqlite:///" + db_path.replace("\\", "/"))
Base.metadata.create_all(bind=eng)
with eng.begin() as con:
con.execute(_sa.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
"('user_root','root','','Set By Hand','admin',1,0,0,'','','',0,'',"
"datetime('now'),datetime('now'))"))
con.execute(_sa.text(
"insert into users (id,username,email,full_name,role,is_active,"
"failed_attempts,token_version,project_role,locale,timezone,"
"auto_add_projects,auto_add_role,created_at,updated_at) values "
"('user_shelved','shelved','','Shelved Person','project_user',0,0,0,"
"'','','',0,'',datetime('now'),datetime('now'))"))
eng.dispose()
fake["shelved"] = {"email": "shelved@example.test", "name": "Shelved Person"}
# Restart so the running process picks up the augmented directory.
server.kill(); server.wait(timeout=10)
server = start(port, db_path, fake)
if server is None:
print("the test server would not restart with the augmented directory.")
return 2
final_url, status, _ = sign_in(base, opener(), "root")
chk("a seeded identity signs in", status == 200 and urlparse(final_url).path == "/index.html",
(final_url, status))
chk("...and the existing admin is STILL an admin",
users_in(db_path).get("root", ("", "", None))[0] == "admin", users_in(db_path))
chk("...with their locally-set name untouched by Okta",
users_in(db_path).get("root", (None, None))[1] == "Set By Hand", users_in(db_path))
before = set(users_in(db_path))
final_url, status, _ = sign_in(base, opener(), "newperson")
chk("an unrecognized identity is JIT-provisioned", status == 200, (final_url, status))
chk("...at the lowest role", users_in(db_path).get("newperson", ("", "", None))[0]
== "project_user", users_in(db_path).get("newperson"))
chk("...and only one new row appeared",
set(users_in(db_path)) - before == {"newperson"}, set(users_in(db_path)) - before)
final_url, status, _ = sign_in(base, opener(), "shelved")
chk("a disabled local account is refused despite Okta approving it",
"error=disabled" in final_url, final_url)
before = set(users_in(db_path))
final_url, status, _ = consent_as(base, opener(), "nobody-such-identity")
# okta_callback()'s `except OAuthError` is deliberately generic (T10.5:
# a plain-language ?error= for whatever Authlib/the provider rejected,
# not a code-by-code breakdown) — so this lands on the same ?error=
# cancelled as every other refusal, not a distinct "invalid_request".
# The thing actually under test is the SERVER-side refusal, verified by
# checking no account got created — not the display string.
chk("consenting as an identity the fake never offered is refused BY THE SERVER"
" (not just absent from the picker)",
"login.html" in final_url and "error=cancelled" in final_url, final_url)
chk("...and creates nothing", set(users_in(db_path)) == before,
set(users_in(db_path)) - before)
before = set(users_in(db_path))
final_url, status, _ = consent_as(base, opener(), "root", state_override="tampered-state")
chk("a consent hit whose state does not match the session is refused",
"login.html" in final_url and "error=cancelled" in final_url, final_url)
chk("...and creates nothing", set(users_in(db_path)) == before,
set(users_in(db_path)) - before)
before = set(users_in(db_path))
final_url, status, _ = deny(base, opener())
chk("denying consent lands back on login with a plain message",
"login.html" in final_url and "error=cancelled" in final_url, final_url)
chk("...and creates nothing", set(users_in(db_path)) == before,
set(users_in(db_path)) - before)
cold = opener()
final_url, status, _ = fetch(
cold, base + "/api/auth/okta/callback?code=forged&state=forged")
chk("an unsolicited hit on the callback (no login ever started) is refused, not a 500",
status == 200 and "login.html" in final_url and "error=cancelled" in final_url,
(final_url, status))
final_url, status, _ = sign_in(base, opener(), "root", next_path="/wp-creation-index.html?wp=x")
chk("a same-site ?next= survives the round trip",
urlparse(final_url).path == "/wp-creation-index.html"
and "wp=x" in urlparse(final_url).query, final_url)
final_url, status, _ = sign_in(base, opener(), "root", next_path="https://evil.example.com/phish")
chk("an off-site ?next= is ignored, not honoured",
urlparse(final_url).path == "/index.html", final_url)
finally:
server.kill()
try:
server.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("\n3. the identity claim name is genuinely configurable (T10.3)")
db_fd2, db2 = tempfile.mkstemp(suffix=".db"); os.close(db_fd2)
port2 = free_port()
server2 = start(port2, db2, {"root": {"email": "root@example.test", "name": "Root"}},
identity_claim="upn")
if server2 is None:
print("the identity-claim test server would not start.")
return 2
try:
base2 = f"http://127.0.0.1:{port2}"
final_url, status, _ = sign_in(base2, opener(), "root")
chk("sign-in works with a non-default OKTA_IDENTITY_CLAIM (upn)",
status == 200 and urlparse(final_url).path == "/index.html", (final_url, status))
chk("...and JIT-provisioned the account under that identity",
users_in(db2).get("root", ("", "", None))[0] == "project_user", users_in(db2))
finally:
server2.kill()
try:
server2.wait(timeout=10)
except subprocess.TimeoutExpired:
pass
print("\n" + "-" * 54)
print(f"{len(_PASS)}/{len(_PASS) + len(_FAIL)} checks passed.")
for f in _FAIL:
print(" - " + f)
return 1 if _FAIL else 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as exc: # noqa: BLE001
print(f"could not run: {type(exc).__name__}: {exc}")
sys.exit(2)