fix(oidc): gate authorize on a server-validated OP session (BUNYIP-53) #67

Merged
nrupard merged 1 commit from fix/bunyip-53-logout-terminates-op-session into main 2026-06-05 22:26:15 +02:00
Owner

Problem

After the PMS-137 Option-B cutover, bunyip-api is the OIDC OP. The mokosh SPA logout drives the browser to the hub /logout, yet the next /oauth2/authorize immediately re-authenticated the user and bounced back to /dashboard instead of landing on /login (BUNYIP-53).

Root cause: /oauth2/authorize authenticated only off the stateless hub access_token cookie and minted a brand-new op_session on every call, consulting no server-side session state. logout revoked the op_sessions rows and cleared cookies, but authorize read none of that, so a surviving/replayed access_token (e.g. across the staging a8n.systems / api.a8n.systems cookie-domain split) kept minting codes. PMS-137 had deliberately made the hub access_token double as the OP session via COOKIE_DOMAIN; that implicit reliance was the defect.

Re-verified at runtime (token-replay against the local just dev stack): after a clean logout that cleared both cookies and revoked the op_sessions, replaying the pre-logout access_token at /oauth2/authorize still returned 302 -> cb?code=....

Fix

A real, server-validated OP session cookie:

  • bunyip-api creates an op_session and sets an opaque bunyip_op_session cookie at every login event (password, 2FA, magic-link, invite accept, register, first-admin setup).
  • /oauth2/authorize reads that cookie, validates it via the existing load_op_session, reuses the session (retiring the per-request create and its TODO), and redirects to /login when the session is absent, expired, or revoked. It no longer trusts the stateless access_token, so a stale token can no longer mint a code.
  • logout revokes the op_sessions synchronously (back-channel logout-token fan-out stays spawned) so an immediately-subsequent authorize finds no session; AuthCookies::clear also deletes bunyip_op_session. The same revoke is applied to GET /v1/auth/logout and /v1/auth/logout-all via a shared helper.

No schema change and no new sqlx queries (reuses op_sessions, create_op_session, load_op_session, revoke_sessions_for_backchannel).

Verification

just check-container green (fmt + clippy -D warnings + workspace lib tests; only the pre-existing sqlx-postgres future-incompat warning).

Runtime token-replay against the rebuilt just dev-detach stack:

step result
login sets bunyip_op_session alongside access/refresh
authorize (fresh login) 302 -> cb?code=... (happy path)
replay stale access_token after logout 302 -> /login (was: minted a code)
replay stale bunyip_op_session after logout 302 -> /login (revoked server-side)
replay both stale cookies 302 -> /login
fresh login again -> authorize 302 -> cb?code=... (intact)

Out of scope

AC2 (un-fixme the mokosh-server auth-ui E2E, PMS-142) lives in the mokosh-server repo and follows after this lands and deploys to staging. Final staging re-verification of AC1 happens on the converged Option-B deploy.

Refs BUNYIP-53.

## Problem After the PMS-137 Option-B cutover, bunyip-api is the OIDC OP. The mokosh SPA logout drives the browser to the hub `/logout`, yet the next `/oauth2/authorize` immediately re-authenticated the user and bounced back to `/dashboard` instead of landing on `/login` (BUNYIP-53). Root cause: `/oauth2/authorize` authenticated **only** off the stateless hub `access_token` cookie and minted a brand-new `op_session` on every call, consulting no server-side session state. `logout` revoked the `op_sessions` rows and cleared cookies, but authorize read none of that, so a surviving/replayed `access_token` (e.g. across the staging `a8n.systems` / `api.a8n.systems` cookie-domain split) kept minting codes. PMS-137 had deliberately made the hub `access_token` double as the OP session via `COOKIE_DOMAIN`; that implicit reliance was the defect. Re-verified at runtime (token-replay against the local `just dev` stack): after a clean logout that cleared both cookies and revoked the op_sessions, replaying the pre-logout `access_token` at `/oauth2/authorize` still returned `302 -> cb?code=...`. ## Fix A real, server-validated OP session cookie: - bunyip-api creates an `op_session` and sets an opaque `bunyip_op_session` cookie at every login event (password, 2FA, magic-link, invite accept, register, first-admin setup). - `/oauth2/authorize` reads that cookie, validates it via the existing `load_op_session`, reuses the session (retiring the per-request create and its `TODO`), and redirects to `/login` when the session is absent, expired, or revoked. It no longer trusts the stateless `access_token`, so a stale token can no longer mint a code. - `logout` revokes the `op_sessions` **synchronously** (back-channel logout-token fan-out stays spawned) so an immediately-subsequent authorize finds no session; `AuthCookies::clear` also deletes `bunyip_op_session`. The same revoke is applied to `GET /v1/auth/logout` and `/v1/auth/logout-all` via a shared helper. No schema change and no new sqlx queries (reuses `op_sessions`, `create_op_session`, `load_op_session`, `revoke_sessions_for_backchannel`). ## Verification `just check-container` green (fmt + clippy `-D warnings` + workspace lib tests; only the pre-existing sqlx-postgres future-incompat warning). Runtime token-replay against the rebuilt `just dev-detach` stack: | step | result | |---|---| | login | sets `bunyip_op_session` alongside access/refresh | | authorize (fresh login) | 302 -> `cb?code=...` (happy path) | | replay stale `access_token` after logout | 302 -> `/login` (was: minted a code) | | replay stale `bunyip_op_session` after logout | 302 -> `/login` (revoked server-side) | | replay both stale cookies | 302 -> `/login` | | fresh login again -> authorize | 302 -> `cb?code=...` (intact) | ## Out of scope AC2 (un-fixme the mokosh-server `auth-ui` E2E, PMS-142) lives in the mokosh-server repo and follows after this lands and deploys to staging. Final staging re-verification of AC1 happens on the converged Option-B deploy. Refs BUNYIP-53.
fix(oidc): gate authorize on a server-validated OP session
All checks were successful
Check / fmt / clippy / build / test (pull_request) Successful in 1m4s
Create release / Create release from merged PR (pull_request) Has been skipped
b4ab88d656
GET /logout did not terminate the OP session: /oauth2/authorize authenticated solely off the stateless hub access_token cookie and minted a brand-new op_session on every call, consulting no server-side state. logout revoked the op_sessions rows and cleared the cookies, but authorize read none of that, so a surviving or replayed access_token (e.g. across the staging a8n.systems / api.a8n.systems cookie-domain split) re-authenticated the user and the mokosh SPA bounced straight back to /dashboard. Re-verified locally by replaying a pre-logout access_token after a clean logout: it still minted a code.

Introduce a real, server-validated OP session cookie. bunyip-api now creates an op_session and sets an opaque bunyip_op_session cookie at every login event (password, 2FA, magic-link, invite accept, register, first-admin setup); /oauth2/authorize reads that cookie, validates it via load_op_session, reuses the session (retiring the per-request create and its TODO), and redirects to /login when absent, expired, or revoked. Because authorize no longer trusts the stateless token, a stale access_token can no longer mint a code.

logout now revokes the op_sessions synchronously (the back-channel logout-token fan-out stays spawned) so an immediately-subsequent authorize finds no session, and AuthCookies::clear additionally deletes bunyip_op_session. The same revoke is applied to GET /v1/auth/logout and /v1/auth/logout-all via a shared helper.

#BUNYIP-53

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch fix/bunyip-53-logout-terminates-op-session 2026-06-05 22:26:15 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
psa-systems/bunyip!67
No description provided.