diff --git a/src/middleware/auth.test.ts b/src/middleware/auth.test.ts new file mode 100644 index 0000000..883aca2 --- /dev/null +++ b/src/middleware/auth.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, beforeAll, vi } from 'vitest'; +import { generateKeyPairSync } from 'node:crypto'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { signToken } from './jwt.js'; +import { createAuthMiddleware, type AuthenticatedRequest } from './auth.js'; + +let privateKey: string; +let publicKey: string; + +beforeAll(() => { + const { privateKey: priv, publicKey: pub } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + privateKey = priv.export({ type: 'pkcs8', format: 'pem' }) as string; + publicKey = pub.export({ type: 'spki', format: 'pem' }) as string; +}); + +/** Build a minimal mock IncomingMessage with the given Authorization header. */ +function mockRequest(authHeader?: string): IncomingMessage { + return { + headers: authHeader ? { authorization: authHeader } : {}, + } as IncomingMessage; +} + +/** Build a mock ServerResponse that records the status code and body. */ +function mockResponse() { + const res = { + statusCode: 0, + headers: {} as Record, + body: '', + writeHead: vi.fn(function (this: typeof res, code: number, headers: Record) { + this.statusCode = code; + Object.assign(this.headers, headers); + }), + end: vi.fn(function (this: typeof res, data: string) { + this.body = data; + }), + }; + // bind so `this` works in vi.fn callbacks + res.writeHead = res.writeHead.bind(res); + res.end = res.end.bind(res); + return res as unknown as ReturnType & ServerResponse; +} + +describe('createAuthMiddleware — token accepted', () => { + it('should call next() and attach auth when a valid token is supplied', () => { + const middleware = createAuthMiddleware(publicKey); + const token = signToken({ sub: 'u1' }, privateKey, { expiresIn: 60 }); + const req = mockRequest(`Bearer ${token}`); + const res = mockResponse(); + const next = vi.fn(); + + middleware(req, res as unknown as ServerResponse, next); + + expect(next).toHaveBeenCalledOnce(); + expect(next).toHaveBeenCalledWith(/* no error */); + expect((req as AuthenticatedRequest).auth.payload.sub).toBe('u1'); + expect(res.writeHead).not.toHaveBeenCalled(); + }); + + it('should validate issuer and audience when options are provided', () => { + const middleware = createAuthMiddleware(publicKey, { + issuer: 'opendev', + audience: 'api', + }); + const token = signToken({}, privateKey, { + expiresIn: 60, + issuer: 'opendev', + audience: 'api', + }); + const req = mockRequest(`Bearer ${token}`); + const res = mockResponse(); + const next = vi.fn(); + + middleware(req, res as unknown as ServerResponse, next); + + expect(next).toHaveBeenCalledOnce(); + }); +}); + +describe('createAuthMiddleware — missing or malformed header', () => { + it('should respond 401 when there is no Authorization header', () => { + const middleware = createAuthMiddleware(publicKey); + const req = mockRequest(); // no header + const res = mockResponse(); + const next = vi.fn(); + + middleware(req, res as unknown as ServerResponse, next); + + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('should respond 401 when the scheme is not Bearer', () => { + const middleware = createAuthMiddleware(publicKey); + const req = mockRequest('Basic dXNlcjpwYXNz'); + const res = mockResponse(); + const next = vi.fn(); + + middleware(req, res as unknown as ServerResponse, next); + + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('should include WWW-Authenticate header in 401 responses', () => { + const middleware = createAuthMiddleware(publicKey); + const res = mockResponse(); + + middleware(mockRequest(), res as unknown as ServerResponse, vi.fn()); + + expect(res.headers['WWW-Authenticate']).toContain('Bearer'); + }); + + it('should respond with JSON body on 401', () => { + const middleware = createAuthMiddleware(publicKey); + const res = mockResponse(); + + middleware(mockRequest(), res as unknown as ServerResponse, vi.fn()); + + const body = JSON.parse(res.body); + expect(body).toHaveProperty('error', 'Unauthorized'); + expect(body).toHaveProperty('reason'); + }); +}); + +describe('createAuthMiddleware — invalid token', () => { + it('should respond 401 for an expired token', () => { + const middleware = createAuthMiddleware(publicKey); + const token = signToken({ exp: Math.floor(Date.now() / 1000) - 10 }, privateKey); + const req = mockRequest(`Bearer ${token}`); + const res = mockResponse(); + const next = vi.fn(); + + middleware(req, res as unknown as ServerResponse, next); + + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + expect(JSON.parse(res.body).reason).toMatch(/expired/i); + }); + + it('should respond 401 for a token with an invalid signature', () => { + const { publicKey: otherPub } = generateKeyPairSync('rsa', { modulusLength: 2048 }); + const middleware = createAuthMiddleware( + otherPub.export({ type: 'spki', format: 'pem' }) as string, + ); + const token = signToken({ sub: 'u1' }, privateKey, { expiresIn: 60 }); + const req = mockRequest(`Bearer ${token}`); + const res = mockResponse(); + const next = vi.fn(); + + middleware(req, res as unknown as ServerResponse, next); + + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); + + it('should respond 401 when issuer does not match', () => { + const middleware = createAuthMiddleware(publicKey, { issuer: 'opendev' }); + const token = signToken({ iss: 'impostor' }, privateKey, { expiresIn: 60 }); + const req = mockRequest(`Bearer ${token}`); + const res = mockResponse(); + const next = vi.fn(); + + middleware(req, res as unknown as ServerResponse, next); + + expect(res.statusCode).toBe(401); + }); +}); + +describe('createAuthMiddleware — custom tokenExtractor', () => { + it('should use the provided extractor instead of the Authorization header', () => { + const token = signToken({ sub: 'cookie-user' }, privateKey, { expiresIn: 60 }); + const middleware = createAuthMiddleware(publicKey, { + tokenExtractor: () => token, + }); + const req = mockRequest(); // no Authorization header + const res = mockResponse(); + const next = vi.fn(); + + middleware(req, res as unknown as ServerResponse, next); + + expect(next).toHaveBeenCalledOnce(); + expect((req as AuthenticatedRequest).auth.payload.sub).toBe('cookie-user'); + }); + + it('should respond 401 when the custom extractor returns null', () => { + const middleware = createAuthMiddleware(publicKey, { + tokenExtractor: () => null, + }); + const res = mockResponse(); + const next = vi.fn(); + + middleware(mockRequest(), res as unknown as ServerResponse, next); + + expect(res.statusCode).toBe(401); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..40df33f --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,92 @@ +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { verifyToken, JwtError, type VerifiedToken, type VerifyOptions } from './jwt.js'; + +export interface AuthenticatedRequest extends IncomingMessage { + /** Decoded and verified JWT, attached by createAuthMiddleware */ + auth: VerifiedToken; +} + +/** Express / Node http compatible next() signature */ +export type NextFn = (err?: Error) => void; + +export type AuthMiddleware = ( + req: IncomingMessage, + res: ServerResponse, + next: NextFn, +) => void; + +export interface AuthMiddlewareOptions extends VerifyOptions { + /** + * Override how the raw JWT string is extracted from a request. + * Defaults to the Authorization: Bearer header. + */ + tokenExtractor?: (req: IncomingMessage) => string | null; +} + +function bearerTokenExtractor(req: IncomingMessage): string | null { + const header = req.headers['authorization']; + if (!header) return null; + + // Must be exactly "Bearer " — no other scheme accepted + const spaceIndex = header.indexOf(' '); + if (spaceIndex === -1) return null; + + const scheme = header.slice(0, spaceIndex); + if (scheme.toLowerCase() !== 'bearer') return null; + + const token = header.slice(spaceIndex + 1).trim(); + return token.length > 0 ? token : null; +} + +function rejectUnauthorized(res: ServerResponse, reason: string): void { + const body = JSON.stringify({ error: 'Unauthorized', reason }); + res.writeHead(401, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + // Tell clients this endpoint requires Bearer tokens + 'WWW-Authenticate': 'Bearer realm="opendev"', + }); + res.end(body); +} + +/** + * Creates HTTP middleware that enforces RS256 JWT authentication. + * + * On success: attaches the decoded token to req.auth and calls next(). + * On failure: responds with 401 JSON and does NOT call next(). + * + * @param publicKey PEM-encoded RSA public key used to verify signatures. + * @param options Optional issuer/audience validation and token extraction. + */ +export function createAuthMiddleware( + publicKey: string, + options: AuthMiddlewareOptions = {}, +): AuthMiddleware { + const { tokenExtractor = bearerTokenExtractor, ...verifyOptions } = options; + + return function authMiddleware( + req: IncomingMessage, + res: ServerResponse, + next: NextFn, + ): void { + const token = tokenExtractor(req); + + if (!token) { + rejectUnauthorized(res, 'Missing or malformed Authorization header'); + return; + } + + try { + const verified = verifyToken(token, publicKey, verifyOptions); + (req as AuthenticatedRequest).auth = verified; + next(); + } catch (err) { + if (err instanceof JwtError) { + rejectUnauthorized(res, err.message); + } else { + // Unexpected error — pass to Express/Node error handler + next(err instanceof Error ? err : new Error(String(err))); + } + } + }; +} diff --git a/src/middleware/jwt.test.ts b/src/middleware/jwt.test.ts new file mode 100644 index 0000000..5f64829 --- /dev/null +++ b/src/middleware/jwt.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect, beforeAll } from 'vitest'; +import { generateKeyPairSync } from 'node:crypto'; +import { signToken, verifyToken, JwtError } from './jwt.js'; + +let privateKey: string; +let publicKey: string; +let foreignPublicKey: string; + +beforeAll(() => { + const primary = generateKeyPairSync('rsa', { modulusLength: 2048 }); + privateKey = primary.privateKey.export({ type: 'pkcs8', format: 'pem' }) as string; + publicKey = primary.publicKey.export({ type: 'spki', format: 'pem' }) as string; + + // A second key pair used to test signature rejection + const foreign = generateKeyPairSync('rsa', { modulusLength: 2048 }); + foreignPublicKey = foreign.publicKey.export({ type: 'spki', format: 'pem' }) as string; +}); + +describe('signToken', () => { + it('should produce a three-part base64url string', () => { + const token = signToken({ sub: 'u1' }, privateKey); + const parts = token.split('.'); + expect(parts).toHaveLength(3); + parts.forEach((p) => expect(p).toMatch(/^[A-Za-z0-9_-]+$/)); + }); + + it('should embed iat automatically', () => { + const before = Math.floor(Date.now() / 1000); + const token = signToken({}, privateKey); + const { payload } = verifyToken(token, publicKey); + expect(payload.iat).toBeGreaterThanOrEqual(before); + expect(payload.iat).toBeLessThanOrEqual(Math.floor(Date.now() / 1000)); + }); + + it('should embed exp when expiresIn is provided', () => { + const token = signToken({}, privateKey, { expiresIn: 3600 }); + const { payload } = verifyToken(token, publicKey); + expect(payload.exp).toBeDefined(); + expect(payload.exp! - payload.iat!).toBe(3600); + }); + + it('should embed iss, aud, sub from options', () => { + const token = signToken({}, privateKey, { + issuer: 'opendev', + audience: 'api', + subject: 'user:42', + }); + const { payload } = verifyToken(token, publicKey); + expect(payload.iss).toBe('opendev'); + expect(payload.aud).toBe('api'); + expect(payload.sub).toBe('user:42'); + }); + + it('should allow arbitrary custom claims in the payload', () => { + const token = signToken({ role: 'admin', orgId: 99 }, privateKey); + const { payload } = verifyToken(token, publicKey); + expect(payload.role).toBe('admin'); + expect(payload.orgId).toBe(99); + }); +}); + +describe('verifyToken — happy path', () => { + it('should return header and payload on a valid token', () => { + const token = signToken({ sub: 'u1' }, privateKey, { expiresIn: 60 }); + const result = verifyToken(token, publicKey); + expect(result.header.alg).toBe('RS256'); + expect(result.header.typ).toBe('JWT'); + expect(result.payload.sub).toBe('u1'); + }); + + it('should accept an audience array when token aud matches any element', () => { + const token = signToken({ aud: 'api' }, privateKey); + expect(() => verifyToken(token, publicKey, { audience: ['api', 'admin'] })).not.toThrow(); + }); + + it('should accept a token whose aud is an array containing the expected audience', () => { + const token = signToken({ aud: ['api', 'admin'] }, privateKey); + expect(() => verifyToken(token, publicKey, { audience: 'admin' })).not.toThrow(); + }); + + it('should tolerate clock skew on an already-expired token when clockSkew is set', () => { + // exp 5 seconds ago — within 10-second skew tolerance + const token = signToken({ exp: Math.floor(Date.now() / 1000) - 5 }, privateKey); + expect(() => verifyToken(token, publicKey, { clockSkew: 10 })).not.toThrow(); + }); +}); + +describe('verifyToken — signature errors', () => { + it('should throw INVALID_SIGNATURE when verified with the wrong public key', () => { + const token = signToken({ sub: 'u1' }, privateKey); + const err = (() => { + try { + verifyToken(token, foreignPublicKey); + } catch (e) { + return e; + } + })(); + expect(err).toBeInstanceOf(JwtError); + expect((err as JwtError).code).toBe('INVALID_SIGNATURE'); + }); + + it('should throw INVALID_SIGNATURE when the signature segment is tampered with', () => { + const token = signToken({ sub: 'u1' }, privateKey); + const parts = token.split('.'); + const tampered = `${parts[0]}.${parts[1]}.aW52YWxpZA`; // "invalid" in base64url + expect(() => verifyToken(tampered, publicKey)).toThrowError( + expect.objectContaining({ code: 'INVALID_SIGNATURE' }), + ); + }); + + it('should throw INVALID_SIGNATURE when the payload is tampered with', () => { + const token = signToken({ role: 'user' }, privateKey); + const [h, , s] = token.split('.'); + // Replace payload with a forged one claiming admin role + const forgedPayload = Buffer.from(JSON.stringify({ role: 'admin' })).toString('base64url'); + expect(() => verifyToken(`${h}.${forgedPayload}.${s}`, publicKey)).toThrowError( + expect.objectContaining({ code: 'INVALID_SIGNATURE' }), + ); + }); +}); + +describe('verifyToken — structural errors', () => { + it('should throw MALFORMED when the token has fewer than 3 parts', () => { + expect(() => verifyToken('only.two', publicKey)).toThrowError( + expect.objectContaining({ code: 'MALFORMED' }), + ); + }); + + it('should throw MALFORMED when the token is an empty string', () => { + expect(() => verifyToken('', publicKey)).toThrowError( + expect.objectContaining({ code: 'MALFORMED' }), + ); + }); + + it('should throw MALFORMED when the algorithm is not RS256', () => { + // Manually craft a token with alg:HS256 to test algorithm lock + const fakeHeader = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url'); + const fakePayload = Buffer.from(JSON.stringify({ sub: 'u' })).toString('base64url'); + expect(() => verifyToken(`${fakeHeader}.${fakePayload}.fakesig`, publicKey)).toThrowError( + expect.objectContaining({ code: 'MALFORMED' }), + ); + }); +}); + +describe('verifyToken — claim validation', () => { + it('should throw EXPIRED when exp is in the past', () => { + const token = signToken({ exp: Math.floor(Date.now() / 1000) - 1 }, privateKey); + expect(() => verifyToken(token, publicKey)).toThrowError( + expect.objectContaining({ code: 'EXPIRED' }), + ); + }); + + it('should throw NOT_YET_VALID when nbf is in the future', () => { + const token = signToken({ nbf: Math.floor(Date.now() / 1000) + 300 }, privateKey); + expect(() => verifyToken(token, publicKey)).toThrowError( + expect.objectContaining({ code: 'NOT_YET_VALID' }), + ); + }); + + it('should throw INVALID_ISSUER when iss does not match', () => { + const token = signToken({ iss: 'other-service' }, privateKey); + expect(() => verifyToken(token, publicKey, { issuer: 'opendev' })).toThrowError( + expect.objectContaining({ code: 'INVALID_ISSUER' }), + ); + }); + + it('should throw INVALID_AUDIENCE when aud does not match', () => { + const token = signToken({ aud: 'api' }, privateKey); + expect(() => verifyToken(token, publicKey, { audience: 'admin' })).toThrowError( + expect.objectContaining({ code: 'INVALID_AUDIENCE' }), + ); + }); +}); diff --git a/src/middleware/jwt.ts b/src/middleware/jwt.ts new file mode 100644 index 0000000..31daac9 --- /dev/null +++ b/src/middleware/jwt.ts @@ -0,0 +1,185 @@ +import { createSign, createVerify, KeyObject } from 'node:crypto'; + +export type JwtErrorCode = + | 'MALFORMED' + | 'EXPIRED' + | 'NOT_YET_VALID' + | 'INVALID_SIGNATURE' + | 'INVALID_AUDIENCE' + | 'INVALID_ISSUER'; + +export class JwtError extends Error { + readonly code: JwtErrorCode; + + constructor(message: string, code: JwtErrorCode) { + super(message); + this.name = 'JwtError'; + this.code = code; + } +} + +export interface JwtHeader { + alg: 'RS256'; + typ: 'JWT'; +} + +export interface JwtPayload { + iss?: string; + sub?: string; + aud?: string | string[]; + exp?: number; + nbf?: number; + iat?: number; + jti?: string; + [key: string]: unknown; +} + +export interface VerifiedToken { + header: JwtHeader; + payload: JwtPayload; +} + +export interface SignOptions { + /** Token lifetime in seconds */ + expiresIn?: number; + issuer?: string; + audience?: string | string[]; + subject?: string; +} + +export interface VerifyOptions { + issuer?: string; + audience?: string | string[]; + /** Clock skew tolerance in seconds (default: 0) */ + clockSkew?: number; +} + +function base64urlEncode(input: Buffer | string): string { + const buf = typeof input === 'string' ? Buffer.from(input, 'utf8') : input; + return buf.toString('base64url'); +} + +function base64urlDecode(input: string): Buffer { + // base64url → base64: replace URL-safe chars, restore padding + const standard = input.replace(/-/g, '+').replace(/_/g, '/'); + const padded = standard + '='.repeat((4 - (standard.length % 4)) % 4); + return Buffer.from(padded, 'base64'); +} + +function safeJsonParse(buf: Buffer, context: string): T { + try { + return JSON.parse(buf.toString('utf8')) as T; + } catch { + throw new JwtError(`Malformed token: invalid ${context} encoding`, 'MALFORMED'); + } +} + +/** + * Signs a JWT using RS256 (RSA + SHA-256). + * The private key must be a PEM-encoded PKCS#8 or PKCS#1 RSA key, or a KeyObject. + */ +export function signToken( + payload: JwtPayload, + privateKey: string | KeyObject, + options: SignOptions = {}, +): string { + const now = Math.floor(Date.now() / 1000); + + const claims: JwtPayload = { iat: now, ...payload }; + + if (options.expiresIn !== undefined) claims.exp = now + options.expiresIn; + if (options.issuer !== undefined) claims.iss = options.issuer; + if (options.audience !== undefined) claims.aud = options.audience; + if (options.subject !== undefined) claims.sub = options.subject; + + const header: JwtHeader = { alg: 'RS256', typ: 'JWT' }; + const encodedHeader = base64urlEncode(JSON.stringify(header)); + const encodedPayload = base64urlEncode(JSON.stringify(claims)); + const signingInput = `${encodedHeader}.${encodedPayload}`; + + const signer = createSign('RSA-SHA256'); + signer.update(signingInput, 'utf8'); + const rawSignature = signer.sign(privateKey); + + return `${signingInput}.${base64urlEncode(rawSignature)}`; +} + +/** + * Verifies an RS256 JWT and returns the decoded header and payload. + * Throws JwtError with a specific code for every failure mode so callers + * can respond differently (e.g. 401 vs 403). + */ +export function verifyToken( + token: string, + publicKey: string | KeyObject, + options: VerifyOptions = {}, +): VerifiedToken { + const parts = token.split('.'); + if (parts.length !== 3) { + throw new JwtError('Malformed token: expected header.payload.signature', 'MALFORMED'); + } + + const [encodedHeader, encodedPayload, encodedSignature] = parts as [string, string, string]; + + const header = safeJsonParse(base64urlDecode(encodedHeader), 'header'); + if (header.alg !== 'RS256') { + // Reject algorithm substitution attacks + throw new JwtError( + `Unsupported algorithm "${header.alg}": only RS256 is accepted`, + 'MALFORMED', + ); + } + + const signingInput = `${encodedHeader}.${encodedPayload}`; + const rawSignature = base64urlDecode(encodedSignature); + + const verifier = createVerify('RSA-SHA256'); + verifier.update(signingInput, 'utf8'); + + let signatureValid: boolean; + try { + signatureValid = verifier.verify(publicKey, rawSignature); + } catch { + // createVerify throws when the key is malformed, treat as invalid sig + signatureValid = false; + } + + if (!signatureValid) { + throw new JwtError('Token signature is invalid', 'INVALID_SIGNATURE'); + } + + const payload = safeJsonParse(base64urlDecode(encodedPayload), 'payload'); + + const skew = options.clockSkew ?? 0; + const now = Math.floor(Date.now() / 1000); + + if (typeof payload.exp === 'number' && now > payload.exp + skew) { + throw new JwtError('Token has expired', 'EXPIRED'); + } + + if (typeof payload.nbf === 'number' && now < payload.nbf - skew) { + throw new JwtError('Token is not yet valid', 'NOT_YET_VALID'); + } + + if (options.issuer !== undefined && payload.iss !== options.issuer) { + throw new JwtError( + `Invalid issuer: expected "${options.issuer}", got "${String(payload.iss)}"`, + 'INVALID_ISSUER', + ); + } + + if (options.audience !== undefined) { + const expected = Array.isArray(options.audience) ? options.audience : [options.audience]; + const actual = Array.isArray(payload.aud) + ? payload.aud + : payload.aud !== undefined + ? [payload.aud] + : []; + const hasMatch = expected.some((e) => actual.includes(e)); + if (!hasMatch) { + throw new JwtError('Token audience does not match', 'INVALID_AUDIENCE'); + } + } + + return { header, payload }; +}