Skip to content
Open
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
197 changes: 197 additions & 0 deletions src/middleware/auth.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | number>,
body: '',
writeHead: vi.fn(function (this: typeof res, code: number, headers: Record<string, string | number>) {
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<typeof mockResponse> & 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();
});
});
92 changes: 92 additions & 0 deletions src/middleware/auth.ts
Original file line number Diff line number Diff line change
@@ -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 <token> 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 <token>" — 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)));
}
}
};
}
Loading
Loading