learning_ai_clock/backend/src/lib/auth.test.ts

124 lines
3.8 KiB
TypeScript

import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import { SignJWT, generateKeyPair, exportJWK } from 'jose';
import { createServer, type Server } from 'node:http';
const { mockConfig } = vi.hoisted(() => ({
mockConfig: {
JWT_SECRET: 'test-hs256-secret-at-least-32-chars-long',
PLATFORM_JWKS_URL: undefined as string | undefined,
} as Record<string, unknown>,
}));
vi.mock('./config.js', () => ({
config: new Proxy({} as Record<string, unknown>, {
get: (_target, prop: string) => mockConfig[prop],
}),
}));
import { extractAuth, requireRole } from './auth.js';
let rsaKeys: Awaited<ReturnType<typeof generateKeyPair>>;
let server: Server;
let jwksPort: number;
beforeAll(async () => {
rsaKeys = await generateKeyPair('RS256');
const publicJwk = await exportJWK(rsaKeys.publicKey);
publicJwk.alg = 'RS256';
publicJwk.use = 'sig';
publicJwk.kid = 'test-key-1';
server = createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ keys: [publicJwk] }));
});
await new Promise<void>(resolve => server.listen(0, resolve));
const addr = server.address();
jwksPort = typeof addr === 'object' && addr ? addr.port : 0;
});
afterAll(async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
});
function makeHS256Token(claims: Record<string, unknown>) {
const secret = new TextEncoder().encode(mockConfig.JWT_SECRET as string);
return new SignJWT(claims)
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(secret);
}
describe('JWT verification — RS256 JWKS + HS256 fallback', () => {
it('should fall back to HS256 when JWKS unavailable', async () => {
mockConfig.PLATFORM_JWKS_URL = undefined;
const token = await makeHS256Token({
sub: 'user-456',
email: 'fallback@example.com',
role: 'admin',
type: 'access',
});
const payload = await extractAuth({
headers: { authorization: `Bearer ${token}` },
});
expect(payload.sub).toBe('user-456');
expect(payload.role).toBe('admin');
});
it('should accept RS256 token verified via JWKS', async () => {
mockConfig.PLATFORM_JWKS_URL = `http://localhost:${jwksPort}`;
const token = await new SignJWT({
sub: 'user-123',
email: 'test@example.com',
role: 'user',
type: 'access',
})
.setProtectedHeader({ alg: 'RS256', kid: 'test-key-1' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(rsaKeys.privateKey);
const payload = await extractAuth({
headers: { authorization: `Bearer ${token}` },
});
expect(payload.sub).toBe('user-123');
expect(payload.role).toBe('user');
});
it('should throw UnauthorizedError when no Bearer token', async () => {
await expect(extractAuth({ headers: {} })).rejects.toThrow('Unauthorized');
await expect(
extractAuth({ headers: { authorization: 'Basic abc' } }),
).rejects.toThrow('Unauthorized');
});
it('should reject non-access token type', async () => {
mockConfig.PLATFORM_JWKS_URL = undefined;
const token = await makeHS256Token({ sub: 'user-1', type: 'refresh' });
await expect(
extractAuth({ headers: { authorization: `Bearer ${token}` } }),
).rejects.toThrow('Invalid or expired token');
});
it('should enforce role via requireRole', async () => {
mockConfig.PLATFORM_JWKS_URL = undefined;
const token = await makeHS256Token({
sub: 'user-1',
role: 'user',
type: 'access',
});
const req = { headers: { authorization: `Bearer ${token}` } };
const payload = await requireRole(req, 'user');
expect(payload.sub).toBe('user-1');
await expect(requireRole(req, 'admin')).rejects.toThrow(
'Insufficient permissions',
);
});
});