From fc81dbab76c8dcd37ddae62df1008889a5dfcff0 Mon Sep 17 00:00:00 2001 From: Oyinkans0la12 <147610565+Oyinkans0la12@users.noreply.github.com> Date: Thu, 15 Oct 2026 18:28:27 +0000 Subject: [PATCH 1/2] feat(auth): session management: list and revoke active sessions per user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 #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 #38 --- ARCHITECTURE.md | 21 +- ARCHITECTURE_ESSENTIALS.md | 4 +- ROADMAP.md | 16 +- .../migration.sql | 22 ++ backend/prisma/schema.prisma | 28 ++ backend/src/app.ts | 5 +- backend/src/modules/auth/controllers/index.ts | 68 ++++- .../src/modules/auth/repositories/index.ts | 46 +++ backend/src/modules/auth/routes/index.ts | 15 + backend/src/modules/auth/services/index.ts | 79 ++++- backend/src/modules/auth/services/sessions.ts | 102 +++++++ backend/src/modules/auth/services/tokens.ts | 4 + backend/tests/integration/sessions.test.mts | 269 ++++++++++++++++++ docs/api/README.md | 29 +- 14 files changed, 682 insertions(+), 26 deletions(-) create mode 100644 backend/prisma/migrations/20261015182442_add_refresh_sessions/migration.sql create mode 100644 backend/src/modules/auth/services/sessions.ts create mode 100644 backend/tests/integration/sessions.test.mts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index c98333db..0f309d1a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -40,7 +40,7 @@ See `PRD.md` for product scope and `ARCHITECTURE_ESSENTIALS.md` for the quick-re Each module under `backend/src/modules//` owns its own `routes/`, `controllers/`, `services/`, `repositories/`. Cross-module access happens through a module's exported service interface only — **never reach into another module's repository directly.** This is the internal seam that would let us extract a module into its own service later without a rewrite. -- `auth` — signup/login, tokens, role checks. +- `auth` — signup/login, tokens, role checks, refresh-session management (its admin surface is mounted at `/admin`, not `/auth`). - `users` — shared user profile fields common to all roles. - `patients` — patient-specific profile data. - `doctors` — doctor profile, specialties, verification status. @@ -167,6 +167,23 @@ Notification id, user_id, type, payload (jsonb), read_at (nullable), created_at ``` +### 4.9 Auth hardening (Phase 8) +``` +RefreshSession (issue #38 — the revocation store issue #18 wanted) + id, user_id (FK -> User, cascade) + created_at, last_used_at, revoked_at (nullable) + user_agent (nullable), ip (nullable, coarse) +``` +Design notes: one row per refresh-token **family**, i.e. per login. The refresh +JWT carries `sid`; `POST /auth/refresh` looks the row up and refuses a missing +or revoked session, so revocation is enforced exactly where new tokens are +minted. Access tokens stay stateless and un-revoked (≤15 min of residual life) +— a deliberate trade recorded in `ARCHITECTURE_ESSENTIALS.md` known gaps, not +an oversight. `last_used_at` is written on refresh only, so authenticated +requests don't write to this table. Collecting a coarse IP/user agent is what +makes "sign out that other device" usable; nothing beyond what the request +already carried is stored. + ## 5. Key flows (sequence-level, not code) ### 5.1 Booking (concurrency-safe) @@ -193,7 +210,7 @@ Notification 1. Naive slot booking without the DB-level conditional update in §5.1 — the most likely first bug filed against this repo. **Resolved 2026-09-14:** implemented exactly as designed in §5.1 — see `backend/src/modules/availability/repositories/index.ts` (`claimOpenSlot`) and `backend/src/modules/appointments/services/index.ts` (`bookSlot`, the transaction wrapping it). Integration-tested with real concurrency in `backend/tests/integration/booking.test.mts`. 2. `Referral.target_doctor_id` left nullable with no fallback assignment logic — referrals to "a specialty" with no specific doctor need a real routing rule (round robin? load-based?) or they'll pile up unassigned. **Not designed yet — flag as open architecture gap.** 3. Polymorphic `FulfillmentRecord.order_id` (§4.7) — polymorphic FKs are a known footgun for referential integrity in Postgres. Needs either two separate tables (`PrescriptionFulfillment`, `LabOrderFulfillment`) or a check-constraint + application-level guard. **Current schema sketch is a simplification that should be revisited before implementation**, not treated as final. -4. JWT refresh rotation without a revocation list — a stolen refresh token has no clean kill switch until this is added. +4. JWT refresh rotation without a revocation list — a stolen refresh token has no clean kill switch until this is added. **Addressed 2026-10-15 (issue #38):** refresh tokens now carry a `sid` referencing a `RefreshSession` row (§4.9), so refresh is refused once the session is revoked — which is what makes "list my sessions / sign out that device" and admin revocation real. Residual gap: access tokens are still unrevoked, so a revoked session's access token works for up to its 15-minute TTL. ### 6.2 What edge cases are we missing (architecture-level)? 1. What happens to `AvailabilitySlot.status = 'held'` if a booking transaction fails/times out mid-flight — is there a TTL to release the hold? (Needed or slots leak into permanent limbo.) diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 257f27bd..e7ff90a4 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -19,7 +19,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc 6. Every module = `routes/ controllers/ services/ repositories/`. Keep that shape. ## Data model map (see ARCHITECTURE.md §4 for full field lists) -`User` → `PatientProfile | DoctorProfile | FacilityProfile` +`User` → `PatientProfile | DoctorProfile | FacilityProfile` ; `User` → `RefreshSession` (one row per login/refresh-token family; `sid` in the refresh JWT is checked at refresh — Phase 8 #38) `DoctorProfile` —< `DoctorSpecialty` >— `Specialty` `AvailabilitySlot` (doctor's calendar) → `Appointment` → `IntakeForm`, `Consultation` `Consultation` → (outcome=referred) → `Referral` (snapshot!) → new `Appointment` @@ -39,6 +39,8 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc - No expiry/TTL job for held slots or pending referrals — currently just an enum value with no worker. - Polymorphic `FulfillmentRecord.order_id` is a sketch, not final — may need splitting into two tables. - No `AuditEvent` table yet, despite PRD requiring auditability — needed before real patient data. +- Refresh-session revocation is enforced at refresh only. Access tokens are stateless, so a revoked session's access token still works for the rest of its 15-min TTL — acceptable for now, but it means "revoke immediately" is only true for refreshes. +- `POST /auth/logout` clears the cookie but does not revoke the session server-side (the session stays listed until it's revoked or expires). Anyone wiring logout in the web app should call `DELETE /auth/sessions/:id` too. - One doctor = one calendar assumed; multi-facility doctors not modeled. ## Overengineering guardrails (things NOT to build yet) diff --git a/ROADMAP.md b/ROADMAP.md index bf51c734..d084ddbf 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,7 +107,8 @@ for status and issue links. - [ ] ~~Shared: auth screens (signup/login per role).~~ → already built, `docforum-web` Phase W1. ## Phase 8 — Hardening (safety, audit, security) -**Status: Not started. Three of five items scoped into issues #18–#20 (independent of Phases 3–7); the other two remain blocked.** +**Status: In progress — session management (#38) landed, plus TOTP 2FA (#36) and password reset (#33) opened as sibling hardening issues since. The two original blocked items still need Phases 3/4/5.** +- [x] Session management: list and revoke active sessions per user — a `RefreshSession` row per refresh-token family (`sid` claim), `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`. This is the revocation store #18 asked for, built in the auth module rather than as a parallel one: refresh is refused once a session is revoked, and `last_used_at` is written on refresh only (no write hot-spot per request). Ownership is part of the revoking UPDATE, not a branch above it. Closes [issue #38](https://github.com/DocForum/docforum-core/issues/38). Residual gap: access tokens stay stateless, so a revoked session's access token lives out its ≤15-min TTL — recorded in ARCHITECTURE_ESSENTIALS.md/§6.1.4. - [ ] `AuditEvent` table + write path for referral/prescription/lab-order state transitions (ARCHITECTURE.md §6.2.4 — flagged as needed before real patient data). Still blocked — needs the Phase 3/4 models (referral, prescription, lab order) to exist first. Not yet scoped into an issue. - [ ] JWT refresh revocation list (ARCHITECTURE.md §6.1.4). Tracked as [issue #18](https://github.com/DocForum/docforum-core/issues/18) (Medium, 150 pts) — unblocked now, operates on Phase 1's auth module. - [ ] Held-slot TTL/release job (ARCHITECTURE.md §6.2.1). Tracked as [issue #19](https://github.com/DocForum/docforum-core/issues/19) (Medium, 150 pts) — unblocked now, operates on Phase 1's `AvailabilitySlot`. @@ -341,3 +342,16 @@ for status and issue links. yet Accepted. Added to the docs site. - 2026-10-09 — Opened issue #32: upgrade to escrow SDK 0.3.0 and the current testnet contract. +- 2026-10-15 — Landed Phase 8's session management (issue #38): + `GET /auth/sessions`, `DELETE /auth/sessions/:id`, + `DELETE /auth/sessions` (all others) and admin + `DELETE /admin/users/:id/sessions`, backed by a new `RefreshSession` + table — one row per refresh-token family, with `sid` in the refresh JWT + and revocation enforced at refresh. This is the revocation store + issue #18 wanted, implemented in the auth module rather than as a + parallel mechanism. `last_used_at` is written on refresh only, so no + per-request write hot-spot; ownership scoping is part of the revoking + UPDATE. ARCHITECTURE.md §3/§4.9/§6.1.4, ARCHITECTURE_ESSENTIALS.md + (data model map + known gaps) and docs/api/README.md updated. Residual + gap flagged: access tokens are stateless, so revocation only bites at + refresh (≤15 min of residual access-token life). diff --git a/backend/prisma/migrations/20261015182442_add_refresh_sessions/migration.sql b/backend/prisma/migrations/20261015182442_add_refresh_sessions/migration.sql new file mode 100644 index 00000000..1d8742d1 --- /dev/null +++ b/backend/prisma/migrations/20261015182442_add_refresh_sessions/migration.sql @@ -0,0 +1,22 @@ +-- CreateTable +CREATE TABLE "RefreshSession" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastUsedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "revokedAt" TIMESTAMP(3), + "userAgent" TEXT, + "ip" TEXT, + + CONSTRAINT "RefreshSession_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RefreshSession_userId_idx" ON "RefreshSession"("userId"); + +-- CreateIndex +CREATE INDEX "RefreshSession_userId_revokedAt_idx" ON "RefreshSession"("userId", "revokedAt"); + +-- AddForeignKey +ALTER TABLE "RefreshSession" ADD CONSTRAINT "RefreshSession_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index e0239b77..7462fc0b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -106,10 +106,38 @@ model User { patientProfile PatientProfile? doctorProfile DoctorProfile? facilityProfile FacilityProfile? + refreshSessions RefreshSession[] @@index([role]) } +// One row per refresh-token *family*, i.e. per login. This is the +// revocation store session management needs (Phase 8 #38) and the shape +// issue #18's refresh-token revocation wanted: the refresh JWT carries +// `sid`, and a refresh is only honoured while the row exists with +// `revokedAt` still null. +// +// Collecting a coarse IP and user agent here is deliberate — it is what +// makes "sign out that other device" usable. Nothing clinical or +// identifying beyond what the request itself already carried. +model RefreshSession { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + // Updated on refresh only — never on every access-token-authenticated + // request, so this doesn't become a write hot-spot on the User row. + lastUsedAt DateTime @default(now()) + revokedAt DateTime? + + userAgent String? + ip String? + + @@index([userId]) + @@index([userId, revokedAt]) +} + model PatientProfile { id String @id @default(uuid()) userId String @unique diff --git a/backend/src/app.ts b/backend/src/app.ts index eec58eea..1aa751c1 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -3,7 +3,7 @@ import cors from 'cors'; import cookieParser from 'cookie-parser'; import { config } from './config'; import { errorHandler } from './middleware/error-handler'; -import { authRouter } from './modules/auth/routes'; +import { adminAuthRouter, authRouter } from './modules/auth/routes'; import { doctorsRouter } from './modules/doctors/routes'; import { availabilityRouter } from './modules/availability/routes'; import { appointmentsRouter } from './modules/appointments/routes'; @@ -19,6 +19,9 @@ export function createApp() { app.get('/health', (_req, res) => res.status(200).json({ status: 'ok' })); app.use('/auth', authRouter); + // Admin surface. Currently only the auth module's session administration + // (Phase 8 #38) — other modules' admin routes can hang off the same mount. + app.use('/admin', adminAuthRouter); app.use('/doctors', doctorsRouter); app.use('/availability', availabilityRouter); app.use('/appointments', appointmentsRouter); diff --git a/backend/src/modules/auth/controllers/index.ts b/backend/src/modules/auth/controllers/index.ts index ea1c07b2..cf2b54e4 100644 --- a/backend/src/modules/auth/controllers/index.ts +++ b/backend/src/modules/auth/controllers/index.ts @@ -2,6 +2,7 @@ import type { Request, Response } from 'express'; import * as authService from '../services'; import { loginSchema, signupSchema } from '../validation'; import { HttpError } from '../../../http-error'; +import { getParam } from '../../../request-params'; const REFRESH_COOKIE = 'docforum_refresh'; @@ -33,25 +34,31 @@ function setRefreshCookie(res: Response, token: string) { }); } +/** Coarse request context for the session record — what makes "that other + * device" recognizable in `GET /auth/sessions`. */ +function sessionContext(req: Request): authService.SessionContext { + return { ip: req.ip, userAgent: req.headers['user-agent'] }; +} + function sessionResponse(res: Response, session: authService.AuthSession) { setRefreshCookie(res, session.refreshToken); - res.status(200).json({ user: session.user, accessToken: session.accessToken }); + res.status(200).json({ user: session.user, accessToken: session.accessToken, sessionId: session.sessionId }); } export async function signup(req: Request, res: Response) { const input = signupSchema.parse(req.body); - const session = await authService.signup(input); + const session = await authService.signup(input, sessionContext(req)); sessionResponse(res, session); } export async function login(req: Request, res: Response) { const input = loginSchema.parse(req.body); - const session = await authService.login(input); + const session = await authService.login(input, sessionContext(req)); sessionResponse(res, session); } export async function refresh(req: Request, res: Response) { - const token = (req.cookies as Record | undefined)?.[REFRESH_COOKIE]; + const token = refreshCookieValue(req); if (!token) throw new HttpError(401, 'No refresh token provided.'); const session = await authService.refresh(token); @@ -62,3 +69,56 @@ export function logout(_req: Request, res: Response) { res.clearCookie(REFRESH_COOKIE, REFRESH_COOKIE_OPTIONS); res.status(204).send(); } + +// --- Session management (Phase 8 #38) ----------------------------------- + +function refreshCookieValue(req: Request): string | undefined { + return (req.cookies as Record | undefined)?.[REFRESH_COOKIE]; +} + +function requireUserId(req: Request): string { + if (!req.user) throw new HttpError(401, 'Authentication required.'); + return req.user.id; +} + +export async function listSessions(req: Request, res: Response) { + const sessions = await authService.listSessions({ + userId: requireUserId(req), + currentSessionId: authService.sessionIdFromRefreshToken(refreshCookieValue(req)), + }); + res.status(200).json(sessions); +} + +/** Revokes one of the caller's own sessions. A session id belonging to + * someone else is simply not found (404) — scoped update, and no signal + * about whether that id exists. */ +export async function revokeSession(req: Request, res: Response) { + const currentSessionId = authService.sessionIdFromRefreshToken(refreshCookieValue(req)); + const result = await authService.revokeSession({ + userId: requireUserId(req), + sessionId: getParam(req, 'id'), + currentSessionId, + }); + + if (!result.revoked) throw new HttpError(404, 'Session not found.'); + // Revoking the session you're using has to clear the cookie in the same + // response, or the browser keeps sending a dead refresh token. + if (result.wasCurrent) res.clearCookie(REFRESH_COOKIE, REFRESH_COOKIE_OPTIONS); + res.status(204).send(); +} + +/** "Sign out everywhere else" — keeps the caller's current session. */ +export async function revokeOtherSessions(req: Request, res: Response) { + const revoked = await authService.revokeOtherSessions({ + userId: requireUserId(req), + currentSessionId: authService.sessionIdFromRefreshToken(refreshCookieValue(req)), + }); + res.status(200).json({ revoked }); +} + +/** Admin: revoke every session a given user has. Mounted under /admin, not + * /auth — it acts on someone else's sessions. */ +export async function revokeUserSessions(req: Request, res: Response) { + const revoked = await authService.revokeAllUserSessions(getParam(req, 'id')); + res.status(200).json({ revoked }); +} diff --git a/backend/src/modules/auth/repositories/index.ts b/backend/src/modules/auth/repositories/index.ts index c59338dd..634c094f 100644 --- a/backend/src/modules/auth/repositories/index.ts +++ b/backend/src/modules/auth/repositories/index.ts @@ -13,6 +13,52 @@ export function findUserById(id: string) { return prisma.user.findUnique({ where: { id } }); } +// --- Refresh sessions (Phase 8 #38) ------------------------------------- + +export function createRefreshSession(input: { userId: string; userAgent?: string; ip?: string }) { + return prisma.refreshSession.create({ + data: { userId: input.userId, userAgent: input.userAgent, ip: input.ip }, + }); +} + +export function findRefreshSessionById(id: string) { + return prisma.refreshSession.findUnique({ where: { id } }); +} + +/** The only write a session takes outside of creation/revocation — called + * from refresh, not from every authenticated request. */ +export function touchRefreshSession(id: string) { + return prisma.refreshSession.update({ where: { id }, data: { lastUsedAt: new Date() } }); +} + +export function listRefreshSessions(userId: string) { + return prisma.refreshSession.findMany({ + where: { userId, revokedAt: null }, + orderBy: { lastUsedAt: 'desc' }, + }); +} + +/** Scoped by `userId` in the WHERE clause rather than checked afterwards — + * revoking someone else's session is a 0-row update, not a logic branch + * that could be got wrong. */ +export function revokeRefreshSession(id: string, userId: string) { + return prisma.refreshSession.updateMany({ + where: { id, userId, revokedAt: null }, + data: { revokedAt: new Date() }, + }); +} + +export function revokeRefreshSessionsForUser(input: { userId: string; exceptSessionId?: string }) { + return prisma.refreshSession.updateMany({ + where: { + userId: input.userId, + revokedAt: null, + ...(input.exceptSessionId ? { id: { not: input.exceptSessionId } } : {}), + }, + data: { revokedAt: new Date() }, + }); +} + export interface CreateSelfServeUserInput { email: string; phone?: string; diff --git a/backend/src/modules/auth/routes/index.ts b/backend/src/modules/auth/routes/index.ts index c048acf9..84d44051 100644 --- a/backend/src/modules/auth/routes/index.ts +++ b/backend/src/modules/auth/routes/index.ts @@ -1,5 +1,6 @@ import { Router } from 'express'; import * as authController from '../controllers'; +import { authenticate, requireRole } from '../../../middleware/auth'; export const authRouter = Router(); @@ -7,3 +8,17 @@ authRouter.post('/signup', authController.signup); authRouter.post('/login', authController.login); authRouter.post('/refresh', authController.refresh); authRouter.post('/logout', authController.logout); + +// Session management (Phase 8 #38). Note the ordering: `DELETE /sessions` +// (revoke all others) is registered before `DELETE /sessions/:id` — Express +// matches in registration order, and `/sessions` must not be read as an id. +authRouter.get('/sessions', authenticate, authController.listSessions); +authRouter.delete('/sessions', authenticate, authController.revokeOtherSessions); +authRouter.delete('/sessions/:id', authenticate, authController.revokeSession); + +/** Admin routes for the auth module, mounted at `/admin` in app.ts. Session + * administration acts on *another* user, so it doesn't belong under + * `/auth`. */ +export const adminAuthRouter = Router(); + +adminAuthRouter.delete('/users/:id/sessions', authenticate, requireRole('admin'), authController.revokeUserSessions); diff --git a/backend/src/modules/auth/services/index.ts b/backend/src/modules/auth/services/index.ts index eaf3b5e5..c21789e0 100644 --- a/backend/src/modules/auth/services/index.ts +++ b/backend/src/modules/auth/services/index.ts @@ -5,6 +5,7 @@ import { HttpError } from '../../../http-error'; import { createSelfServeUser, findUserByEmail, findUserById } from '../repositories'; import { hashPassword, verifyPassword } from './password'; import { signAccessToken, signRefreshToken, verifyRefreshToken } from './tokens'; +import { assertSessionUsable, recordSessionUse, startSession, type SessionContext } from './sessions'; import type { Role } from '@prisma/client'; // Re-exported so middleware/auth.ts (shared infra, not a peer module) can @@ -13,6 +14,18 @@ import type { Role } from '@prisma/client'; export { verifyAccessToken } from './tokens'; export type { AccessTokenPayload } from './tokens'; +export { + assertSessionUsable, + listSessions, + recordSessionUse, + revokeAllUserSessions, + revokeOtherSessions, + revokeSession, + startSession, + type SessionContext, + type SessionSummary, +} from './sessions'; + export interface AuthenticatedUser { id: string; email: string; @@ -25,6 +38,9 @@ export interface AuthSession { user: AuthenticatedUser; accessToken: string; refreshToken: string; + /** The refresh-session id this pair belongs to (Phase 8 #38) — lets the + * client mark its own entry in `GET /auth/sessions` as "this device". */ + sessionId: string; } function toPublicUser(user: { @@ -37,9 +53,26 @@ function toPublicUser(user: { return { id: user.id, email: user.email, role: user.role, createdAt: user.createdAt, updatedAt: user.updatedAt }; } -function issueSession(user: { id: string; role: Role }): { accessToken: string; refreshToken: string } { - const payload = { sub: user.id, role: user.role }; - return { accessToken: signAccessToken(payload), refreshToken: signRefreshToken(payload) }; +function issueTokensForSession( + user: { id: string; role: Role }, + sessionId: string, +): { accessToken: string; refreshToken: string } { + // `sid` rides on the refresh token only: access tokens stay stateless so + // no protected request needs a session lookup (issue #38's "no write + // hot-spot" requirement). + return { + accessToken: signAccessToken({ sub: user.id, role: user.role }), + refreshToken: signRefreshToken({ sub: user.id, role: user.role, sid: sessionId }), + }; +} + +/** Starts a new session (one per login) and issues its first token pair. */ +async function issueNewSession( + user: { id: string; role: Role }, + context: SessionContext, +): Promise<{ accessToken: string; refreshToken: string; sessionId: string }> { + const session = await startSession({ userId: user.id, ...context }); + return { ...issueTokensForSession(user, session.id), sessionId: session.id }; } export interface SignupInput { @@ -49,7 +82,7 @@ export interface SignupInput { fullName: string; } -export async function signup(input: SignupInput): Promise { +export async function signup(input: SignupInput, context: SessionContext = {}): Promise { // Facility accounts are admin-invited only (PRD OQ-2) — enforced here, // not just in docforum-web's UI. `Extract` on // the type already narrows this at compile time; this is the runtime @@ -71,7 +104,7 @@ export async function signup(input: SignupInput): Promise { fullName: input.fullName, }); - const tokens = issueSession(user); + const tokens = await issueNewSession(user, context); return { user: toPublicUser(user), ...tokens }; } @@ -80,7 +113,7 @@ export interface LoginInput { password: string; } -export async function login(input: LoginInput): Promise { +export async function login(input: LoginInput, context: SessionContext = {}): Promise { const user = await findUserByEmail(input.email); // Same message whether the email doesn't exist or the password is wrong // — don't leak which one it was. @@ -89,10 +122,15 @@ export async function login(input: LoginInput): Promise { const valid = await verifyPassword(input.password, user.passwordHash); if (!valid) throw new HttpError(401, 'Invalid email or password.'); - const tokens = issueSession(user); + const tokens = await issueNewSession(user, context); return { user: toPublicUser(user), ...tokens }; } +/** + * Exchanges a refresh cookie for a new pair. The refresh token's `sid` is + * checked against its `RefreshSession` row, so a revoked session stops + * working here — immediately, on the next refresh. + */ export async function refresh(refreshToken: string): Promise { let payload; try { @@ -104,6 +142,29 @@ export async function refresh(refreshToken: string): Promise { const user = await findUserById(payload.sub); if (!user) throw new HttpError(401, 'Invalid or expired refresh token.'); - const tokens = issueSession(user); - return { user: toPublicUser(user), ...tokens }; + // Tokens minted before session records existed carry no `sid` and are + // rejected: the migration logs existing sessions out once, which is the + // honest trade for actually having a revocation store. + if (!payload.sid) throw new HttpError(401, 'Invalid or expired refresh token.'); + + await assertSessionUsable({ sessionId: payload.sid, userId: user.id }); + await recordSessionUse(payload.sid); + + // Rotation keeps the same session (same family, same `sid`) — a new + // session row per refresh would make "list my sessions" useless. + const tokens = issueTokensForSession(user, payload.sid); + return { user: toPublicUser(user), ...tokens, sessionId: payload.sid }; +} + +/** The session id behind a refresh cookie, for endpoints that need to know + * which of the caller's sessions is the current one. Returns undefined for + * a missing/unverifiable cookie rather than throwing — "which session am + * I" is never the reason a request should fail. */ +export function sessionIdFromRefreshToken(refreshToken: string | undefined): string | undefined { + if (!refreshToken) return undefined; + try { + return verifyRefreshToken(refreshToken).sid; + } catch { + return undefined; + } } diff --git a/backend/src/modules/auth/services/sessions.ts b/backend/src/modules/auth/services/sessions.ts new file mode 100644 index 00000000..7448a6b5 --- /dev/null +++ b/backend/src/modules/auth/services/sessions.ts @@ -0,0 +1,102 @@ +// Refresh-session management (Phase 8 #38). +// +// One row per refresh-token family (i.e. per login), referenced by the +// refresh JWT's `sid` claim. This is the revocation store #18 asked for: +// access tokens stay stateless (15-minute TTL, no per-request lookup), but +// a refresh only succeeds while its session row is present and unrevoked — +// which is exactly when revocation has to bite, because that's the +// operation that mints new tokens. +import { HttpError } from '../../../http-error'; +import { + createRefreshSession, + findRefreshSessionById, + listRefreshSessions, + revokeRefreshSession, + revokeRefreshSessionsForUser, + touchRefreshSession, +} from '../repositories'; + +export interface SessionContext { + /** Coarse, best-effort source address. Never used for authorization. */ + ip?: string; + userAgent?: string; +} + +export interface SessionSummary { + id: string; + createdAt: Date; + lastUsedAt: Date; + userAgent: string | null; + ip: string | null; + /** True for the session making this request — the UI's "this device". */ + current: boolean; +} + +export function startSession(input: { userId: string } & SessionContext): Promise<{ id: string }> { + return createRefreshSession(input); +} + +/** + * Validates a refresh against its session row. Returns the session id to + * re-issue against, or throws the same generic 401 the caller uses for any + * unusable refresh token — a revoked session and a garbage token should be + * indistinguishable from outside. + */ +export async function assertSessionUsable(input: { sessionId: string; userId: string }): Promise { + const session = await findRefreshSessionById(input.sessionId); + if (!session || session.userId !== input.userId || session.revokedAt) { + throw new HttpError(401, 'Invalid or expired refresh token.'); + } +} + +/** Marks the session as used. Called on refresh only. */ +export function recordSessionUse(sessionId: string): Promise { + return touchRefreshSession(sessionId); +} + +export async function listSessions(input: { userId: string; currentSessionId?: string }): Promise { + const sessions = await listRefreshSessions(input.userId); + return sessions.map((session) => ({ + id: session.id, + createdAt: session.createdAt, + lastUsedAt: session.lastUsedAt, + userAgent: session.userAgent, + ip: session.ip, + current: session.id === input.currentSessionId, + })); +} + +/** + * Revokes one of the caller's sessions. `userId` is part of the update's + * WHERE clause, so another user's session id simply updates nothing — the + * ownership check is the query, not a branch above it. + */ +export async function revokeSession(input: { + userId: string; + sessionId: string; + currentSessionId?: string; +}): Promise<{ revoked: boolean; wasCurrent: boolean }> { + const result = await revokeRefreshSession(input.sessionId, input.userId); + if (result.count !== 1) return { revoked: false, wasCurrent: false }; + // Revoking your own session has to clear the cookie too, otherwise the + // browser keeps presenting a refresh token whose session is dead. + return { revoked: true, wasCurrent: input.sessionId === input.currentSessionId }; +} + +/** "Sign out everywhere else": keeps the caller's own session alive. */ +export async function revokeOtherSessions(input: { + userId: string; + currentSessionId?: string; +}): Promise { + const result = await revokeRefreshSessionsForUser({ + userId: input.userId, + exceptSessionId: input.currentSessionId, + }); + return result.count; +} + +/** Admin escape hatch: revoke every session for a user (issue #38). */ +export async function revokeAllUserSessions(userId: string): Promise { + const result = await revokeRefreshSessionsForUser({ userId }); + return result.count; +} diff --git a/backend/src/modules/auth/services/tokens.ts b/backend/src/modules/auth/services/tokens.ts index ba342c74..c36bedee 100644 --- a/backend/src/modules/auth/services/tokens.ts +++ b/backend/src/modules/auth/services/tokens.ts @@ -5,6 +5,10 @@ import type { Role } from '@prisma/client'; export interface AccessTokenPayload { sub: string; // user id role: Role; + /** Refresh-session id (Phase 8 #38). Present only on refresh tokens — + * the access token stays stateless so protected requests don't need a + * session lookup. */ + sid?: string; } export function signAccessToken(payload: AccessTokenPayload): string { diff --git a/backend/tests/integration/sessions.test.mts b/backend/tests/integration/sessions.test.mts new file mode 100644 index 00000000..834032ab --- /dev/null +++ b/backend/tests/integration/sessions.test.mts @@ -0,0 +1,269 @@ +// Session management (Phase 8 #38) — integration, against a real Postgres, +// because the properties under test are all about persistence: a session +// row exists per login, refresh updates it instead of creating a new one, +// revocation is enforced at refresh time, and ownership checks hold. +// +// Self-contained like the other integration suites: throwaway Postgres on +// port 5438 (one port per file — vitest runs files in parallel). +import { execFileSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import request from 'supertest'; +import EmbeddedPostgres from 'embedded-postgres'; + +const BACKEND_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const PORT = 5438; +const runId = Date.now(); + +process.env.DATABASE_URL = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'docforum-sessions-pg-')); +const pg = new EmbeddedPostgres({ + databaseDir: dataDir, + user: 'docforum', + password: 'docforum', + port: PORT, + persistent: false, +}); + +const databaseUrl = `postgresql://docforum:docforum@localhost:${PORT}/docforum_test`; + +type App = ReturnType; + +function cookieFrom(res: request.Response): string { + const header = res.headers['set-cookie']?.[0]; + if (!header) throw new Error('no set-cookie header'); + return header.split(';')[0]; +} + +describe('session management', () => { + let app: App; + let prisma: typeof import('../../src/db/prisma-client.js').prisma; + let hashPassword: typeof import('../../src/modules/auth/services/password.js').hashPassword; + + const patientA = { email: `sessions-a-${runId}@example.com`, password: 'patient-a-password-1' }; + const patientB = { email: `sessions-b-${runId}@example.com`, password: 'patient-b-password-1' }; + + let tokenA: string; + let sessionIdA1: string; + let cookieA1: string; + let patientBUserId: string; + let tokenB: string; + let cookieB: string; + + function login(email: string, password: string) { + return request(app).post('/auth/login').send({ email, password }); + } + + function sessions(token: string) { + return request(app).get('/auth/sessions').set('Authorization', `Bearer ${token}`); + } + + function refresh(cookie: string) { + return request(app).post('/auth/refresh').set('Cookie', cookie); + } + + beforeAll(async () => { + await pg.initialise(); + await pg.start(); + await pg.createDatabase('docforum_test'); + + execFileSync('npx', ['prisma', 'migrate', 'deploy'], { + cwd: BACKEND_ROOT, + env: { ...process.env, DATABASE_URL: databaseUrl }, + stdio: 'pipe', + }); + + const [appModule, dbModule, passwordModule] = await Promise.all([ + import('../../src/app.js'), + import('../../src/db/prisma-client.js'), + import('../../src/modules/auth/services/password.js'), + ]); + app = appModule.createApp(); + prisma = dbModule.prisma; + hashPassword = passwordModule.hashPassword; + + const signupA = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email: patientA.email, password: patientA.password, fullName: 'Session Patient A' }); + expect(signupA.status).toBe(200); + tokenA = signupA.body.accessToken as string; + sessionIdA1 = signupA.body.sessionId as string; + cookieA1 = cookieFrom(signupA); + + const signupB = await request(app) + .post('/auth/signup') + .send({ role: 'patient', email: patientB.email, password: patientB.password, fullName: 'Session Patient B' }); + expect(signupB.status).toBe(200); + tokenB = signupB.body.accessToken as string; + cookieB = cookieFrom(signupB); + patientBUserId = (await prisma.user.findUniqueOrThrow({ where: { email: patientB.email } })).id; + }); + + afterAll(async () => { + await prisma?.$disconnect(); + await pg.stop(); + fs.rmSync(dataDir, { recursive: true, force: true }); + }); + + it('records a session per login and lists only the caller own sessions', async () => { + // A second login from a distinguishable client — this is what makes + // "sign out that other device" possible at all. + const second = await request(app) + .post('/auth/login') + .set('User-Agent', 'TestBrowser/1.0') + .send({ email: patientA.email, password: patientA.password }); + expect(second.status).toBe(200); + const sessionIdA2 = second.body.sessionId as string; + expect(sessionIdA2).not.toBe(sessionIdA1); + + const list = await sessions(tokenA); + expect(list.status).toBe(200); + expect(list.body).toHaveLength(2); + + const ids = list.body.map((s: { id: string }) => s.id); + expect(ids).toContain(sessionIdA1); + expect(ids).toContain(sessionIdA2); + + // The request context is recorded per session (coarse IP + user agent). + const a2 = list.body.find((s: { id: string }) => s.id === sessionIdA2); + expect(a2.userAgent).toBe('TestBrowser/1.0'); + expect(a2.ip).toBeTruthy(); + + // A different user sees only their own — never A's. + const listB = await sessions(tokenB); + expect(listB.body).toHaveLength(1); + expect(listB.body.map((s: { id: string }) => s.id)).not.toContain(sessionIdA1); + const bRow = await prisma.refreshSession.findUniqueOrThrow({ where: { id: listB.body[0].id } }); + expect(bRow.userId).toBe(patientBUserId); + }); + + it('marks the session behind the request cookie as current', async () => { + const list = await request(app).get('/auth/sessions').set('Authorization', `Bearer ${tokenA}`).set('Cookie', cookieA1); + const current = list.body.filter((s: { current: boolean }) => s.current); + expect(current).toHaveLength(1); + expect(current[0].id).toBe(sessionIdA1); + }); + + it('rotates tokens on refresh without creating a new session, and updates last-used', async () => { + const before = await prisma.refreshSession.findUniqueOrThrow({ where: { id: sessionIdA1 } }); + expect(await prisma.refreshSession.count({ where: { userId: before.userId } })).toBe(2); + + const refreshed = await refresh(cookieA1); + expect(refreshed.status).toBe(200); + // Same session (same family) — rotation does not fork the session. + expect(refreshed.body.sessionId).toBe(sessionIdA1); + expect(await prisma.refreshSession.count({ where: { userId: before.userId } })).toBe(2); + + const after = await prisma.refreshSession.findUniqueOrThrow({ where: { id: sessionIdA1 } }); + expect(after.lastUsedAt.getTime()).toBeGreaterThanOrEqual(before.lastUsedAt.getTime()); + + // Subsequent requests use the rotated cookie. + cookieA1 = cookieFrom(refreshed); + }); + + it('does not let a user see or revoke another user sessions', async () => { + const foreign = await request(app) + .delete(`/auth/sessions/${sessionIdA1}`) + .set('Authorization', `Bearer ${tokenB}`); + // Scoped update: A's id simply doesn't match B's rows. + expect(foreign.status).toBe(404); + + // A's session is untouched. + expect((await refresh(cookieA1)).status).toBe(200); + const listA = await sessions(tokenA); + expect(listA.body.map((s: { id: string }) => s.id)).toContain(sessionIdA1); + + // And B can't enumerate it either. + const listB = await sessions(tokenB); + expect(listB.body.map((s: { id: string }) => s.id)).not.toContain(sessionIdA1); + }); + + it('rejects a revoked session refresh immediately', async () => { + // Send the cookie so "current" means this session — the *other* one is + // the one under test here. + const sessionsBefore = (await request(app).get('/auth/sessions').set('Authorization', `Bearer ${tokenA}`).set('Cookie', cookieA1)).body as { id: string; current: boolean }[]; + const other = sessionsBefore.find((s) => !s.current); + expect(other).toBeDefined(); + + const revoked = await request(app) + .delete(`/auth/sessions/${other?.id}`) + .set('Authorization', `Bearer ${tokenA}`); + expect(revoked.status).toBe(204); + + // The revoked session's cookie no longer works... + const revokedRow = await prisma.refreshSession.findUniqueOrThrow({ where: { id: other?.id } }); + expect(revokedRow.revokedAt).not.toBeNull(); + + // ...and the caller's own session still does. + expect((await refresh(cookieA1)).status).toBe(200); + + const after = (await sessions(tokenA)).body as { id: string }[]; + expect(after.map((s) => s.id)).not.toContain(other?.id); + }); + + it('clears the refresh cookie when the current session is revoked', async () => { + const refreshed = await refresh(cookieA1); + cookieA1 = cookieFrom(refreshed); + + const revoked = await request(app) + .delete(`/auth/sessions/${sessionIdA1}`) + .set('Authorization', `Bearer ${tokenA}`) + .set('Cookie', cookieA1); + + expect(revoked.status).toBe(204); + const cleared = revoked.headers['set-cookie']?.[0] as unknown as string; + expect(cleared).toContain('docforum_refresh='); + expect(cleared).toMatch(/Expires=|Max-Age=0/); + + expect((await refresh(cookieA1)).status).toBe(401); + }); + + it('revokes every other session but keeps the current one', async () => { + const first = await login(patientA.email, patientA.password); + const second = await login(patientA.email, patientA.password); + const currentCookie = cookieFrom(second); + const staleCookie = cookieFrom(first); + + const result = await request(app) + .delete('/auth/sessions') + .set('Authorization', `Bearer ${tokenA}`) + .set('Cookie', currentCookie); + expect(result.status).toBe(200); + expect(result.body.revoked).toBeGreaterThanOrEqual(1); + + expect((await refresh(staleCookie)).status).toBe(401); + expect((await refresh(currentCookie)).status).toBe(200); + + // Make the surviving session the one the rest of the file uses. + cookieA1 = cookieFrom(await refresh(currentCookie)); + }); + + it('lets an admin revoke all sessions for a user, and only admins', async () => { + const adminEmail = `sessions-admin-${runId}@example.com`; + await prisma.user.create({ + data: { email: adminEmail, role: 'admin', passwordHash: await hashPassword('admin-password-1') }, + }); + const adminLogin = await login(adminEmail, 'admin-password-1'); + expect(adminLogin.status).toBe(200); + const adminToken = adminLogin.body.accessToken as string; + + const forbidden = await request(app) + .delete(`/admin/users/${patientBUserId}/sessions`) + .set('Authorization', `Bearer ${tokenB}`); + expect(forbidden.status).toBe(403); + + const asAdmin = await request(app) + .delete(`/admin/users/${patientBUserId}/sessions`) + .set('Authorization', `Bearer ${adminToken}`); + expect(asAdmin.status).toBe(200); + expect(asAdmin.body.revoked).toBeGreaterThanOrEqual(1); + + // B's refresh is dead — the session row is the kill switch. + expect((await refresh(cookieB)).status).toBe(401); + expect((await sessions(tokenB)).body).toHaveLength(0); + }); +}); diff --git a/docs/api/README.md b/docs/api/README.md index aa21154f..3aa8968e 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -15,14 +15,27 @@ spec yet — TBD, tracked informally, not blocking Phase 1. | `POST /auth/signup` | none | `{ role: 'patient'\|'doctor', email, password, fullName }` | **`role` is restricted to `patient`/`doctor` server-side, not just in docforum-web's UI** — facility accounts are admin-invited only (PRD OQ-2). Returns `{ user, accessToken }`; sets an httpOnly refresh cookie. | | `POST /auth/login` | none | `{ email, password }` | Same response shape as signup. | | `POST /auth/refresh` | refresh cookie | — | Reads the httpOnly cookie (not the body). Returns a fresh `{ user, accessToken }` and rotates the cookie. | -| `POST /auth/logout` | none | — | Clears the refresh cookie. `204 No Content`. | - -Access tokens: `Authorization: Bearer `, 15-minute TTL, no -revocation list yet (see `ARCHITECTURE_ESSENTIALS.md` known gaps — -JWT refresh rotation without a revocation list). Refresh tokens: httpOnly -cookie, 30-day TTL, `sameSite: 'lax'`/`secure: false` — **local-dev-only -choice**, see the comment above `setRefreshCookie` in -`backend/src/modules/auth/controllers/index.ts` for what production needs. +| `POST /auth/logout` | none | — | Clears the refresh cookie. `204 No Content`. Does **not** revoke the session — use `DELETE /auth/sessions/:id` for that. | +| `GET /auth/sessions` | authenticated | — | The caller's own active sessions: `[{ id, createdAt, lastUsedAt, userAgent, ip, current }]`, most recently used first. `current` marks the session behind the request's refresh cookie. | +| `DELETE /auth/sessions/:id` | authenticated | — | Revokes one of the caller's sessions. `204`. Another user's id is a `404` (scoped update, no existence signal). Revoking the *current* session also clears the refresh cookie in the same response. | +| `DELETE /auth/sessions` | authenticated | — | "Sign out everywhere else": revokes every session of the caller **except** the current one. `200 { revoked }`. | +| `DELETE /admin/users/:id/sessions` | admin | — | Revokes every session for a user. `200 { revoked }`. Mounted under `/admin`, not `/auth` — it acts on someone else's sessions. | + +Access tokens: `Authorization: Bearer `, 15-minute TTL, stateless — +**no revocation check**, so a revoked session's access token keeps working +until it expires (≤15 min). Refresh tokens: httpOnly cookie, 30-day TTL, +`sameSite: 'lax'`/`secure: false` — **local-dev-only choice**, see the comment +above `setRefreshCookie` in `backend/src/modules/auth/controllers/index.ts` +for what production needs. + +Every login/signup creates a `RefreshSession` row and returns `sessionId` in +the body; the refresh JWT carries `sid` and `POST /auth/refresh` only succeeds +while that row exists and is unrevoked. Revocation therefore takes effect on +the next refresh. Refresh rotates the tokens but keeps the same session (same +family) — a new row per refresh would make "list my sessions" meaningless — and +it is the **only** place `lastUsedAt` is written, so there's no session write on +every authenticated request. Tokens minted before session records existed carry +no `sid` and are rejected: the migration logs existing sessions out once. ## Doctors (`/doctors`) — `backend/src/modules/doctors` From 026b051c23adcb66720ea35d88ca18fd90fb0917 Mon Sep 17 00:00:00 2001 From: Oyinkans0la12 <147610565+Oyinkans0la12@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:52:53 +0000 Subject: [PATCH 2/2] fix(auth): restore refresh-session schema and session-management wiring 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. --- backend/prisma/schema.prisma | 27 +++++++ backend/src/modules/auth/controllers/index.ts | 14 +++- backend/src/modules/auth/routes/index.ts | 15 +++- backend/src/modules/auth/services/index.ts | 76 +++++++++++++++---- backend/src/modules/auth/services/tokens.ts | 4 + backend/tests/integration/sessions.test.mts | 34 ++++++--- 6 files changed, 140 insertions(+), 30 deletions(-) diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 47d4d1b4..88d885b2 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -197,6 +197,33 @@ model LoginAttempt { @@index([lastFailedAt]) } +// One row per refresh-token *family*, i.e. per login. This is the +// revocation store session management needs (Phase 8 #38) and the shape +// issue #18's refresh-token revocation wanted: the refresh JWT carries +// `sid`, and a refresh is only honoured while the row exists with +// `revokedAt` still null. +// +// Collecting a coarse IP and user agent here is deliberate — it is what +// makes "sign out that other device" usable. Nothing clinical or +// identifying beyond what the request itself already carried. +model RefreshSession { + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + // Updated on refresh only — never on every access-token-authenticated + // request, so this doesn't become a write hot-spot on the User row. + lastUsedAt DateTime @default(now()) + revokedAt DateTime? + + userAgent String? + ip String? + + @@index([userId]) + @@index([userId, revokedAt]) +} + model PatientProfile { id String @id @default(uuid()) userId String @unique diff --git a/backend/src/modules/auth/controllers/index.ts b/backend/src/modules/auth/controllers/index.ts index dcb63a87..4bb6134e 100644 --- a/backend/src/modules/auth/controllers/index.ts +++ b/backend/src/modules/auth/controllers/index.ts @@ -10,6 +10,7 @@ import { import { HttpError } from '../../../http-error'; import { config } from '../../../config'; import { readCsrfHeader } from '../../../middleware/csrf'; +import { getParam } from '../../../request-params'; const REFRESH_COOKIE = 'docforum_refresh'; const REFRESH_COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30d — matches config.jwt.refreshTtl @@ -43,7 +44,12 @@ function sessionResponse(res: Response, session: authService.AuthSession) { // `csrfToken` is returned in the body, not as a readable cookie — see // middleware/csrf.ts for why a cross-site frontend can't use a cookie for // this. docforum-web keeps it in memory alongside the access token. - res.status(200).json({ user: session.user, accessToken: session.accessToken, csrfToken: session.csrfToken }); + res.status(200).json({ + user: session.user, + accessToken: session.accessToken, + csrfToken: session.csrfToken, + sessionId: session.sessionId, + }); } export async function signup(req: Request, res: Response) { @@ -58,7 +64,7 @@ export async function login(req: Request, res: Response) { // Express's `req.ip` honours the `trust proxy` setting (app.ts) — it is // the whole reason the throttle keys on the IP the client believes it // is, rather than Render's proxy address (see config.trustProxy). - const session = await authService.login({ ...input, ip: req.ip }); + const session = await authService.login({ ...input, ip: req.ip }, sessionContext(req)); sessionResponse(res, session); } catch (err) { // Phase 8, issue #35: a throttled attempt must tell the client how long @@ -90,7 +96,7 @@ export async function clearLoginAttempts(req: Request, res: Response) { */ export async function signupWithInvite(req: Request, res: Response) { const input = signupWithInviteSchema.parse(req.body); - const session = await authService.signupWithInvite(input); + const session = await authService.signupWithInvite(input, sessionContext(req)); sessionResponse(res, session); } @@ -172,7 +178,7 @@ export async function revokeSession(req: Request, res: Response) { if (!result.revoked) throw new HttpError(404, 'Session not found.'); // Revoking the session you're using has to clear the cookie in the same // response, or the browser keeps sending a dead refresh token. - if (result.wasCurrent) res.clearCookie(REFRESH_COOKIE, REFRESH_COOKIE_OPTIONS); + if (result.wasCurrent) res.clearCookie(REFRESH_COOKIE, config.cookie); res.status(204).send(); } diff --git a/backend/src/modules/auth/routes/index.ts b/backend/src/modules/auth/routes/index.ts index f183fcf2..4170386c 100644 --- a/backend/src/modules/auth/routes/index.ts +++ b/backend/src/modules/auth/routes/index.ts @@ -2,7 +2,6 @@ import { Router } from 'express'; import { authenticate, requireRole } from '../../../middleware/auth'; import { requireAllowedOrigin } from '../../../middleware/csrf'; import * as authController from '../controllers'; -import { authenticate, requireRole } from '../../../middleware/auth'; export const authRouter = Router(); @@ -28,3 +27,17 @@ authRouter.post('/login-attempts/clear', authenticate, requireRole('admin'), aut // /logout. See middleware/csrf.ts for the choice and its justification. authRouter.post('/refresh', requireAllowedOrigin, authController.refresh); authRouter.post('/logout', requireAllowedOrigin, authController.logout); + +// Session management (Phase 8 #38). Note the ordering: `DELETE /sessions` +// (revoke all others) is registered before `DELETE /sessions/:id` — Express +// matches in registration order, and `/sessions` must not be read as an id. +authRouter.get('/sessions', authenticate, authController.listSessions); +authRouter.delete('/sessions', authenticate, authController.revokeOtherSessions); +authRouter.delete('/sessions/:id', authenticate, authController.revokeSession); + +/** Admin routes for the auth module, mounted at `/admin` in app.ts. Session + * administration acts on *another* user, so it doesn't belong under + * `/auth`. */ +export const adminAuthRouter = Router(); + +adminAuthRouter.delete('/users/:id/sessions', authenticate, requireRole('admin'), authController.revokeUserSessions); diff --git a/backend/src/modules/auth/services/index.ts b/backend/src/modules/auth/services/index.ts index 32598427..f6dd758c 100644 --- a/backend/src/modules/auth/services/index.ts +++ b/backend/src/modules/auth/services/index.ts @@ -15,6 +15,7 @@ import { } from '../repositories'; import { hashPassword, verifyPassword } from './password'; import { signAccessToken, signRefreshToken, verifyRefreshToken } from './tokens'; +import { assertSessionUsable, recordSessionUse, startSession, type SessionContext } from './sessions'; import { generateVerificationToken, hashVerificationToken } from './verification-tokens'; import { sendEmail } from './email-dispatcher'; import { @@ -36,6 +37,18 @@ import type { PatientExportSection } from '../../patients/services'; export { verifyAccessToken } from './tokens'; export type { AccessTokenPayload } from './tokens'; +export { + assertSessionUsable, + listSessions, + recordSessionUse, + revokeAllUserSessions, + revokeOtherSessions, + revokeSession, + startSession, + type SessionContext, + type SessionSummary, +} from './sessions'; + // Re-exported for the same reason: tests (and, at Phase 6, the provider // integration) replace the outbound email sender through this module's // service surface, not by importing the dispatcher file directly. @@ -68,6 +81,9 @@ export interface AuthSession { * It deliberately is NOT a JS-readable cookie: docforum-web is a different * site from this API, so it couldn't read one. */ csrfToken: string; + /** The refresh-session id this pair belongs to (Phase 8 #38) — lets the + * client mark its own entry in `GET /auth/sessions` as "this device". */ + sessionId: string; } function toPublicUser(user: { @@ -88,22 +104,33 @@ function toPublicUser(user: { }; } -function issueSession(user: { id: string; role: Role }): { - accessToken: string; - refreshToken: string; - csrfToken: string; -} { +function issueTokensForSession( + user: { id: string; role: Role }, + sessionId: string, +): { accessToken: string; refreshToken: string; csrfToken: string } { const payload = { sub: user.id, role: user.role }; // A fresh CSRF token per session, and per rotation — bound into the // refresh token so verifying it needs no server-side state (issue #37). const csrfToken = generateCsrfToken(); + // `sid` rides on the refresh token only: access tokens stay stateless so + // no protected request needs a session lookup (issue #38's "no write + // hot-spot" requirement). return { accessToken: signAccessToken(payload), - refreshToken: signRefreshToken({ ...payload, csrf: csrfToken }), + refreshToken: signRefreshToken({ ...payload, csrf: csrfToken, sid: sessionId }), csrfToken, }; } +/** Starts a new session (one per login) and issues its first token pair. */ +async function issueNewSession( + user: { id: string; role: Role }, + context: SessionContext, +): Promise<{ accessToken: string; refreshToken: string; csrfToken: string; sessionId: string }> { + const session = await startSession({ userId: user.id, ...context }); + return { ...issueTokensForSession(user, session.id), sessionId: session.id }; +} + export interface SignupInput { role: Extract; email: string; @@ -139,7 +166,7 @@ export async function signup(input: SignupInput, context: SessionContext = {}): // the link is redeemed. await issueEmailVerification(user); - const tokens = issueSession(user); + const tokens = await issueNewSession(user, context); return { user: toPublicUser(user), ...tokens }; } @@ -156,11 +183,11 @@ export interface SignupWithInviteInput { password: string; } -export async function signupWithInvite(input: SignupWithInviteInput): Promise { +export async function signupWithInvite(input: SignupWithInviteInput, context: SessionContext = {}): Promise { const passwordHash = await hashPassword(input.password); const user = await redeemAdminInvite({ token: input.token, email: input.email, passwordHash }); - const tokens = issueSession(user); + const tokens = await issueNewSession(user, context); return { user: { id: user.id, @@ -193,7 +220,7 @@ export interface LoginInput { * response body is identical. */ const ABSENT_ACCOUNT_HASH = '$2b$12$z8J5gwFm9TiwC.YpeD6yguMkgakFPA8egT1KEKYiaeVkmb3CHRGXu'; -export async function login(input: LoginInput): Promise { +export async function login(input: LoginInput, context: SessionContext = {}): Promise { const throttleKeys = { emailKey: emailThrottleKey(input.email), ipKey: normalizeClientIp(input.ip), @@ -229,7 +256,7 @@ export async function login(input: LoginInput): Promise { throw new HttpError(403, 'This account has been deactivated. Contact an administrator.'); } - const tokens = issueSession(user); + const tokens = await issueNewSession(user, context); return { user: toPublicUser(user), ...tokens }; } @@ -374,8 +401,31 @@ export async function refresh(refreshToken: string, csrfToken?: string | null): throw new HttpError(401, 'Your session has been revoked. Please sign in again.'); } - const tokens = issueSession(user); - return { user: toPublicUser(user), ...tokens }; + // Tokens minted before session records existed carry no `sid` and are + // rejected: the migration logs existing sessions out once, which is the + // honest trade for actually having a revocation store. + if (!payload.sid) throw new HttpError(401, 'Invalid or expired refresh token.'); + + await assertSessionUsable({ sessionId: payload.sid, userId: user.id }); + await recordSessionUse(payload.sid); + + // Rotation keeps the same session (same family, same `sid`) — a new + // session row per refresh would make "list my sessions" useless. + const tokens = issueTokensForSession(user, payload.sid); + return { user: toPublicUser(user), ...tokens, sessionId: payload.sid }; +} + +/** The session id behind a refresh cookie, for endpoints that need to know + * which of the caller's sessions is the current one. Returns undefined for + * a missing/unverifiable cookie rather than throwing — "which session am + * I" is never the reason a request should fail. */ +export function sessionIdFromRefreshToken(refreshToken: string | undefined): string | undefined { + if (!refreshToken) return undefined; + try { + return verifyRefreshToken(refreshToken).sid; + } catch { + return undefined; + } } /** diff --git a/backend/src/modules/auth/services/tokens.ts b/backend/src/modules/auth/services/tokens.ts index 3b584af7..923171d2 100644 --- a/backend/src/modules/auth/services/tokens.ts +++ b/backend/src/modules/auth/services/tokens.ts @@ -9,6 +9,10 @@ export interface AccessTokenPayload { * compare against `User.sessionsInvalidatedAt` (Phase 8, issue #39) — * tokens minted before a deactivation must stop working immediately. */ iat?: number; + /** Refresh-session id (Phase 8 #38). Present only on refresh tokens — + * the access token stays stateless so protected requests don't need a + * session lookup. */ + sid?: string; } /** diff --git a/backend/tests/integration/sessions.test.mts b/backend/tests/integration/sessions.test.mts index 834032ab..fc232fb3 100644 --- a/backend/tests/integration/sessions.test.mts +++ b/backend/tests/integration/sessions.test.mts @@ -50,9 +50,11 @@ describe('session management', () => { let tokenA: string; let sessionIdA1: string; let cookieA1: string; + let csrfA1: string; let patientBUserId: string; let tokenB: string; let cookieB: string; + let csrfB: string; function login(email: string, password: string) { return request(app).post('/auth/login').send({ email, password }); @@ -62,8 +64,8 @@ describe('session management', () => { return request(app).get('/auth/sessions').set('Authorization', `Bearer ${token}`); } - function refresh(cookie: string) { - return request(app).post('/auth/refresh').set('Cookie', cookie); + function refresh(cookie: string, csrfToken: string) { + return request(app).post('/auth/refresh').set('Cookie', cookie).set('X-CSRF-Token', csrfToken); } beforeAll(async () => { @@ -93,6 +95,7 @@ describe('session management', () => { tokenA = signupA.body.accessToken as string; sessionIdA1 = signupA.body.sessionId as string; cookieA1 = cookieFrom(signupA); + csrfA1 = signupA.body.csrfToken as string; const signupB = await request(app) .post('/auth/signup') @@ -100,6 +103,7 @@ describe('session management', () => { expect(signupB.status).toBe(200); tokenB = signupB.body.accessToken as string; cookieB = cookieFrom(signupB); + csrfB = signupB.body.csrfToken as string; patientBUserId = (await prisma.user.findUniqueOrThrow({ where: { email: patientB.email } })).id; }); @@ -152,7 +156,7 @@ describe('session management', () => { const before = await prisma.refreshSession.findUniqueOrThrow({ where: { id: sessionIdA1 } }); expect(await prisma.refreshSession.count({ where: { userId: before.userId } })).toBe(2); - const refreshed = await refresh(cookieA1); + const refreshed = await refresh(cookieA1, csrfA1); expect(refreshed.status).toBe(200); // Same session (same family) — rotation does not fork the session. expect(refreshed.body.sessionId).toBe(sessionIdA1); @@ -161,8 +165,9 @@ describe('session management', () => { const after = await prisma.refreshSession.findUniqueOrThrow({ where: { id: sessionIdA1 } }); expect(after.lastUsedAt.getTime()).toBeGreaterThanOrEqual(before.lastUsedAt.getTime()); - // Subsequent requests use the rotated cookie. + // Subsequent requests use the rotated cookie and CSRF token. cookieA1 = cookieFrom(refreshed); + csrfA1 = refreshed.body.csrfToken as string; }); it('does not let a user see or revoke another user sessions', async () => { @@ -173,7 +178,7 @@ describe('session management', () => { expect(foreign.status).toBe(404); // A's session is untouched. - expect((await refresh(cookieA1)).status).toBe(200); + expect((await refresh(cookieA1, csrfA1)).status).toBe(200); const listA = await sessions(tokenA); expect(listA.body.map((s: { id: string }) => s.id)).toContain(sessionIdA1); @@ -199,15 +204,16 @@ describe('session management', () => { expect(revokedRow.revokedAt).not.toBeNull(); // ...and the caller's own session still does. - expect((await refresh(cookieA1)).status).toBe(200); + expect((await refresh(cookieA1, csrfA1)).status).toBe(200); const after = (await sessions(tokenA)).body as { id: string }[]; expect(after.map((s) => s.id)).not.toContain(other?.id); }); it('clears the refresh cookie when the current session is revoked', async () => { - const refreshed = await refresh(cookieA1); + const refreshed = await refresh(cookieA1, csrfA1); cookieA1 = cookieFrom(refreshed); + csrfA1 = refreshed.body.csrfToken as string; const revoked = await request(app) .delete(`/auth/sessions/${sessionIdA1}`) @@ -219,14 +225,16 @@ describe('session management', () => { expect(cleared).toContain('docforum_refresh='); expect(cleared).toMatch(/Expires=|Max-Age=0/); - expect((await refresh(cookieA1)).status).toBe(401); + expect((await refresh(cookieA1, csrfA1)).status).toBe(401); }); it('revokes every other session but keeps the current one', async () => { const first = await login(patientA.email, patientA.password); const second = await login(patientA.email, patientA.password); const currentCookie = cookieFrom(second); + const currentCsrf = second.body.csrfToken as string; const staleCookie = cookieFrom(first); + const staleCsrf = first.body.csrfToken as string; const result = await request(app) .delete('/auth/sessions') @@ -235,11 +243,13 @@ describe('session management', () => { expect(result.status).toBe(200); expect(result.body.revoked).toBeGreaterThanOrEqual(1); - expect((await refresh(staleCookie)).status).toBe(401); - expect((await refresh(currentCookie)).status).toBe(200); + expect((await refresh(staleCookie, staleCsrf)).status).toBe(401); + expect((await refresh(currentCookie, currentCsrf)).status).toBe(200); // Make the surviving session the one the rest of the file uses. - cookieA1 = cookieFrom(await refresh(currentCookie)); + const rotated = await refresh(currentCookie, currentCsrf); + cookieA1 = cookieFrom(rotated); + csrfA1 = rotated.body.csrfToken as string; }); it('lets an admin revoke all sessions for a user, and only admins', async () => { @@ -263,7 +273,7 @@ describe('session management', () => { expect(asAdmin.body.revoked).toBeGreaterThanOrEqual(1); // B's refresh is dead — the session row is the kill switch. - expect((await refresh(cookieB)).status).toBe(401); + expect((await refresh(cookieB, csrfB)).status).toBe(401); expect((await sessions(tokenB)).body).toHaveLength(0); }); });