fix(web): rotate at+jwt when /users/me reports a different role than the cookie's JWT #308

Merged
YousifShkara merged 1 commit from fix/BUNYIP-308-rotate-jwt-on-role-mismatch into main 2026-07-02 08:42:52 +02:00
Owner

A user who just becomes an admin on bunyip could not see any admin data (audit-log history, users list, admin feedback, ...) until they manually logged out and back in. Root cause: AdminUser (crates/bunyip-domain/src/middleware/auth.rs:203-224) gates every /v1/admin/* endpoint on claims.role decoded from the incoming at+jwt, NOT the DB row. bunyip-web's guard / admin_guard read role from the DB-fresh /users/me response, so the admin shell renders happily; but the SAME cookie's at+jwt still carries the pre-promotion role = "subscriber" claim, so every /v1/admin/* fetch on the shell 403s and the pages come back empty.

The intended cure is BUNYIP-137 (revoke refresh tokens on role change) + BUNYIP-145 (fan out SessionRevoked via SSE) forcing the tab to reload through /login. That works when SSE reaches the browser. In practice it does not always: BUNYIP-307 documented that bunyip-web on c-01 was serving the internal docker origin into the SSE subscriber, so the browser blocked the whole connection as Mixed Content and the revoke event was never delivered. Even after BUNYIP-307 restores SSE, an unlucky user can be mid-request when the promotion lands, or on a route without SSE mounted, and still see the same broken-admin state.

Close the gap in bunyip-web/src/auth.rs::authenticate. After /users/me returns 200 with the DB-fresh User, decode the role claim (unverified) from the cookie's at+jwt payload and compare it against the DB role. On mismatch, call /auth/refresh before returning; bunyip-api's refresh path re-fetches the user before minting (services/auth.rs:404-437), so the rotated at+jwt carries the current role. Merge the returned Set-Cookies into the request's forward cookie so the SAME request's downstream /v1/admin/* fetches see the fresh claim, and pass the Set-Cookies up to the browser so the next request also carries the rotated token. Refresh failure is not fatal - fall back to the pre-refresh AuthCtx and let bunyip-api's 401/403 route the user through the normal /login path.

The unverified decode is safe here: bunyip-api's /users/me has already verified the token against its keys, so we know the token is trusted. We only decode the payload as data to detect a stale-role mismatch. A malformed / unparseable token yields None and skips the mismatch check, which preserves the pre-BUNYIP-308 behaviour.

No change to the AdminUser / MemberUser extractors, no change to /v1/users/me, no change to the SSE session_revoked / claims_changed handlers, no change to the docker configs. The whole fix lives inside one BFF function plus a tiny helper.

New base64 dep on bunyip-web (0.21, same version already used across bunyip-domain / bunyip-oidc / bunyip-api). Unit tests cover: admin/subscriber decode from a well-formed cookie, missing access_token cookie, malformed JWT, payload with no role field, role_matches on the promotion + demotion + unknown cases. just check-container clean (224 domain tests pass).

Covers both directions. Promotion: DB=admin, JWT=subscriber -> rotate, fresh admin JWT lands, admin shell fetches succeed. Demotion: DB=subscriber, JWT=admin -> rotate. If the demoted user's session was revoked by BUNYIP-137, refresh fails and the user's next click lands them at /login (correct semantics).

#BUNYIP-308

A user who just becomes an admin on bunyip could not see any admin data (audit-log history, users list, admin feedback, ...) until they manually logged out and back in. Root cause: `AdminUser` (crates/bunyip-domain/src/middleware/auth.rs:203-224) gates every `/v1/admin/*` endpoint on `claims.role` decoded from the incoming at+jwt, NOT the DB row. bunyip-web's `guard` / `admin_guard` read `role` from the DB-fresh `/users/me` response, so the admin shell renders happily; but the SAME cookie's at+jwt still carries the pre-promotion `role = "subscriber"` claim, so every `/v1/admin/*` fetch on the shell 403s and the pages come back empty. The intended cure is BUNYIP-137 (revoke refresh tokens on role change) + BUNYIP-145 (fan out `SessionRevoked` via SSE) forcing the tab to reload through /login. That works when SSE reaches the browser. In practice it does not always: BUNYIP-307 documented that bunyip-web on c-01 was serving the internal docker origin into the SSE subscriber, so the browser blocked the whole connection as Mixed Content and the revoke event was never delivered. Even after BUNYIP-307 restores SSE, an unlucky user can be mid-request when the promotion lands, or on a route without SSE mounted, and still see the same broken-admin state. Close the gap in `bunyip-web/src/auth.rs::authenticate`. After `/users/me` returns 200 with the DB-fresh User, decode the `role` claim (unverified) from the cookie's at+jwt payload and compare it against the DB role. On mismatch, call `/auth/refresh` before returning; bunyip-api's refresh path re-fetches the user before minting (services/auth.rs:404-437), so the rotated at+jwt carries the current role. Merge the returned Set-Cookies into the request's forward cookie so the SAME request's downstream `/v1/admin/*` fetches see the fresh claim, and pass the Set-Cookies up to the browser so the next request also carries the rotated token. Refresh failure is not fatal - fall back to the pre-refresh AuthCtx and let bunyip-api's 401/403 route the user through the normal /login path. The unverified decode is safe here: bunyip-api's `/users/me` has already verified the token against its keys, so we know the token is trusted. We only decode the payload as data to detect a stale-role mismatch. A malformed / unparseable token yields None and skips the mismatch check, which preserves the pre-BUNYIP-308 behaviour. No change to the `AdminUser` / `MemberUser` extractors, no change to `/v1/users/me`, no change to the SSE `session_revoked` / `claims_changed` handlers, no change to the docker configs. The whole fix lives inside one BFF function plus a tiny helper. New base64 dep on bunyip-web (0.21, same version already used across bunyip-domain / bunyip-oidc / bunyip-api). Unit tests cover: admin/subscriber decode from a well-formed cookie, missing `access_token` cookie, malformed JWT, payload with no `role` field, role_matches on the promotion + demotion + unknown cases. `just check-container` clean (224 domain tests pass). Covers both directions. Promotion: DB=admin, JWT=subscriber -> rotate, fresh admin JWT lands, admin shell fetches succeed. Demotion: DB=subscriber, JWT=admin -> rotate. If the demoted user's session was revoked by BUNYIP-137, refresh fails and the user's next click lands them at /login (correct semantics). #BUNYIP-308
fix(web): rotate at+jwt when /users/me reports a different role than the cookie's JWT
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 32s
Check / fmt + clippy + build + tests (pull_request) Successful in 30m6s
Create release / Create release from merged PR (pull_request) Has been skipped
0f9181b23b
A user who just becomes an admin on bunyip could not see any admin data (audit-log history, users list, admin feedback, ...) until they manually logged out and back in. Root cause: `AdminUser` (crates/bunyip-domain/src/middleware/auth.rs:203-224) gates every `/v1/admin/*` endpoint on `claims.role` decoded from the incoming at+jwt, NOT the DB row. bunyip-web's `guard` / `admin_guard` read `role` from the DB-fresh `/users/me` response, so the admin shell renders happily; but the SAME cookie's at+jwt still carries the pre-promotion `role = "subscriber"` claim, so every `/v1/admin/*` fetch on the shell 403s and the pages come back empty.

The intended cure is BUNYIP-137 (revoke refresh tokens on role change) + BUNYIP-145 (fan out `SessionRevoked` via SSE) forcing the tab to reload through /login. That works when SSE reaches the browser. In practice it does not always: BUNYIP-307 documented that bunyip-web on c-01 was serving the internal docker origin into the SSE subscriber, so the browser blocked the whole connection as Mixed Content and the revoke event was never delivered. Even after BUNYIP-307 restores SSE, an unlucky user can be mid-request when the promotion lands, or on a route without SSE mounted, and still see the same broken-admin state.

Close the gap in `bunyip-web/src/auth.rs::authenticate`. After `/users/me` returns 200 with the DB-fresh User, decode the `role` claim (unverified) from the cookie's at+jwt payload and compare it against the DB role. On mismatch, call `/auth/refresh` before returning; bunyip-api's refresh path re-fetches the user before minting (services/auth.rs:404-437), so the rotated at+jwt carries the current role. Merge the returned Set-Cookies into the request's forward cookie so the SAME request's downstream `/v1/admin/*` fetches see the fresh claim, and pass the Set-Cookies up to the browser so the next request also carries the rotated token. Refresh failure is not fatal - fall back to the pre-refresh AuthCtx and let bunyip-api's 401/403 route the user through the normal /login path.

The unverified decode is safe here: bunyip-api's `/users/me` has already verified the token against its keys, so we know the token is trusted. We only decode the payload as data to detect a stale-role mismatch. A malformed / unparseable token yields None and skips the mismatch check, which preserves the pre-BUNYIP-308 behaviour.

No change to the `AdminUser` / `MemberUser` extractors, no change to `/v1/users/me`, no change to the SSE `session_revoked` / `claims_changed` handlers, no change to the docker configs. The whole fix lives inside one BFF function plus a tiny helper.

New base64 dep on bunyip-web (0.21, same version already used across bunyip-domain / bunyip-oidc / bunyip-api). Unit tests cover: admin/subscriber decode from a well-formed cookie, missing `access_token` cookie, malformed JWT, payload with no `role` field, role_matches on the promotion + demotion + unknown cases. `just check-container` clean (224 domain tests pass).

Covers both directions. Promotion: DB=admin, JWT=subscriber -> rotate, fresh admin JWT lands, admin shell fetches succeed. Demotion: DB=subscriber, JWT=admin -> rotate. If the demoted user's session was revoked by BUNYIP-137, refresh fails and the user's next click lands them at /login (correct semantics).

#BUNYIP-308
YousifShkara deleted branch fix/BUNYIP-308-rotate-jwt-on-role-mismatch 2026-07-02 08:42:53 +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!308
No description provided.