From f27d87531c04e131daa930b83ef0d1644daae3cf Mon Sep 17 00:00:00 2001 From: Chris Smith Date: Wed, 30 Sep 2026 09:43:54 -0400 Subject: [PATCH] Add auth role to api --- api/src/auth.middleware.test.ts | 59 +++++++++++++++++++++ api/src/auth.middleware.ts | 53 +++++++++++++++++++ api/src/auth.router.test.ts | 88 +++++++++++++++++++++++++++++++ api/src/auth.router.ts | 92 +++++++++++++++++++++++++++++++++ api/src/auth.service.test.ts | 39 ++++++++++++++ api/src/auth.service.ts | 33 ++++++++++++ 6 files changed, 364 insertions(+) create mode 100644 api/src/auth.middleware.test.ts create mode 100644 api/src/auth.middleware.ts create mode 100644 api/src/auth.router.test.ts create mode 100644 api/src/auth.router.ts create mode 100644 api/src/auth.service.test.ts create mode 100644 api/src/auth.service.ts diff --git a/api/src/auth.middleware.test.ts b/api/src/auth.middleware.test.ts new file mode 100644 index 0000000..322d029 --- /dev/null +++ b/api/src/auth.middleware.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Request, Response } from 'express'; +import { Pool } from 'pg'; +import { createAuthMiddleware } from './auth.middleware.ts'; + +function createMockResponse() { + const res: Partial = {}; + const data = { statusCode: 200, jsonPayload: null as any }; + + res.status = function (code: number) { data.statusCode = code; return this as Response; }; + res.json = function (payload: any) { data.jsonPayload = payload; return this as Response; }; + return { mockRes: res as Response, data }; +} + +function createMockDb(sessionRow: any) { + return { + query: async (text: string, values: any[]) => { + return { rows: sessionRow ? [sessionRow] : [] }; + } + } as unknown as Pool; +} + +describe('Auth Middleware (TDD)', () => { + it('should return 401 Unauthorized if no session cookie is attached to the request', async () => { + const mockDb = createMockDb(null); + const middleware = createAuthMiddleware(mockDb); + + const mockReq = { headers: {} } as unknown as Request; // Missing header/cookie structures + const { mockRes, data } = createMockResponse(); + let nextCalled = false; + + await middleware(mockReq, mockRes, () => { nextCalled = true; }); + + assert.equal(data.statusCode, 401); + assert.equal(data.jsonPayload.success, false); + assert.equal(nextCalled, false); // Blocked early + }); + + it('should return 401 Unauthorized if the session token has expired', async () => { + // Return a mock session row that expired 1 hour ago + const pastDate = new Date(Date.now() - 3600000); + const mockDb = createMockDb({ user_id: 1, expires_at: pastDate }); + const middleware = createAuthMiddleware(mockDb); + + // Simulate an Express request passing an expired cookie string + const mockReq = { + headers: { cookie: 'session_token=expired-token-string' } + } as unknown as Request; + + const { mockRes, data } = createMockResponse(); + let nextCalled = false; + + await middleware(mockReq, mockRes, () => { nextCalled = true; }); + + assert.equal(data.statusCode, 401); + assert.equal(nextCalled, false); + }); +}); diff --git a/api/src/auth.middleware.ts b/api/src/auth.middleware.ts new file mode 100644 index 0000000..2f88734 --- /dev/null +++ b/api/src/auth.middleware.ts @@ -0,0 +1,53 @@ +import { Request, Response, NextFunction } from 'express'; +import { Pool } from 'pg'; + +// Helper utility to parse cookies manually without adding extra npm packages +function parseCookieString(cookieHeader: string | undefined, name: string): string | null { + if (!cookieHeader) return null; + const match = cookieHeader.match(new RegExp('(^| )' + name + '=([^;]+)')); + return match ? match[2] : null; +} + +export function createAuthMiddleware(db: Pool) { + return async (req: Request, res: Response, next: NextFunction): Promise => { + try { + const cookieHeader = req.headers.cookie; + const token = parseCookieString(cookieHeader, 'session_token'); + + if (!token) { + res.status(401).json({ success: false, error: 'Unauthorized. Session cookie missing.' }); + return; + } + + // Query the database to find an active session matching the token + const query = ` + SELECT user_id, expires_at + FROM sessions + WHERE id = $1; + `; + const result = await db.query(query, [token]); + + if (result.rows.length === 0) { + res.status(401).json({ success: false, error: 'Unauthorized. Invalid session token.' }); + return; + } + + const session = result.rows[0]; + const now = new Date(); + + // Validate expiration constraint + if (new Date(session.expires_at) < now) { + res.status(401).json({ success: false, error: 'Unauthorized. Session expired.' }); + return; + } + + // Inject the authenticated identity directly into the Request object for use in down-stream router handlers + (req as any).userId = session.user_id; + + next(); // Execution matches, pass cleanly to route target + } catch (error) { + console.error('Auth middleware failure:', error); + res.status(500).json({ success: false, error: 'Internal Server Error' }); + } + }; +} diff --git a/api/src/auth.router.test.ts b/api/src/auth.router.test.ts new file mode 100644 index 0000000..8742213 --- /dev/null +++ b/api/src/auth.router.test.ts @@ -0,0 +1,88 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createAuthRouter } from './auth.router.ts'; +import { AuthService } from './auth.service.ts'; +import { Pool } from 'pg'; +import { Request, Response } from 'express'; + +function createMockResponse() { + const res: Partial = {}; + const data = { statusCode: 200, jsonPayload: null as any, cookies: {} as any }; + + res.status = function (code: number) { data.statusCode = code; return this as Response; }; + res.json = function (payload: any) { data.jsonPayload = payload; return this as Response; }; + res.cookie = function (name: string, val: string, options: any) { + data.cookies[name] = { val, options }; + return this as Response; + }; + return { mockRes: res as Response, data }; +} + +function createMockDb(userRows: any[], sessionRows: any[] = []) { + return { + query: async (text: string, values: any[]) => { + if (text.trim().startsWith('SELECT') && text.includes('FROM users')) { + return { rows: userRows }; + } + return { rows: sessionRows }; + } + } as unknown as Pool; +} + +describe('Auth Router (TDD)', () => { + const authService = new AuthService(); + + describe('POST /register', () => { + it('should block registrations missing username or password with a 400 status', async () => { + const mockDb = createMockDb([]); + const router = createAuthRouter(mockDb, authService); + + const mockReq = { body: { username: '' } } as unknown as Request; + const { mockRes, data } = createMockResponse(); + + const handler = router.stack.find((l: any) => l.route.path === '/register').route.stack[0].handle; + await handler(mockReq, mockRes); + + assert.equal(data.statusCode, 400); + assert.equal(data.jsonPayload.success, false); + }); + }); + + describe('POST /login', () => { + it('should successfully issue a high-entropy cookie on valid credentials', async () => { + const password = 'secure-password'; + const hash = await authService.hashPassword(password); + + // Simulate database finding the registered user + const mockDb = createMockDb([{ id: 42, username: 'testuser', password_hash: hash }]); + const router = createAuthRouter(mockDb, authService); + + const mockReq = { body: { username: 'testuser', password } } as unknown as Request; + const { mockRes, data } = createMockResponse(); + + const handler = router.stack.find((l: any) => l.route.path === '/login').route.stack[0].handle; + await handler(mockReq, mockRes); + + assert.equal(data.statusCode, 200); + assert.equal(data.jsonPayload.success, true); + assert.ok(data.cookies['session_token']); + assert.equal(data.cookies['session_token'].options.httpOnly, true); + assert.equal(data.cookies['session_token'].options.sameSite, 'strict'); + }); + + it('should reject invalid passwords with a 401 status code', async () => { + const hash = await authService.hashPassword('real-password'); + const mockDb = createMockDb([{ id: 42, username: 'testuser', password_hash: hash }]); + const router = createAuthRouter(mockDb, authService); + + const mockReq = { body: { username: 'testuser', password: 'wrong-password' } } as unknown as Request; + const { mockRes, data } = createMockResponse(); + + const handler = router.stack.find((l: any) => l.route.path === '/login').route.stack[0].handle; + await handler(mockReq, mockRes); + + assert.equal(data.statusCode, 401); + assert.equal(data.jsonPayload.success, false); + }); + }); +}); diff --git a/api/src/auth.router.ts b/api/src/auth.router.ts new file mode 100644 index 0000000..f6a4f40 --- /dev/null +++ b/api/src/auth.router.ts @@ -0,0 +1,92 @@ +import { Router, Request, Response } from 'express'; +import { Pool } from 'pg'; +import { AuthService } from './auth.service.ts'; + +export function createAuthRouter(db: Pool, authService: AuthService): Router { + const router = Router(); + + /** + * POST /api/auth/register + */ + router.post('/register', async (req: Request, res: Response): Promise => { + try { + const { username, password } = req.body; + + if (!username || !password || username.trim() === '' || password.length < 8) { + res.status(400).json({ success: false, error: 'Username required, and password must be at least 8 characters long.' }); + return; + } + + // Check if user already exists + const checkUser = await db.query('SELECT id FROM users WHERE username = $1;', [username]); + if (checkUser.rows.length > 0) { + res.status(409).json({ success: false, error: 'Username is already taken.' }); + return; + } + + // Hash password using Argon2id + const hash = await authService.hashPassword(password); + + // Save user + await db.query('INSERT INTO users (username, password_hash) VALUES ($1, $2);', [username, hash]); + + res.status(201).json({ success: true, message: 'User registered successfully!' }); + } catch (error) { + console.error('Registration failure:', error); + res.status(500).json({ success: false, error: 'Internal Server Error' }); + } + }); + + /** + * POST /api/auth/login + */ + router.post('/login', async (req: Request, res: Response): Promise => { + try { + const { username, password } = req.body; + + if (!username || !password) { + res.status(400).json({ success: false, error: 'Username and password are required.' }); + return; + } + + const result = await db.query('SELECT id, password_hash FROM users WHERE username = $1;', [username]); + if (result.rows.length === 0) { + res.status(401).json({ success: false, error: 'Invalid username or password.' }); + return; + } + + const user = result.rows[0]; + const validPassword = await authService.verifyPassword(password, user.password_hash); + + if (!validPassword) { + res.status(401).json({ success: false, error: 'Invalid username or password.' }); + return; + } + + // Generate a high-entropy session token + const sessionToken = authService.generateSessionToken(); + const expiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); // Expires in 30 days + + // Store the session securely in the database + await db.query( + 'INSERT INTO sessions (id, user_id, expires_at) VALUES ($1, $2, $3);', + [sessionToken, user.id, expiresAt] + ); + + // Issue the secure HttpOnly cookie + res.cookie('session_token', sessionToken, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', // Requires HTTPS in production + sameSite: 'strict', + expires: expiresAt, + }); + + res.json({ success: true, message: 'Logged in successfully!' }); + } catch (error) { + console.error('Login failure:', error); + res.status(500).json({ success: false, error: 'Internal Server Error' }); + } + }); + + return router; +} diff --git a/api/src/auth.service.test.ts b/api/src/auth.service.test.ts new file mode 100644 index 0000000..c433597 --- /dev/null +++ b/api/src/auth.service.test.ts @@ -0,0 +1,39 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { AuthService } from './auth.service.ts'; + +describe('AuthService (TDD)', () => { + const authService = new AuthService(); + + describe('Password Hashing & Verification', () => { + it('should hash a raw password string and verify it successfully', async () => { + const password = 'my-super-secure-password'; + const hash = await authService.hashPassword(password); + + // Verify the hash is distinct and obfuscated + assert.notEqual(hash, password); + assert.ok(hash.startsWith('$argon2id$')); // Confirms it uses the Argon2id standard + + // Verify correct verification resolves true + const isValid = await authService.verifyPassword(password, hash); + assert.equal(isValid, true); + }); + + it('should reject validation if the password string does not match the hash', async () => { + const hash = await authService.hashPassword('correct-password'); + const isValid = await authService.verifyPassword('wrong-password', hash); + assert.equal(isValid, false); + }); + }); + + describe('Session Token Generation', () => { + it('should generate high-entropy random session tokens', () => { + const token1 = authService.generateSessionToken(); + const token2 = authService.generateSessionToken(); + + assert.equal(typeof token1, 'string'); + assert.equal(token1.length, 64); // Uses a 32-byte hex representation + assert.notEqual(token1, token2); // Tokens must never collide + }); + }); +}); diff --git a/api/src/auth.service.ts b/api/src/auth.service.ts new file mode 100644 index 0000000..144a3f7 --- /dev/null +++ b/api/src/auth.service.ts @@ -0,0 +1,33 @@ +import argon2 from 'argon2'; +import crypto from 'node:crypto'; + +export class AuthService { + /** + * Hashes a raw password using the Argon2id industry standard. + */ + async hashPassword(password: string): Promise { + return argon2.hash(password, { + type: argon2.argon2id, // Strongest configuration variant against timing attacks + memoryCost: 2 ** 16, // 64MB memory utilization block + timeCost: 3, // 3 computational passes + }); + } + + /** + * Cryptographically verifies a password against a known hash. + */ + async verifyPassword(password: string, hash: string): Promise { + try { + return await argon2.verify(hash, password); + } catch { + return false; // Safely catches malformed hashes without crashing + } + } + + /** + * Generates a unique, high-entropy 64-character hex session token. + */ + generateSessionToken(): string { + return crypto.randomBytes(32).toString('hex'); + } +}