fix(oidc): clear stale host-only op_session on silent-SSO set path (BUNYIP-146) #177

Merged
nrupard merged 2 commits from fix/bunyip-146-op-session-cookie-domain into main 2026-06-20 17:48:01 +02:00
Owner

What

Fix /oauth2/authorize bouncing an authenticated user to /login when a stale host-only bunyip_op_session cookie shadows the correctly domain-scoped one after a COOKIE_DOMAIN change (BUNYIP-146, surfaced by the a8n -> mokosh rebrand + env-domain-injection refactor).

Root cause

When COOKIE_DOMAIN is set, the live bunyip_op_session is domain-scoped. A cookie issued before the domain was configured is host-only, and browsers send the more-specific host-only cookie first. So a dead host-only sid wins at load_op_session, resolves to None, and authorize falls through to /login.

The login path (bunyip-api auth handlers) and the refresh-rotation path (try_silent_sso Path 2) already neutralize this via clear_stale, which dual-emits a no-domain Max-Age 0 clear alongside the domain-scoped set. The silent-SSO access-token path (try_silent_sso Path 1) did not: it set the domain-scoped op_session cookie with no accompanying clear, leaving the host-only straggler to win on the next authorize.

Change

  • New AuthCookies::op_session_set(sid, secure, cookie_domain) mirrors the dual-emit shape of clear_op_session_only on the set path. With a cookie_domain, it pairs the domain-scoped set with a no-domain Max-Age 0 clear so the browser deletes the host-only sibling; with no domain it returns just the host-only set cookie. Only the OP session cookie is touched, so hub access/refresh cookies stay intact.
  • Wire it into try_silent_sso Path 1 (crates/bunyip-oidc/src/handlers/oidc.rs).
  • Two unit tests covering the no-domain (single set) and with-domain (set + stale clear) shapes.

Scope note

This PR covers the in-repo code hardening (AC3). The remaining acceptance criteria are deployment-side: set COOKIE_DOMAIN on staging/prod to the registrable parent (e.g. .mokosh.systems) so bunyip_op_session is shared across the api.* host and the apex, then verify mokosh-server's e2e/tests/oidc.spec.ts (authorize -> token -> userinfo -> refresh) against staging. compose.yml/compose.dev.yml already read COOKIE_DOMAIN from env (${COOKIE_DOMAIN:-}), so no compose change is needed; the value is an ops/env setting, not a hardcoded default.

Verification

just check-container green: fmt + clippy (-D warnings) + workspace lib tests (202 domain incl. 2 new, 9 oci, 9 oidc).

🤖 Generated with Claude Code

## What Fix `/oauth2/authorize` bouncing an authenticated user to `/login` when a stale host-only `bunyip_op_session` cookie shadows the correctly domain-scoped one after a `COOKIE_DOMAIN` change (BUNYIP-146, surfaced by the a8n -> mokosh rebrand + env-domain-injection refactor). ## Root cause When `COOKIE_DOMAIN` is set, the live `bunyip_op_session` is domain-scoped. A cookie issued before the domain was configured is host-only, and browsers send the more-specific host-only cookie first. So a dead host-only sid wins at `load_op_session`, resolves to `None`, and authorize falls through to `/login`. The login path (`bunyip-api` auth handlers) and the refresh-rotation path (`try_silent_sso` Path 2) already neutralize this via `clear_stale`, which dual-emits a no-domain Max-Age 0 clear alongside the domain-scoped set. The silent-SSO access-token path (`try_silent_sso` Path 1) did not: it set the domain-scoped op_session cookie with no accompanying clear, leaving the host-only straggler to win on the next authorize. ## Change - New `AuthCookies::op_session_set(sid, secure, cookie_domain)` mirrors the dual-emit shape of `clear_op_session_only` on the set path. With a `cookie_domain`, it pairs the domain-scoped set with a no-domain Max-Age 0 clear so the browser deletes the host-only sibling; with no domain it returns just the host-only set cookie. Only the OP session cookie is touched, so hub access/refresh cookies stay intact. - Wire it into `try_silent_sso` Path 1 (`crates/bunyip-oidc/src/handlers/oidc.rs`). - Two unit tests covering the no-domain (single set) and with-domain (set + stale clear) shapes. ## Scope note This PR covers the in-repo code hardening (AC3). The remaining acceptance criteria are deployment-side: set `COOKIE_DOMAIN` on staging/prod to the registrable parent (e.g. `.mokosh.systems`) so `bunyip_op_session` is shared across the `api.*` host and the apex, then verify mokosh-server's `e2e/tests/oidc.spec.ts` (authorize -> token -> userinfo -> refresh) against staging. `compose.yml`/`compose.dev.yml` already read `COOKIE_DOMAIN` from env (`${COOKIE_DOMAIN:-}`), so no compose change is needed; the value is an ops/env setting, not a hardcoded default. ## Verification `just check-container` green: fmt + clippy (-D warnings) + workspace lib tests (202 domain incl. 2 new, 9 oci, 9 oidc). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(oidc): clear stale host-only op_session on silent-SSO set path (BUNYIP-146)
All checks were successful
Check / fmt / clippy / build / test (pull_request) Successful in 1m19s
f3223a4a63
When COOKIE_DOMAIN is configured the live `bunyip_op_session` cookie is domain-scoped, but a cookie issued before the domain was set (or before a COOKIE_DOMAIN change such as the a8n -> mokosh rebrand) is host-only. Browsers send the more-specific host-only cookie first, so a stale host-only sid shadows the freshly-set domain-scoped one and `/oauth2/authorize` keeps reading the dead value and bouncing to `/login`.

The login and refresh-rotation paths already neutralize this via `clear_stale` (which dual-emits a no-domain Max-Age 0 clear alongside the domain-scoped set). The `try_silent_sso` access-token path did not: it set the domain-scoped op_session cookie with no accompanying clear, leaving the host-only straggler to win on the next authorize.

Add `AuthCookies::op_session_set`, which mirrors the dual-emit shape of `clear_op_session_only` on the set path: when a cookie_domain is present it pairs the domain-scoped set with a no-domain clear so the browser deletes the host-only sibling. With no cookie_domain the live cookie is itself host-only, so it returns just the set cookie. Only the OP session cookie is touched; hub access/refresh cookies stay intact. Wire it into the silent-SSO access-token path.

#BUNYIP-146

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
test(oidc): pin path+secure equality in op_session_set dual-emit test (BUNYIP-146)
All checks were successful
Create release / Create release from merged PR (pull_request) Has been skipped
Check / fmt / clippy / build / test (pull_request) Successful in 59s
a83ca28954
Per RFC 6265 a browser evicts a stored cookie only when the incoming Max-Age 0 cookie matches on name, path, and secure. The dual-emit test already covered name, value, domain, and Max-Age but not path/secure, so a future drift in either the clear or the set builder could silently make the stale host-only cookie undeletable without failing a test. Assert path and secure are equal across the clear and the set (and pin their concrete values).

#BUNYIP-146

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch fix/bunyip-146-op-session-cookie-domain 2026-06-20 17:48:02 +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!177
No description provided.