Add auth role to api

This commit is contained in:
chris committed 2026-09-30 09:43:54 -04:00
1 parent 9a476d9b86
commit f27d87531c
6 files changed
+364

No files matched your search

+59
View File
@@ -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<Response> = {};
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);
});
});
+53
View File
@@ -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<void> => {
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' });
}
};
}
+88
View File
@@ -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<Response> = {};
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);
});
});
});
+92
View File
@@ -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<void> => {
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<void> => {
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;
}
+39
View File
@@ -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
});
});
});
+33
View File
@@ -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<string> {
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<boolean> {
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');
}
}