diff --git a/apps/api/package.json b/apps/api/package.json index 361ab98..4d79923 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,7 @@ "@goforlift/contracts": "workspace:*", "connect-pg-simple": "^10.0.0", "cors": "^2.8.5", + "csrf-sync": "^4.2.1", "dotenv": "^17.2.1", "drizzle-orm": "^0.44.4", "express": "^5.1.0", diff --git a/apps/api/src/app.test.ts b/apps/api/src/app.test.ts index 1c950c0..b605ade 100644 --- a/apps/api/src/app.test.ts +++ b/apps/api/src/app.test.ts @@ -5,6 +5,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { createApp } from './app.js'; +const noOpMiddleware = ((_request, _response, next) => + next()) satisfies import('express').RequestHandler; +const noOpErrorMiddleware = ((error, _request, _response, next) => + next(error)) satisfies import('express').ErrorRequestHandler; + afterEach(() => vi.restoreAllMocks()); describe('GET /health', () => { @@ -12,6 +17,8 @@ describe('GET /health', () => { const response = await request( createApp({ authRouter: Router(), + csrfErrorHandler: noOpErrorMiddleware, + csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', trustProxyHops: 0, sessionMiddleware: (_request, _response, next) => next(), @@ -27,6 +34,8 @@ describe('GET /health', () => { const response = await request( createApp({ authRouter: Router(), + csrfErrorHandler: noOpErrorMiddleware, + csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', trustProxyHops: 0, sessionMiddleware: (_request, _response, next) => next(), @@ -47,6 +56,8 @@ describe('GET /health', () => { const response = await request( createApp({ authRouter: Router(), + csrfErrorHandler: noOpErrorMiddleware, + csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', trustProxyHops: 0, sessionMiddleware: (_request, _response, next) => next(), @@ -67,6 +78,8 @@ describe('GET /health', () => { const response = await request( createApp({ authRouter: Router(), + csrfErrorHandler: noOpErrorMiddleware, + csrfProtection: noOpMiddleware, webOrigin: 'http://localhost:5173', trustProxyHops: 0, sessionMiddleware: (_request, _response, next) => diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 7413920..695cd07 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -6,6 +6,8 @@ import express, { type RequestHandler, type Router } from 'express'; type AppDependencies = { authRouter: Router; checkDatabaseConnection: () => Promise; + csrfErrorHandler: express.ErrorRequestHandler; + csrfProtection: RequestHandler; sessionMiddleware: RequestHandler; trustProxyHops: number; webOrigin: string; @@ -14,6 +16,8 @@ type AppDependencies = { export function createApp({ authRouter, checkDatabaseConnection, + csrfErrorHandler, + csrfProtection, sessionMiddleware, trustProxyHops, webOrigin, @@ -27,6 +31,7 @@ export function createApp({ app.use(cors({ origin: webOrigin, credentials: true })); app.use(sessionMiddleware); app.use(express.json()); + app.use(csrfProtection); app.use('/auth', authRouter); app.get('/health', async (_request, response) => { @@ -47,6 +52,7 @@ export function createApp({ } }); + app.use(csrfErrorHandler); app.use(((error, _request, response, next) => { void error; void next; diff --git a/apps/api/src/auth/csrf/csrf.middleware.ts b/apps/api/src/auth/csrf/csrf.middleware.ts new file mode 100644 index 0000000..781a949 --- /dev/null +++ b/apps/api/src/auth/csrf/csrf.middleware.ts @@ -0,0 +1,34 @@ +/** Configures session-bound synchronizer-token CSRF protection for unsafe requests. */ +import { csrfSync } from 'csrf-sync'; +import type { ErrorRequestHandler } from 'express'; + +const { + csrfSynchronisedProtection, + generateToken, + invalidCsrfTokenError, + revokeToken, +} = csrfSync({ + errorConfig: { + statusCode: 403, + message: 'Invalid CSRF token', + code: 'INVALID_CSRF_TOKEN', + }, +}); + +export const csrfProtection = csrfSynchronisedProtection; +export const generateCsrfToken = generateToken; +export const revokeCsrfToken = revokeToken; + +export const csrfErrorHandler: ErrorRequestHandler = ( + error, + _request, + response, + next, +) => { + if (error === invalidCsrfTokenError) { + response.status(403).json({ error: 'invalid_csrf_token' }); + return; + } + + next(error); +}; diff --git a/apps/api/src/auth/session/session.routes.test.ts b/apps/api/src/auth/session/session.routes.test.ts new file mode 100644 index 0000000..a714be8 --- /dev/null +++ b/apps/api/src/auth/session/session.routes.test.ts @@ -0,0 +1,167 @@ +/** Verifies auth hydration, session-bound CSRF protection, and logout behavior. */ +import express from 'express'; +import session from 'express-session'; +import request from 'supertest'; +import { describe, expect, it, vi } from 'vitest'; +import { z } from 'zod'; + +import { csrfErrorHandler, csrfProtection } from '../csrf/csrf.middleware.js'; +import { createSessionMiddleware } from './session.middleware.js'; +import { createSessionRouter } from './session.routes.js'; + +const sessionConfiguration = { + SESSION_SECRET: 'test-session-secret-at-least-32-characters', + SESSION_DURATION_SECONDS: 604800, + SESSION_COOKIE: { + name: 'goforlift.sid', + httpOnly: true, + secure: false, + sameSite: 'lax' as const, + path: '/', + maxAgeMs: 604800000, + }, +}; + +const publicUser = { + id: 'user-123', + email: 'lifter@example.com', + displayName: 'Go For Lifter', + avatarUrl: 'https://images.example.com/avatar.png', +}; + +const csrfTokenResponseSchema = z.object({ csrfToken: z.string() }); + +function createTestContext( + findPublicUser = vi.fn(() => Promise.resolve(publicUser)), +) { + const store = new session.MemoryStore(); + const app = express(); + app.use(createSessionMiddleware(store, sessionConfiguration)); + app.use((request, _response, next) => { + const testUserId = request.header('x-test-user-id'); + if (testUserId) { + request.session.userId = testUserId; + } + next(); + }); + app.use(csrfProtection); + app.use( + '/auth', + createSessionRouter({ + cookieName: sessionConfiguration.SESSION_COOKIE.name, + cookiePath: sessionConfiguration.SESSION_COOKIE.path, + findPublicUser, + }), + ); + app.use(csrfErrorHandler); + + return { app, findPublicUser, store }; +} + +async function getCsrfToken(agent: ReturnType) { + const response = await agent.get('/auth/csrf-token'); + return csrfTokenResponseSchema.parse(response.body as unknown).csrfToken; +} + +describe('GET /auth/me', () => { + it('returns an anonymous state without creating a session', async () => { + const { app, findPublicUser } = createTestContext(); + + const response = await request(app).get('/auth/me'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ user: null }); + expect(response.headers['set-cookie']).toBeUndefined(); + expect(findPublicUser).not.toHaveBeenCalled(); + }); + + it('returns only the authenticated user public profile', async () => { + const { app, findPublicUser } = createTestContext(); + + const response = await request(app) + .get('/auth/me') + .set('x-test-user-id', publicUser.id); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ user: publicUser }); + expect(findPublicUser).toHaveBeenCalledWith(publicUser.id); + expect(response.text).not.toContain('authProvider'); + expect(response.text).not.toContain('csrfToken'); + }); +}); + +describe('GET /auth/csrf-token', () => { + it('binds different tokens to different server-side sessions', async () => { + const { app } = createTestContext(); + const firstAgent = request.agent(app); + const secondAgent = request.agent(app); + + const firstToken = await getCsrfToken(firstAgent); + const secondToken = await getCsrfToken(secondAgent); + + expect(firstToken).toBeTruthy(); + expect(secondToken).toBeTruthy(); + expect(firstToken).not.toBe(secondToken); + + const crossSessionLogout = await secondAgent + .post('/auth/logout') + .set('x-csrf-token', firstToken); + expect(crossSessionLogout.status).toBe(403); + expect(crossSessionLogout.body).toEqual({ + error: 'invalid_csrf_token', + }); + }); +}); + +describe('POST /auth/logout', () => { + it('rejects missing and invalid CSRF tokens', async () => { + const { app } = createTestContext(); + const agent = request.agent(app); + await getCsrfToken(agent); + + const missing = await agent.post('/auth/logout'); + const invalid = await agent + .post('/auth/logout') + .set('x-csrf-token', 'invalid-token'); + + expect(missing.status).toBe(403); + expect(invalid.status).toBe(403); + }); + + it('destroys the session, clears the cookie, and prevents reuse', async () => { + const { app, store } = createTestContext(); + const agent = request.agent(app); + await agent.get('/auth/me').set('x-test-user-id', publicUser.id); + const csrfToken = await getCsrfToken(agent); + + const logout = await agent + .post('/auth/logout') + .set('x-csrf-token', csrfToken); + const afterLogout = await agent.get('/auth/me'); + + expect(logout.status).toBe(204); + expect(logout.headers['set-cookie']?.[0]).toContain('goforlift.sid=;'); + expect(afterLogout.body).toEqual({ user: null }); + const storedSessions = ( + store as unknown as { sessions: Record } + ).sessions; + expect(Object.keys(storedSessions)).toHaveLength(0); + }); + + it('is idempotent when repeated with a fresh session-bound token', async () => { + const { app } = createTestContext(); + const agent = request.agent(app); + + const firstToken = await getCsrfToken(agent); + const firstLogout = await agent + .post('/auth/logout') + .set('x-csrf-token', firstToken); + const secondToken = await getCsrfToken(agent); + const secondLogout = await agent + .post('/auth/logout') + .set('x-csrf-token', secondToken); + + expect(firstLogout.status).toBe(204); + expect(secondLogout.status).toBe(204); + }); +}); diff --git a/apps/api/src/auth/session/session.routes.ts b/apps/api/src/auth/session/session.routes.ts new file mode 100644 index 0000000..9a1e152 --- /dev/null +++ b/apps/api/src/auth/session/session.routes.ts @@ -0,0 +1,53 @@ +/** Defines current-user, CSRF-token, and logout endpoints for browser sessions. */ +import { Router } from 'express'; + +import type { PublicUser } from '../user/current-user.service.js'; +import { generateCsrfToken, revokeCsrfToken } from '../csrf/csrf.middleware.js'; + +type SessionRouterOptions = { + cookieName: string; + cookiePath: string; + findPublicUser: (userId: string) => Promise; +}; + +export function createSessionRouter({ + cookieName, + cookiePath, + findPublicUser, +}: SessionRouterOptions) { + const router = Router(); + + router.get('/me', async (request, response, next) => { + const userId = request.session.userId; + if (!userId) { + response.status(200).json({ user: null }); + return; + } + + try { + const user = await findPublicUser(userId); + response.status(200).json({ user }); + } catch (error) { + next(error); + } + }); + + router.get('/csrf-token', (request, response) => { + response.status(200).json({ csrfToken: generateCsrfToken(request) }); + }); + + router.post('/logout', (request, response, next) => { + revokeCsrfToken(request); + request.session.destroy((error) => { + if (error) { + next(error); + return; + } + + response.clearCookie(cookieName, { path: cookiePath }); + response.sendStatus(204); + }); + }); + + return router; +} diff --git a/apps/api/src/auth/user/current-user.service.test.ts b/apps/api/src/auth/user/current-user.service.test.ts new file mode 100644 index 0000000..7236138 --- /dev/null +++ b/apps/api/src/auth/user/current-user.service.test.ts @@ -0,0 +1,47 @@ +/** Verifies that current-user lookup selects only the public profile projection. */ +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; +import { describe, expect, it, vi } from 'vitest'; + +import { users } from '../../db/schema/index.js'; +import { findPublicUserById } from './current-user.service.js'; + +describe('findPublicUserById', () => { + it('returns the public user projection', async () => { + const publicUser = { + id: 'user-123', + email: 'lifter@example.com', + displayName: 'Go For Lifter', + avatarUrl: null, + }; + const limit = vi.fn().mockResolvedValue([publicUser]); + const where = vi.fn(() => ({ limit })); + const from = vi.fn(() => ({ where })); + const select = vi.fn(() => ({ from })); + const database = { select } as unknown as NodePgDatabase; + + const result = await findPublicUserById(database, publicUser.id); + + expect(select).toHaveBeenCalledWith({ + id: users.id, + email: users.email, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }); + expect(result).toEqual(publicUser); + }); + + it('returns null when the session user no longer exists', async () => { + const limit = vi.fn().mockResolvedValue([]); + const database = { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ limit })), + })), + })), + } as unknown as NodePgDatabase; + + await expect( + findPublicUserById(database, 'missing-user'), + ).resolves.toBeNull(); + }); +}); diff --git a/apps/api/src/auth/user/current-user.service.ts b/apps/api/src/auth/user/current-user.service.ts new file mode 100644 index 0000000..0b86dc7 --- /dev/null +++ b/apps/api/src/auth/user/current-user.service.ts @@ -0,0 +1,30 @@ +/** Loads the public profile fields exposed for the authenticated application user. */ +import { eq } from 'drizzle-orm'; +import type { NodePgDatabase } from 'drizzle-orm/node-postgres'; + +import { users } from '../../db/schema/index.js'; + +export type PublicUser = { + avatarUrl: string | null; + displayName: string | null; + email: string; + id: string; +}; + +export async function findPublicUserById( + database: NodePgDatabase, + userId: string, +): Promise { + const [user] = await database + .select({ + id: users.id, + email: users.email, + displayName: users.displayName, + avatarUrl: users.avatarUrl, + }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + return user ?? null; +} diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 57a304c..5b3872e 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,13 +1,20 @@ /** Starts the API with database, session, and graceful-shutdown resources. */ import { sql } from 'drizzle-orm'; +import { Router } from 'express'; import { createApp } from './app.js'; +import { + csrfErrorHandler, + csrfProtection, +} from './auth/csrf/csrf.middleware.js'; import { createGoogleOidcRouter, discoverGoogleOidc, } from './auth/google/google-oidc.routes.js'; import { provisionGoogleUser } from './auth/google/google-user.service.js'; import { createPostgresSession } from './auth/session/session.middleware.js'; +import { createSessionRouter } from './auth/session/session.routes.js'; +import { findPublicUserById } from './auth/user/current-user.service.js'; import { env } from './config/env.js'; import { createDatabase } from './db/client.js'; @@ -15,13 +22,26 @@ const { db, pool } = createDatabase(env.DATABASE_URL); const { middleware: sessionMiddleware, store: sessionStore } = createPostgresSession(pool, env); const oidc = await discoverGoogleOidc(env); -const app = createApp({ - authRouter: createGoogleOidcRouter({ +const authRouter = Router(); +authRouter.use( + createGoogleOidcRouter({ oidc, redirectUri: env.GOOGLE_OIDC_REDIRECT_URI, webOrigin: env.WEB_ORIGIN, provisionUser: (profile) => provisionGoogleUser(db, profile), }), +); +authRouter.use( + createSessionRouter({ + cookieName: env.SESSION_COOKIE.name, + cookiePath: env.SESSION_COOKIE.path, + findPublicUser: (userId) => findPublicUserById(db, userId), + }), +); +const app = createApp({ + authRouter, + csrfErrorHandler, + csrfProtection, webOrigin: env.WEB_ORIGIN, trustProxyHops: env.TRUST_PROXY_HOPS, sessionMiddleware, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 98a2302..e6be517 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -44,6 +44,9 @@ importers: cors: specifier: ^2.8.5 version: 2.8.6 + csrf-sync: + specifier: ^4.2.1 + version: 4.2.1 dotenv: specifier: ^17.2.1 version: 17.4.2 @@ -2555,6 +2558,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csrf-sync@4.2.1: + resolution: {integrity: sha512-+q9tlUSCi/kbwr1NYwn5+MeuNhwxz3wSv1yl42BgIWfIuErZ3HajRwzvZTkfiyIqt1PZT8lQSlffhSYjCneN7g==} + css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} @@ -6708,6 +6714,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csrf-sync@4.2.1: + dependencies: + http-errors: 2.0.1 + css.escape@1.5.1: {} cssesc@3.0.0: {}