From 7d459fe681809598a8ad067735206f30f53d8d2b Mon Sep 17 00:00:00 2001 From: rd-code-23 Date: Tue, 18 Aug 2026 21:31:33 -0700 Subject: [PATCH] feat(auth): add authentication and authorization guards --- .../auth/guards/authentication.middleware.ts | 37 +++++ .../guards/authorization.middleware.test.ts | 129 ++++++++++++++++++ .../auth/guards/authorization.middleware.ts | 33 +++++ .../test/authenticated-session.test-helper.ts | 18 +++ apps/api/tsconfig.build.json | 2 +- docs/architecture.md | 6 + 6 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/auth/guards/authentication.middleware.ts create mode 100644 apps/api/src/auth/guards/authorization.middleware.test.ts create mode 100644 apps/api/src/auth/guards/authorization.middleware.ts create mode 100644 apps/api/src/test/authenticated-session.test-helper.ts diff --git a/apps/api/src/auth/guards/authentication.middleware.ts b/apps/api/src/auth/guards/authentication.middleware.ts new file mode 100644 index 0000000..dfa7c55 --- /dev/null +++ b/apps/api/src/auth/guards/authentication.middleware.ts @@ -0,0 +1,37 @@ +/** Establishes a typed, trusted request identity from the server-side session. */ +import type { Request, RequestHandler } from 'express'; + +export type AuthenticatedRequest = Request & { + session: Request['session'] & { userId: string }; +}; + +export function isAuthenticatedRequest( + request: Request, +): request is AuthenticatedRequest { + return ( + typeof request.session.userId === 'string' && + request.session.userId.length > 0 + ); +} + +export function getAuthenticatedUserId(request: Request) { + if (!isAuthenticatedRequest(request)) { + throw new Error('Authenticated request identity is unavailable'); + } + + return request.session.userId; +} + +export const requireAuthentication: RequestHandler = ( + request, + response, + next, +) => { + const userId = request.session.userId; + if (typeof userId !== 'string' || userId.length === 0) { + response.status(401).json({ error: 'authentication_required' }); + return; + } + + next(); +}; diff --git a/apps/api/src/auth/guards/authorization.middleware.test.ts b/apps/api/src/auth/guards/authorization.middleware.test.ts new file mode 100644 index 0000000..cf6e478 --- /dev/null +++ b/apps/api/src/auth/guards/authorization.middleware.test.ts @@ -0,0 +1,129 @@ +/** Verifies authentication and ownership authorization through protected HTTP behavior. */ +import express from 'express'; +import session from 'express-session'; +import request from 'supertest'; +import { describe, expect, it, vi } from 'vitest'; + +import { createSessionMiddleware } from '../session/session.middleware.js'; +import { + establishAuthenticatedTestSession, + withAuthenticatedTestUser, +} from '../../test/authenticated-session.test-helper.js'; +import { + getAuthenticatedUserId, + requireAuthentication, +} from './authentication.middleware.js'; +import { requireAuthorization } from './authorization.middleware.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, + }, +}; + +function createProtectedTestApp( + userOwnsResource: (resourceId: string, userId: string) => Promise, +) { + const app = express(); + app.use( + createSessionMiddleware(new session.MemoryStore(), sessionConfiguration), + ); + app.use(establishAuthenticatedTestSession()); + app.get( + '/protected/:resourceId', + requireAuthentication, + requireAuthorization((authenticatedRequest) => { + const resourceId = authenticatedRequest.params.resourceId; + return ( + typeof resourceId === 'string' && + userOwnsResource(resourceId, authenticatedRequest.session.userId) + ); + }), + (authenticatedRequest, response) => { + response.status(200).json({ + resourceId: authenticatedRequest.params.resourceId, + userId: getAuthenticatedUserId(authenticatedRequest), + }); + }, + ); + + return app; +} + +describe('authentication and authorization guards', () => { + it('returns 401 when a protected request has no authenticated session', async () => { + const userOwnsResource = vi.fn(() => Promise.resolve(true)); + const app = createProtectedTestApp(userOwnsResource); + + const response = await request(app).get('/protected/routine-123'); + + expect(response.status).toBe(401); + expect(response.body).toEqual({ error: 'authentication_required' }); + expect(userOwnsResource).not.toHaveBeenCalled(); + }); + + it('uses the session user ID for an allowed ownership check', async () => { + const userOwnsResource = vi.fn(() => Promise.resolve(true)); + const app = createProtectedTestApp(userOwnsResource); + + const response = await request(app) + .get('/protected/routine-123') + .set(withAuthenticatedTestUser('owner-123')); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + resourceId: 'routine-123', + userId: 'owner-123', + }); + expect(userOwnsResource).toHaveBeenCalledWith('routine-123', 'owner-123'); + }); + + it('returns 403 for authenticated cross-user access', async () => { + const userOwnsResource = vi.fn((_resourceId, userId) => + Promise.resolve(userId === 'owner-123'), + ); + const app = createProtectedTestApp(userOwnsResource); + + const response = await request(app) + .get('/protected/routine-123') + .set(withAuthenticatedTestUser('different-user')); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ error: 'forbidden' }); + }); + + it('does not let client-provided ownership override the session identity', async () => { + const userOwnsResource = vi.fn((_resourceId, userId) => + Promise.resolve(userId === 'owner-123'), + ); + const app = createProtectedTestApp(userOwnsResource); + + const response = await request(app) + .get('/protected/routine-123?userId=owner-123') + .set(withAuthenticatedTestUser('attacker-456')); + + expect(response.status).toBe(403); + expect(userOwnsResource).toHaveBeenCalledWith( + 'routine-123', + 'attacker-456', + ); + }); + + it('fails closed when ownership cannot be established', async () => { + const app = createProtectedTestApp(() => Promise.resolve(false)); + + const response = await request(app) + .get('/protected/missing-resource') + .set(withAuthenticatedTestUser('owner-123')); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ error: 'forbidden' }); + }); +}); diff --git a/apps/api/src/auth/guards/authorization.middleware.ts b/apps/api/src/auth/guards/authorization.middleware.ts new file mode 100644 index 0000000..4f7174b --- /dev/null +++ b/apps/api/src/auth/guards/authorization.middleware.ts @@ -0,0 +1,33 @@ +/** Enforces fail-closed authorization checks using the trusted session identity. */ +import type { RequestHandler } from 'express'; + +import { + isAuthenticatedRequest, + type AuthenticatedRequest, +} from './authentication.middleware.js'; + +export type AuthorizationCheck = ( + request: AuthenticatedRequest, +) => boolean | Promise; + +export function requireAuthorization( + isAuthorized: AuthorizationCheck, +): RequestHandler { + return async (request, response, next) => { + if (!isAuthenticatedRequest(request)) { + response.status(401).json({ error: 'authentication_required' }); + return; + } + + try { + if (!(await isAuthorized(request))) { + response.status(403).json({ error: 'forbidden' }); + return; + } + + next(); + } catch (error) { + next(error); + } + }; +} diff --git a/apps/api/src/test/authenticated-session.test-helper.ts b/apps/api/src/test/authenticated-session.test-helper.ts new file mode 100644 index 0000000..e861c9d --- /dev/null +++ b/apps/api/src/test/authenticated-session.test-helper.ts @@ -0,0 +1,18 @@ +/** Provides reusable test middleware for establishing a server-side session identity. */ +import type { RequestHandler } from 'express'; + +const TEST_USER_HEADER = 'x-test-authenticated-user-id'; + +export function establishAuthenticatedTestSession(): RequestHandler { + return (request, _response, next) => { + const userId = request.header(TEST_USER_HEADER); + if (userId) { + request.session.userId = userId; + } + next(); + }; +} + +export function withAuthenticatedTestUser(userId: string) { + return { [TEST_USER_HEADER]: userId }; +} diff --git a/apps/api/tsconfig.build.json b/apps/api/tsconfig.build.json index ca6dc16..4c32b5b 100644 --- a/apps/api/tsconfig.build.json +++ b/apps/api/tsconfig.build.json @@ -6,5 +6,5 @@ "sourceMap": true }, "include": ["src"], - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.test-helper.ts"] } diff --git a/docs/architecture.md b/docs/architecture.md index a512ba2..ce6c65e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -766,6 +766,12 @@ A user must not be able to access or modify another user's: Client-side hiding of UI elements is not authorization. +Protected endpoints return `401 Unauthorized` when no valid authenticated +session identity exists. They return `403 Forbidden` when an authenticated user +is not allowed to perform the requested action. Resource queries and ownership +checks must use the user ID established from the server-side session; ownership +identifiers supplied by the client are never authoritative. + # 28. Guest Architecture