Skip to content

feat(auth): Sprint 10 — session self-service (list + revoke)#5

Merged
dobrodob merged 1 commit intomainfrom
feat/sprint-10-sessions
Apr 18, 2026
Merged

feat(auth): Sprint 10 — session self-service (list + revoke)#5
dobrodob merged 1 commit intomainfrom
feat/sprint-10-sessions

Conversation

@dobrodob
Copy link
Copy Markdown
Member

Summary

SPEC FR-012: authenticated users can now list their active sessions, revoke any of them, and "log out other devices" — the full session self-service surface.

All Redis infrastructure already existed (Sprint 1 TokenService); this PR adds the three service methods + one controller to expose it.

Endpoints

GET    /me/sessions               → SessionDto[] with `current: boolean` marker
DELETE /me/sessions/:jti          → 204 revoke one (404 if not yours)
POST   /me/sessions/logout-others → revoke all EXCEPT current

TokenService additions

  • listSessionsForUser(userId) — iterates per-user set, fetches metadata, newest-first. Opportunistic SREM of stale jtis (key expired but set still has them) keeps the list fast long-term.
  • revokeSessionForUser(userId, jti)SREM membership check first — a stolen jti can't revoke someone else's session because the set query uses the authenticated userId, not the path param.
  • revokeAllSessionsExcept(userId, keepJti) — preserves the caller's current session so they don't log themselves out mid-click.

Security shape

  • 404 (not 403) on foreign/unknown jtis — can't probe for others' session ids.
  • Self-only paths — all three endpoints use @CurrentUser for userId; no path param can impersonate.
  • Current-session preserved in logout-others — the UX-critical detail that makes the button safe to press.

Tests (10 new, 312 total)

apps/core/test/session.integration.spec.ts:

  • listSessionsForUser: empty / multiple / cross-user isolation / stale cleanup
  • revokeSessionForUser: own OK, cross-user returns false, unknown returns false
  • revokeAllSessionsExcept: keeps current / no-op with 1 / cross-user isolation

Registration issues a session as a side effect (login-on-register); tests clear it post-registration so each assertion reasons about an empty baseline.

Verification

  • npx tsc --noEmit clean
  • npx biome check . clean (139 files)
  • npx vitest run 312/312 (302 + 10 Sprint 10)
  • npm run build:core + build:crdt both clean

Not in scope

  • Device/browser fingerprinting in SessionDto (needs client cooperation + schema change) — Sprint 11+
  • 2FA (FR-013) — Sprint 11
  • General AuditLog beyond ModerationAction — folds into Sprint 11

🤖 Generated with Claude Code

SPEC FR-012: authenticated users can list their active sessions and
revoke any of them, plus a "log out other devices" convenience.

## What's new

Three user-facing endpoints, guarded by `JwtAuthGuard`:

```
GET    /me/sessions             → SessionDto[] — list, newest-first, with
                                   `current: boolean` marker for this device
DELETE /me/sessions/:jti        → 204 revoke one; 404 if jti isn't yours
POST   /me/sessions/logout-others → revoke all EXCEPT the caller's current session
```

## TokenService additions

- `listSessionsForUser(userId)` — iterates `session:user:{userId}` set,
  fetches each session metadata, filters expired, sorts newest-first.
  Opportunistic cleanup: stale jtis (Redis key expired but set still
  has the member) are pruned in the background.
- `revokeSessionForUser(userId, jti)` — revokes + removes from set,
  BUT only if the jti is actually a member of the caller's set. A
  stolen jti alone can't revoke another user's session.
- `revokeAllSessionsExcept(userId, keepJti)` — "logout other devices"
  primitive. Never touches the preserved jti; never touches sessions
  that aren't in the caller's set.

## Security shape

- Membership check before revoke: the SREM check against
  `session:user:{userId}` means a malicious client that guesses a jti
  belonging to someone else gets `false`, not a revocation.
- 404 (not 403) on unknown/foreign jti: can't probe for others'
  session ids from the response shape.
- Self-only: all three endpoints use `@CurrentUser` for the userId —
  no path parameter that could be tampered with.

## Tests (10 new, 312 total)

`apps/core/test/session.integration.spec.ts`:

- listSessionsForUser: empty, multiple entries w/ correct timestamps,
  cross-user isolation, stale-jti opportunistic cleanup
- revokeSessionForUser: own session revokes + verify-fails afterwards,
  cross-user attempt returns false, unknown jti returns false
- revokeAllSessionsExcept: keeps current + revokes others, no-op when
  only current, cross-user isolation

Registration issues a session as a side effect; tests clear it via
`revokeAllSessionsForUser` right after `makeAuthor` so each test
starts from a known zero-session baseline.

## Verification

- `npx tsc --noEmit` clean
- `npx biome check .` clean (139 files)
- `npx vitest run` **312/312** (302 + 10 Sprint 10)
- `npm run build:core` + `build:crdt` both clean

## Not in scope (future sprints)

- Device/browser fingerprinting in session metadata (would need client
  cooperation + schema change) — Sprint 11+
- 2FA (FR-013) — Sprint 11
- Audit log for session operations (currently only info-logged) —
  folds into general AuditLog from Sprint 11

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@dobrodob dobrodob merged commit 0bf74f0 into main Apr 18, 2026
2 checks passed
@dobrodob dobrodob deleted the feat/sprint-10-sessions branch April 18, 2026 13:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant