feat(auth): RS256 JWKS verification — ChronoMind backend
This commit is contained in:
parent
8cc21d8586
commit
3d2ce9325f
@ -7,3 +7,6 @@ COSMOS_ENDPOINT=https://cosmos-mywisprai.documents.azure.com:443/
|
||||
COSMOS_KEY=
|
||||
COSMOS_DATABASE=lysnrai
|
||||
JWT_SECRET=
|
||||
|
||||
# RS256 JWKS verification (optional — falls back to HS256 if not set)
|
||||
# PLATFORM_JWKS_URL=http://localhost:4003/auth/.well-known/jwks.json
|
||||
|
||||
87
backend/src/lib/auth.test.ts
Normal file
87
backend/src/lib/auth.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -1,6 +1,77 @@
|
||||
/**
|
||||
* Re-export from @bytelyst/auth — shared across all services.
|
||||
* JWT auth middleware — validates tokens issued by platform-service.
|
||||
* Shares the same JWT_SECRET so it can verify without network calls.
|
||||
* 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.
|
||||
*/
|
||||
export { extractAuth, requireRole, type AuthPayload } from '@bytelyst/auth';
|
||||
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;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ const envSchema = z.object({
|
||||
COSMOS_KEY: z.string().min(1, 'COSMOS_KEY is required'),
|
||||
COSMOS_DATABASE: z.string().default('lysnrai'),
|
||||
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
|
||||
PLATFORM_JWKS_URL: z.string().url().optional(),
|
||||
});
|
||||
|
||||
export const config = envSchema.parse(process.env);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user