124 lines
3.8 KiB
TypeScript
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',
|
|
);
|
|
});
|
|
});
|