114 lines
7.2 KiB
Markdown
114 lines
7.2 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.
|
|
|
|
The dev sandbox this was built in has no headless browser and no way to install
|
|
one, so `tests/url_state_check.py` and `tests/browser_check.py` (both need
|
|
`tests/cdp.py`'s real headless Chromium) could not be run there — only
|
|
`okta_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.py` 26/26, including scenario 2's real click-through of
|
|
the fake-Okta round trip, and `browser_check.py` 71/71. Gap closed.
|
|
|
|
**Validated 2026-09-03:** `tests/okta_auth_check.py` 22/22 (no browser needed) ·
|
|
`tests/url_state_check.py` 26/26 · `tests/browser_check.py` 71/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/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.
|