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 7940dd41..2335c33b 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -130,6 +130,7 @@ model User { patientProfile PatientProfile? doctorProfile DoctorProfile? facilityProfile FacilityProfile? + refreshSessions RefreshSession[] emailVerificationTokens EmailVerificationToken[] adminInvitesSent AdminInvite[] @@ -200,6 +201,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/app.ts b/backend/src/app.ts index 02279901..e57c5310 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'; @@ -42,6 +42,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 8111bb24..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 @@ -32,17 +33,28 @@ 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); // `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) { const input = signupSchema.parse(req.body); - const session = await authService.signup(input); + const session = await authService.signup(input, sessionContext(req)); sessionResponse(res, session); } @@ -52,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 @@ -84,12 +96,12 @@ 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); } 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, readCsrfHeader(req)); @@ -132,3 +144,56 @@ export function logout(req: Request, res: Response) { res.clearCookie(REFRESH_COOKIE, config.cookie); 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, config.cookie); + 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 190ecbb4..697f47b8 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 12064bc2..4170386c 100644 --- a/backend/src/modules/auth/routes/index.ts +++ b/backend/src/modules/auth/routes/index.ts @@ -27,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 455c87b9..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; @@ -111,7 +138,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 @@ -139,7 +166,7 @@ export async function signup(input: SignupInput): Promise { // 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/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 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 new file mode 100644 index 00000000..fc232fb3 --- /dev/null +++ b/backend/tests/integration/sessions.test.mts @@ -0,0 +1,279 @@ +// 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 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 }); + } + + function sessions(token: string) { + return request(app).get('/auth/sessions').set('Authorization', `Bearer ${token}`); + } + + function refresh(cookie: string, csrfToken: string) { + return request(app).post('/auth/refresh').set('Cookie', cookie).set('X-CSRF-Token', csrfToken); + } + + 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); + csrfA1 = signupA.body.csrfToken as string; + + 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); + csrfB = signupB.body.csrfToken as string; + 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, csrfA1); + 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 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 () => { + 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, csrfA1)).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, 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, csrfA1); + cookieA1 = cookieFrom(refreshed); + csrfA1 = refreshed.body.csrfToken as string; + + 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, 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') + .set('Authorization', `Bearer ${tokenA}`) + .set('Cookie', currentCookie); + expect(result.status).toBe(200); + expect(result.body.revoked).toBeGreaterThanOrEqual(1); + + 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. + 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 () => { + 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, csrfB)).status).toBe(401); + expect((await sessions(tokenB)).body).toHaveLength(0); + }); +});