feat(auth): RS256 JWKS verification — NoteLett backend

This commit is contained in:
saravanakumardb1 2026-03-12 11:15:06 -07:00
parent 14aeeafe83
commit 8e2a1b37c3
4 changed files with 166 additions and 1 deletions

View File

@ -7,6 +7,7 @@ COSMOS_ENDPOINT=
COSMOS_KEY=
COSMOS_DATABASE=bytelyst
JWT_SECRET=
# PLATFORM_JWKS_URL=http://localhost:4003/auth/.well-known/jwks.json
DB_PROVIDER=cosmos
PRODUCT_ID=notelett
PLATFORM_SERVICE_URL=http://localhost:4003

View File

@ -0,0 +1,87 @@
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()));
});
describe('JWT verification — RS256 JWKS + HS256 fallback', () => {
it('should fall back to HS256 when JWKS unavailable', async () => {
mockConfig.PLATFORM_JWKS_URL = undefined;
const secret = new TextEncoder().encode(mockConfig.JWT_SECRET as string);
const token = await new SignJWT({
sub: 'user-456',
email: 'fallback@example.com',
role: 'admin',
type: 'access',
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(secret);
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');
});
});

View File

@ -1 +1,77 @@
export { extractAuth, requireRole, type AuthPayload } from '@bytelyst/auth';
/**
* JWT auth middleware RS256 JWKS verification with HS256 fallback.
* Tries RS256 via platform-service JWKS endpoint first (if PLATFORM_JWKS_URL
* is configured), then falls back to HS256 symmetric verification via JWT_SECRET.
*/
import { jwtVerify, createRemoteJWKSet } from 'jose';
import { UnauthorizedError, ForbiddenError } from '@bytelyst/errors';
import { config } from './config.js';
import type { AuthPayload } from '@bytelyst/auth';
export type { AuthPayload };
// Lazy-init JWKS client (cached, auto-refreshed by jose)
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
function getJWKS(): ReturnType<typeof createRemoteJWKSet> | null {
if (jwks) return jwks;
const url = config.PLATFORM_JWKS_URL;
if (!url) return null;
jwks = createRemoteJWKSet(new URL(url));
return jwks;
}
function getHmacSecret(): Uint8Array {
return new TextEncoder().encode(config.JWT_SECRET);
}
/**
* Extract and verify auth payload from an Authorization header.
* Tries RS256 via JWKS first, falls back to HS256.
*/
export async function extractAuth(req: {
headers: { authorization?: string };
}): Promise<AuthPayload> {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) {
throw new UnauthorizedError();
}
const token = auth.slice(7);
// Try RS256 via JWKS first
const remoteJWKS = getJWKS();
if (remoteJWKS) {
try {
const { payload } = await jwtVerify(token, remoteJWKS);
const p = payload as unknown as AuthPayload;
if (p.type !== 'access') throw new Error('Not an access token');
return p;
} catch {
// Fall through to HS256
}
}
// Fall back to HS256 (existing behavior)
try {
const { payload } = await jwtVerify(token, getHmacSecret());
const p = payload as unknown as AuthPayload;
if (p.type !== 'access') throw new Error('Not an access token');
return p;
} catch {
throw new UnauthorizedError('Invalid or expired token');
}
}
/**
* Require specific roles. Extracts auth first, then checks role.
*/
export async function requireRole(
req: { headers: { authorization?: string } },
...roles: string[]
): Promise<AuthPayload> {
const payload = await extractAuth(req);
if (roles.length > 0 && (!payload.role || !roles.includes(payload.role))) {
throw new ForbiddenError('Insufficient permissions');
}
return payload;
}

View File

@ -11,6 +11,7 @@ const envSchema = z.object({
COSMOS_KEY: z.string().min(1, 'COSMOS_KEY is required').optional(),
COSMOS_DATABASE: z.string().default('bytelyst'),
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
PLATFORM_JWKS_URL: z.string().url().optional(),
DB_PROVIDER: z.enum(['cosmos', 'memory']).default('cosmos'),
PRODUCT_ID: z.string().default(PRODUCT_ID),
PLATFORM_SERVICE_URL: z.string().default('http://localhost:4003'),