Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;

28 changes: 28 additions & 0 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ model User {
patientProfile PatientProfile?
doctorProfile DoctorProfile?
facilityProfile FacilityProfile?
refreshSessions RefreshSession[]

emailVerificationTokens EmailVerificationToken[]
adminInvitesSent AdminInvite[]
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Expand Down
75 changes: 70 additions & 5 deletions backend/src/modules/auth/controllers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
}

Expand All @@ -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
Expand Down Expand Up @@ -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<string, string> | 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));
Expand Down Expand Up @@ -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<string, string> | 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 });
}
46 changes: 46 additions & 0 deletions backend/src/modules/auth/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 14 additions & 0 deletions backend/src/modules/auth/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Loading
Loading