Skip to content

feat(auth): session management: list and revoke active sessions per user - #143

Merged
afeezorobsco-cyber merged 6 commits into
DocForum:mainfrom
Oyinkans0la12:feat/session-management-list-and-revoke
Oct 20, 2026
Merged

afeezorobsco-cyber merged 6 commits into
DocForum:mainfrom
Oyinkans0la12:feat/session-management-list-and-revoke

Conversation

@Oyinkans0la12

Copy link
Copy Markdown
Contributor

What changed

Implements session management from #38: a persisted RefreshSession row per
refresh-token family (one per login), plus GET /auth/sessions,
DELETE /auth/sessions/:id, DELETE /auth/sessions (sign out everywhere
else), and admin DELETE /admin/users/:id/sessions.

Closes #38

Why

A patient who logged in on a shared clinic computer had no way to sign that
device out. Revocation in #18 gives the backend a kill switch; this gives users
and admins a way to use it, which is what makes revocation worth having.

Implementation notes

  • Built on a real revocation store, not a parallel one. [Phase 8] Session management: list and revoke active sessions per user #38 says to build
    on [Phase 8] JWT refresh-token revocation #18's revocation store — [Phase 8] JWT refresh-token revocation #18 has not landed, so this PR is that store, in
    the auth module where tokens are minted. The refresh JWT carries sid; on
    POST /auth/refresh the RefreshSession row is looked up and a missing or
    revoked session is refused with the same generic 401 as any bad token. That
    is the moment revocation has to bite, because that's the operation that mints
    new tokens.
  • One row per family, not per refresh. Rotation keeps the same session, so
    "list my sessions" keeps meaning logins rather than growing a row per
    refresh. Rotation still issues a brand-new refresh token.
  • No write hot-spot. lastUsedAt is written from refresh only (explicitly
    required by the issue) — access-token-authenticated requests never touch the
    table. Access tokens stay stateless, so no session lookup on any protected
    route.
  • Ownership is the query. Revocation is a single
    updateMany WHERE id = ? AND userId = ? AND revokedAt IS NULL; another user's
    session id updates zero rows and returns 404 — no existence signal, and no
    branch above the query to get wrong.
  • Revoking the current session clears the cookie in the same response (and
    revoking a non-current one deliberately does not).
  • Behavior change, flagged: tokens minted before this change carry no sid and
    are rejected, so the migration logs existing sessions out once. That's the
    honest trade for actually having a revocation store rather than a decorative
    table.

Schema change (proposed)

  • RefreshSession: id, userId, createdAt, lastUsedAt, revokedAt (nullable), userAgent (nullable), ip (nullable) — ARCHITECTURE.md §4.9.

Migration: backend/prisma/migrations/20261015182444_add_refresh_sessions/
generated with scripts/gen-migration.mjs (real Postgres diff).

Coarse IP + user agent are stored because that's what makes "that other device"
recognizable; nothing beyond what the request already carried.

Assumptions / gaps flagged, not silently resolved

  • Access tokens are not revoked. A revoked session's access token keeps
    working for the remainder of its 15-minute TTL. Making that immediate needs a
    per-request revocation check (or much shorter access tokens) and is a real
    design decision, not a detail — recorded in §6.1.4 and
    ARCHITECTURE_ESSENTIALS.md rather than quietly claimed as "instant".
  • POST /auth/logout still only clears the cookie; it doesn't revoke the
    session. Changing that is docforum-web's call to make alongside the UI, so
    it's flagged as a known gap instead of silently redefined here.
  • No index/cleanup job for long-dead session rows yet (fine at current scale,
    not at real scale).

Docs updated in the same PR

ARCHITECTURE.md (§3 module note, §4.9 model, §6.1.4 revocation gap now
addressed with its residual note), ARCHITECTURE_ESSENTIALS.md (data model map

  • three new known gaps), docs/api/README.md (all endpoints, sessionId in the
    session response, the refresh/sid contract), ROADMAP.md (Phase 8 item +
    changelog).

Tests

npm run typecheck and npm run build clean. npm run test → 14 passed.
npm run test:integration → 9 passed (1 existing booking + 8 new).

Integration (tests/integration/sessions.test.mts, real Postgres, real
cookies) covers: a session row per login; a user's list contains only their own
sessions and exactly one current, tied to the request's cookie; refresh
rotates tokens without forking the session and updates lastUsedAt;
one user cannot revoke (404) or see another's session, and the target session
survives; a revoked session's refresh is rejected immediately while the
caller's own keeps working; revoking the current session clears the cookie and
kills that refresh; "revoke all others" kills every other session and keeps the
current one; and admin revocation (403 for a non-admin, working for an admin,
with the target's refresh dead and their session list empty afterwards).

Test Files  2 passed (2)
     Tests  9 passed (9)   # npm run test:integration
Test Files  3 passed (3)
     Tests  14 passed (14)   # npm run test

Cross-PR note

ARCHITECTURE.md §4, auth/services/tokens.ts and
auth/services/index.ts are also touched by the open password-reset (#140) and
2FA (#142) PRs — whichever lands second needs a trivial rebase and §4
subsection renumbering, called out rather than left as a surprise.

Adds a RefreshSession row per refresh-token family (one per login), with
the refresh JWT carrying `sid`. Refresh is refused once the row is
revoked, so revocation is enforced exactly where new tokens are minted.
This is the revocation store issue DocForum#18 asked for, built in the auth
module rather than as a parallel mechanism.

Endpoints: GET /auth/sessions (marks the caller's current session),
DELETE /auth/sessions/:id, DELETE /auth/sessions (sign out everywhere
else), and admin DELETE /admin/users/:id/sessions mounted at /admin.
Revoking the current session clears the refresh cookie in the same
response; ownership is part of the revoking UPDATE's WHERE clause rather
than a check above it, so another user's session id is simply a 404.

lastUsedAt is written on refresh only, so authenticated requests don't
add a write hot-spot. Refresh rotates tokens but keeps the same session
family. Tokens minted before this change carry no sid and are rejected —
the migration logs existing sessions out once, which is the honest trade
for having a revocation store at all.

Residual gap flagged, not hidden: access tokens stay stateless, so a
revoked session's access token works for the rest of its 15-min TTL.

Docs updated in the same change: ARCHITECTURE.md §3/§4.9/§6.1.4,
ARCHITECTURE_ESSENTIALS.md, docs/api/README.md, ROADMAP.md.

Refs DocForum#38
@drips-wave

drips-wave Bot commented Oct 15, 2026

Copy link
Copy Markdown

@Oyinkans0la12 Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

CI is failing on this PR. main itself is green, so the cause is in this branch:

prisma:generate fails: Type "RefreshSession" is neither a built-in type, nor refers to another model. The model RefreshSession block was dropped from backend/prisma/schema.prisma when you merged main in (9025b74), but the field that uses it is still there. Re-add the model block.

Please push a fix and CI will re-run. Before pushing, run npm run prisma:generate && npm run typecheck && npm test && npm run test:integration in backend/.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oyinkans0la12

CI fails at prisma generate (P1012): Type "RefreshSession" is neither a built-in type, nor refers to another model. The branch adds the User relation field and a migration, but schema.prisma has no model RefreshSession { … } block. Please add the model so it matches your migration. main has also moved to Prisma 7 (#151), so rebase onto the latest main first, then run npm run prisma:generate && npm run typecheck locally.

@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oyinkans0la12 Thanks for the PR. It can't be merged yet. To unblock it:

CI is failing. Here's what broke:

Backend (typecheck + test + build):

prisma generate: P1012 Type "RefreshSession" is neither a built-in type, nor refers to another model (prisma/schema.prisma:115) — the model definition is missing

Please fix these errors and run the same steps locally before pushing (npm run typecheck && npm test && npm run build, or cargo test for contracts).

Merge conflicts: this branch conflicts with main. Please rebase onto the latest main (git fetch upstream && git rebase upstream/main), resolve the conflicts (often ROADMAP.md, ARCHITECTURE*.md or prisma/schema.prisma), and force-push.

Push to this same branch and CI will re-run. Once it's green and conflict-free, it'll be reviewed for merge.

@github-actions github-actions Bot added the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 20, 2026
@github-actions github-actions Bot removed the needs-rebase Has merge conflicts with main; the author needs to rebase label Oct 20, 2026
@afeezorobsco-cyber

Copy link
Copy Markdown
Contributor

@Oyinkans0la12 CI still fails at prisma generate:

error P1012: Type "RefreshSession" is neither a built-in type, nor refers to another model, composite type, or enum.

User references RefreshSession[] but the model RefreshSession { ... } definition isn't in backend/prisma/schema.prisma (it was probably lost when resolving the conflict). Add it back along with its migration, then run npm run prisma:generate && npm test.

Push to this branch and CI will re-run. Once it's green it can be merged.

The main merge resolution dropped the RefreshSession model, the /auth
sessions routes and admin router, the sid claim and session tracking in
signup/login/refresh, leaving Prisma unable to generate and the session
controllers calling services that were not exported. Also send the CSRF
token on refresh in the sessions integration test, matching the current
/auth/refresh contract.

@afeezorobsco-cyber afeezorobsco-cyber left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed: no malicious code found, CI is green. Merging.

@afeezorobsco-cyber
afeezorobsco-cyber merged commit c65217b into DocForum:main Oct 20, 2026
2 checks passed
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.

[Phase 8] Session management: list and revoke active sessions per user

2 participants