feat(auth): session hardening - panel, role-change revoke, admin TTL/idle #165

Merged
nrupard merged 2 commits from feat/bunyip-137-session-hardening into main 2026-06-17 19:18:28 +02:00
Owner

What

Implements BUNYIP-137 single sign-on hardening across three self-contained areas. No new tables, no migrations.

1. Active-sessions panel

  • The pre-existing GET /v1/users/me/sessions now returns a current flag per session, set by hashing the caller's presented refresh_token cookie and matching it to the stored row. The stored token_hash is never returned.
  • New POST /v1/users/me/sessions/revoke-others revokes every active session for the caller except the current one. Backed by a new TokenRepository::revoke_other_user_refresh_tokens (scoped to the legacy refresh_tokens table).
  • The single-session revoke (DELETE /v1/users/me/sessions/{id}, with its existing caller-ownership guard) is reused unchanged.
  • bunyip-web adds an "Active Sessions" card to /settings: each device with IP and a relative "last active" time, a "This device" badge on the current session, a per-row Revoke button for non-current sessions, and a "Log out all other devices" action. Rotated cookies are relayed via the existing redirect_cookies path.

2. Revoke sessions on role change

  • update_user_role revokes the target user's sessions (legacy + OIDC families, via the existing revoke_all_user_refresh_tokens) only when the role actually changes. A demoted admin can no longer make admin calls once their access token is revoked or expires. The role-change audit entry records sessions_revoked.

3. Admin session lifetime + idle timeout

  • Admin refresh tokens get a 12-hour absolute ceiling; for admins this deadline is preserved across refresh rotation, so it is a true cap rather than a rolling window. Subscribers keep the 30-day rolling window (behavior unchanged).
  • A 30-minute idle timeout is enforced at refresh: an over-idle admin session is rejected and revoked, forcing re-authentication. Subscribers have no idle limit.
  • The windows live in named policy functions in bunyip-domain (refresh_absolute_ttl, refresh_idle_ttl), not inline literals. The idle decision is a pure function (session_idle_expired) with unit tests.
  • The 12h / 30m values are documented assumptions to revise from real usage.

Tests

Four pure unit tests cover the role-based TTL/idle policy (admin vs subscriber absolute TTL, admin-only idle window, over-idle admin rejected, subscriber never idle-expires). The cross-user revoke guard and revoke-others keep-current behavior are DB-integration paths (no database in the just check lib-test harness) and are enforced in the handler/repository code.

Verification

just check-container (fmt + clippy -D warnings + build + workspace lib tests) passes clean; 191 domain + 19 api + 9 oci + 9 oidc tests green.

Note on the spec

The issue proposed new /v1/auth/sessions endpoints. During implementation I found the API already exposes /v1/users/me/sessions (list + single-revoke), so I extended that surface (added the current flag and the revoke-others endpoint) instead of creating a duplicate API. Deferred follow-up: remember-this-device / TOTP-skip is tracked in BUNYIP-138.

🤖 Generated with Claude Code

## What Implements BUNYIP-137 single sign-on hardening across three self-contained areas. No new tables, no migrations. ## 1. Active-sessions panel - The pre-existing `GET /v1/users/me/sessions` now returns a `current` flag per session, set by hashing the caller's presented `refresh_token` cookie and matching it to the stored row. The stored `token_hash` is never returned. - New `POST /v1/users/me/sessions/revoke-others` revokes every active session for the caller except the current one. Backed by a new `TokenRepository::revoke_other_user_refresh_tokens` (scoped to the legacy `refresh_tokens` table). - The single-session revoke (`DELETE /v1/users/me/sessions/{id}`, with its existing caller-ownership guard) is reused unchanged. - bunyip-web adds an "Active Sessions" card to `/settings`: each device with IP and a relative "last active" time, a "This device" badge on the current session, a per-row Revoke button for non-current sessions, and a "Log out all other devices" action. Rotated cookies are relayed via the existing `redirect_cookies` path. ## 2. Revoke sessions on role change - `update_user_role` revokes the target user's sessions (legacy + OIDC families, via the existing `revoke_all_user_refresh_tokens`) only when the role actually changes. A demoted admin can no longer make admin calls once their access token is revoked or expires. The role-change audit entry records `sessions_revoked`. ## 3. Admin session lifetime + idle timeout - Admin refresh tokens get a 12-hour absolute ceiling; for admins this deadline is preserved across refresh rotation, so it is a true cap rather than a rolling window. Subscribers keep the 30-day rolling window (behavior unchanged). - A 30-minute idle timeout is enforced at refresh: an over-idle admin session is rejected and revoked, forcing re-authentication. Subscribers have no idle limit. - The windows live in named policy functions in `bunyip-domain` (`refresh_absolute_ttl`, `refresh_idle_ttl`), not inline literals. The idle decision is a pure function (`session_idle_expired`) with unit tests. - The 12h / 30m values are documented assumptions to revise from real usage. ## Tests Four pure unit tests cover the role-based TTL/idle policy (admin vs subscriber absolute TTL, admin-only idle window, over-idle admin rejected, subscriber never idle-expires). The cross-user revoke guard and revoke-others keep-current behavior are DB-integration paths (no database in the `just check` lib-test harness) and are enforced in the handler/repository code. ## Verification `just check-container` (fmt + clippy `-D warnings` + build + workspace lib tests) passes clean; 191 domain + 19 api + 9 oci + 9 oidc tests green. ## Note on the spec The issue proposed new `/v1/auth/sessions` endpoints. During implementation I found the API already exposes `/v1/users/me/sessions` (list + single-revoke), so I extended that surface (added the `current` flag and the revoke-others endpoint) instead of creating a duplicate API. Deferred follow-up: remember-this-device / TOTP-skip is tracked in BUNYIP-138. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(auth): session hardening - panel, role-change revoke, admin TTL/idle
All checks were successful
Check / fmt / clippy / build / test (pull_request) Successful in 1m28s
c1f7892c65
Three self-contained session-security improvements (BUNYIP-137).

Active-sessions panel: the existing GET /v1/users/me/sessions now flags the caller's `current` session (hashing the presented refresh-token cookie to match it to its stored row, no hash leaks to the client), and a new POST /v1/users/me/sessions/revoke-others revokes every session except the current one. bunyip-web gains an "Active Sessions" card in /settings listing each device with IP, last-active time, a "This device" badge, a per-row Revoke for non-current sessions, and a "Log out all other devices" action. The single-session DELETE endpoint already existed and is reused as-is.

Revoke on role change: update_user_role now revokes the target user's sessions (legacy + OIDC) when the role actually changes, so a privilege change takes effect immediately instead of lingering for up to the access-token lifetime. The audit log records whether sessions were revoked.

Admin session lifetime: admin refresh tokens get a 12-hour absolute ceiling (preserved across refresh rotation, so it is a true cap) versus the unchanged 30-day rolling window for subscribers, plus a 30-minute idle timeout enforced at refresh (an over-idle admin refresh is rejected and the session revoked). The windows live in named policy functions in bunyip-domain, not inline literals, and the idle decision is a pure, unit-tested function. Subscriber session behavior is unchanged.

Note: the issue spec proposed new /v1/auth/sessions endpoints; the implementation instead extends the pre-existing /v1/users/me/sessions surface, which already covered list + single-revoke, avoiding a duplicate API.

#BUNYIP-137

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
fix(auth): clamp admin refresh deadline to the stricter window on rotation
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 1m27s
8d1dd4a15d
Review hardening for BUNYIP-137. The admin absolute-TTL preservation previously honored the carried deadline verbatim. Clamp it to the stricter of the carried value and a fresh admin window (`existing.min(fresh_deadline)`), so the 12-hour cap can only tighten across refresh rotation and a deadline issued under a looser policy (e.g. a 30-day subscriber window that escaped role-change revocation) can never let an admin session run to the longer deadline. Subscriber behavior is unchanged.

#BUNYIP-137

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
nrupard deleted branch feat/bunyip-137-session-hardening 2026-06-17 19:18:28 +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!165
No description provided.