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
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
13 changes: 13 additions & 0 deletions apps/api/src/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,20 @@ 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', () => {
it('reports a connected database', async () => {
const response = await request(
createApp({
authRouter: Router(),
csrfErrorHandler: noOpErrorMiddleware,
csrfProtection: noOpMiddleware,
webOrigin: 'http://localhost:5173',
trustProxyHops: 0,
sessionMiddleware: (_request, _response, next) => next(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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) =>
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import express, { type RequestHandler, type Router } from 'express';
type AppDependencies = {
authRouter: Router;
checkDatabaseConnection: () => Promise<void>;
csrfErrorHandler: express.ErrorRequestHandler;
csrfProtection: RequestHandler;
sessionMiddleware: RequestHandler;
trustProxyHops: number;
webOrigin: string;
Expand All @@ -14,6 +16,8 @@ type AppDependencies = {
export function createApp({
authRouter,
checkDatabaseConnection,
csrfErrorHandler,
csrfProtection,
sessionMiddleware,
trustProxyHops,
webOrigin,
Expand All @@ -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) => {
Expand All @@ -47,6 +52,7 @@ export function createApp({
}
});

app.use(csrfErrorHandler);
app.use(((error, _request, response, next) => {
void error;
void next;
Expand Down
34 changes: 34 additions & 0 deletions apps/api/src/auth/csrf/csrf.middleware.ts
Original file line number Diff line number Diff line change
@@ -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);
};
167 changes: 167 additions & 0 deletions apps/api/src/auth/session/session.routes.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof request.agent>) {
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<string, string> }
).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);
});
});
53 changes: 53 additions & 0 deletions apps/api/src/auth/session/session.routes.ts
Original file line number Diff line number Diff line change
@@ -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<PublicUser | null>;
};

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;
}
47 changes: 47 additions & 0 deletions apps/api/src/auth/user/current-user.service.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading