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>
101 lines
6.3 KiB
Markdown
101 lines
6.3 KiB
Markdown
# Wave 10 — Okta OIDC authentication
|
|
|
|
Fresh wave 10. The label was previously used by the LDAPS work under `D13`/`D14`, built on
|
|
`feat/ldaps-directory-auth`; that branch was deleted rather than merged and never appeared
|
|
in `IMPLEMENTATION.md`'s wave table, so it carries no claim on the number. See
|
|
`docs/waves/decisions-2026-09-02.md` (`D15`) for why LDAPS was retired before deployment
|
|
and Okta chosen instead.
|
|
|
|
Depends only on `main` as it stands after `D15`. Not sequenced behind any other wave.
|
|
|
|
## Tasks
|
|
|
|
- **T10.1 — Add the Okta OIDC client.** Authlib as a dependency. Config via env vars
|
|
(`OKTA_ISSUER`, `OKTA_CLIENT_ID`, `OKTA_CLIENT_SECRET`, `OKTA_REDIRECT_URI`), same
|
|
pattern `AUTH_SECRET_KEY` already uses in `server/auth.py`.
|
|
|
|
- **T10.2 — Login-redirect and callback routes.** A route that sends the browser to
|
|
Okta's authorize endpoint, and a callback route that exchanges the code for tokens and
|
|
validates the ID token. Access gating is Okta's job, not this app's: only accounts
|
|
assigned to the app integration in Okta can reach it at all, so there is no app-side
|
|
required-group or claim check layered on top. This is a deliberate difference from D13,
|
|
which had to gate on a required AD group itself because an LDAPS bind alone could not
|
|
distinguish an assigned user from any other domain account.
|
|
|
|
- **T10.3 — Identity matching and JIT provisioning.** Reuses D13's shape
|
|
(`_provision_from_directory`-style matching) keyed off an OIDC claim instead of an LDAP
|
|
search result. **Open dependency:** which claim carries the AD `sAMAccountName`
|
|
equivalent (`preferred_username`, `upn`, or a custom claim) is asked of security and not
|
|
yet answered. Build with a configurable claim name and a documented default, not a
|
|
hardcoded one, so the answer can drop in without a code change.
|
|
|
|
- **T10.4 — Remove the local password path entirely.** Drop `password_hash` (Alembic
|
|
migration; plain `op.drop_column`, matching existing precedent for other NOT NULL
|
|
columns on `users` — no `batch_alter_table` needed), remove the bcrypt-based
|
|
`login()`, remove the username/password form. Real deletion, matching `D15`'s "full
|
|
replacement," not a toggle or a fallback. Scope corrected by `D16` after hazard
|
|
review turned up more call sites than the original bullet named:
|
|
- `create_user()` and `admin_reset_password()` in `server/app.py` (admin console's
|
|
"add user" and "reset password" routes) — rework to drop the password field
|
|
entirely rather than break.
|
|
- `html/users.js`'s "add user" form (`nu-password`) and "Reset password" button —
|
|
matching frontend change.
|
|
- `server/manage_users.py` — reworked per `D16` from account *creation* to
|
|
*promotion*: `create-admin`/`create`/`reset-password` (password-based) are
|
|
replaced by a promote-by-username command that operates on a row Okta's JIT
|
|
provisioning (T10.3) already created, never a hand-typed new one. This is now
|
|
the documented admin-bootstrap path — see `D16`.
|
|
- `tests/browser_check.py` and `tests/launcher_check.py` — stop calling
|
|
`auth.hash_password()` to seed fixture rows.
|
|
- `server/smoketest.py` and `server/seed_demo.py` — currently authenticate via
|
|
`POST /api/auth/login`. Switch to minting a session with `auth.create_token()`
|
|
directly, the same technique `browser_check.py` already uses, so both scripts
|
|
(named explicitly in `CLAUDE.md`'s verification section) keep working without
|
|
depending on `T10.7`'s timing.
|
|
|
|
- **T10.5 — Frontend: login becomes a redirect, not a form.** `login.html`/`login.js`
|
|
change to a "Sign in with Okta" flow. Sign-out lands back on the app's own login page.
|
|
|
|
- **T10.6 — Deployment docs and env var reference.** `DEPLOYMENT.md`,
|
|
`server/.env.example`, `server/README.md` describe the Okta config in place of the LDAP
|
|
config they never ended up describing (D13 never shipped, so these still describe the
|
|
original local-password system today).
|
|
|
|
- **T10.7 — Test coverage without a live Okta dependency.** A fake-OIDC-provider test
|
|
seam, mirroring `ldap_fake.py`, so the suite runs with no live Okta tenant reachable.
|
|
Built: `server/okta_fake.py` (env-driven, `WP_OKTA_FAKE_DIRECTORY`, production-refusing
|
|
the same way `ldap_fake.py` does), dispatched from `okta_auth._build_oauth()` before
|
|
the real Authlib client is considered. Only the two Authlib calls that touch the
|
|
network — `authorize_redirect` / `authorize_access_token` — are faked; `app.py`'s
|
|
`okta_login()`/`okta_callback()` (the `?next=` guard, the disabled-account check, JIT
|
|
provisioning, the identity-claim lookup) run unmodified against the fake, same
|
|
boundary the LDAP predecessor drew around the anonymous-bind guard. Two fake-only
|
|
routes (`/_fake_provider`, `/_fake_provider/consent`) stand in for Okta's own sign-in
|
|
screen and are registered in `app.py` only when the fake is active at import time —
|
|
in production they do not exist, not merely refuse. `tests/browser_check.py`'s
|
|
`start_server()` now takes an optional `extra_env` and sets
|
|
`WP_OKTA_FAKE_DIRECTORY` unconditionally (same reasoning `ldap_fake`'s equivalent
|
|
used: almost nothing signs in, but the one check that does should not fail
|
|
mysteriously). `tests/url_state_check.py` scenario 2 is un-skipped and drives the
|
|
real round trip — login.html's button, the fake picker page, the fake consent
|
|
redirect, `okta_callback()` — proving `?next=` survives it, same tightened
|
|
"actually left login.html" assertion the LDAP predecessor's own bug fix used.
|
|
`tests/okta_auth_check.py` is new: the production guard, single-use/replay on the
|
|
authorization code, an unsolicited callback hit, a tampered state, a denied consent,
|
|
an unknown identity, a disabled account, JIT provisioning, an existing admin
|
|
surviving unchanged, same-site vs. off-site `?next=`, and `OKTA_IDENTITY_CLAIM`
|
|
genuinely working under a non-default claim name — 22/22.
|
|
|
|
- **T10.8 — Verification.** 390px and 1440px, full suite, done-when checks per task,
|
|
matching the rigor D13 was held to.
|
|
|
|
## Still open
|
|
|
|
- The OIDC claim mapping (`T10.3`).
|
|
- Final confirmation of the redirect/callback URI (`https://wp.controls.dev/api/auth/okta/callback`
|
|
proposed, pending security).
|
|
- The `Business Technology Group` pilot assignment in Okta.
|
|
|
|
Closed since first written: admin bootstrap and break-glass posture, previously open
|
|
questions, decided in `D16` (2026-09-03) and folded into `T10.4` above.
|