7.2 KiB
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 patternAUTH_SECRET_KEYalready uses inserver/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 ADsAMAccountNameequivalent (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; plainop.drop_column, matching existing precedent for other NOT NULL columns onusers— nobatch_alter_tableneeded), remove the bcrypt-basedlogin(), remove the username/password form. Real deletion, matchingD15's "full replacement," not a toggle or a fallback. Scope corrected byD16after hazard review turned up more call sites than the original bullet named:create_user()andadmin_reset_password()inserver/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 perD16from 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 — seeD16.tests/browser_check.pyandtests/launcher_check.py— stop callingauth.hash_password()to seed fixture rows.server/smoketest.pyandserver/seed_demo.py— currently authenticate viaPOST /api/auth/login. Switch to minting a session withauth.create_token()directly, the same techniquebrowser_check.pyalready uses, so both scripts (named explicitly inCLAUDE.md's verification section) keep working without depending onT10.7's timing.
-
T10.5 — Frontend: login becomes a redirect, not a form.
login.html/login.jschange 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.mddescribe 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 wayldap_fake.pydoes), dispatched fromokta_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'sokta_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 inapp.pyonly when the fake is active at import time — in production they do not exist, not merely refuse.tests/browser_check.py'sstart_server()now takes an optionalextra_envand setsWP_OKTA_FAKE_DIRECTORYunconditionally (same reasoningldap_fake's equivalent used: almost nothing signs in, but the one check that does should not fail mysteriously).tests/url_state_check.pyscenario 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.pyis 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=, andOKTA_IDENTITY_CLAIMgenuinely working under a non-default claim name — 22/22.The dev sandbox this was built in has no headless browser and no way to install one, so
tests/url_state_check.pyandtests/browser_check.py(both needtests/cdp.py's real headless Chromium) could not be run there — onlyokta_auth_check.py's HTTP-level coverage of the same mechanism. Run for real afterward on a machine with Docker, via a separate general-purpose tool (headless-py-test-runner, kept out of this repo — it is not Work Package Suite specific):url_state_check.py26/26, including scenario 2's real click-through of the fake-Okta round trip, andbrowser_check.py71/71. Gap closed.Validated 2026-09-03:
tests/okta_auth_check.py22/22 (no browser needed) ·tests/url_state_check.py26/26 ·tests/browser_check.py71/71 — all three run clean, the last two against real headless Chromium via Docker. -
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/callbackproposed, pending security). - The
Business Technology Grouppilot 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.