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_KEY=
|
||||||
COSMOS_DATABASE=lysnrai
|
COSMOS_DATABASE=lysnrai
|
||||||
JWT_SECRET=
|
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 — RS256 JWKS verification with HS256 fallback.
|
||||||
* JWT auth middleware — validates tokens issued by platform-service.
|
* Tries RS256 via platform-service JWKS endpoint first (if PLATFORM_JWKS_URL
|
||||||
* Shares the same JWT_SECRET so it can verify without network calls.
|
* 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_KEY: z.string().min(1, 'COSMOS_KEY is required'),
|
||||||
COSMOS_DATABASE: z.string().default('lysnrai'),
|
COSMOS_DATABASE: z.string().default('lysnrai'),
|
||||||
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
|
JWT_SECRET: z.string().min(1, 'JWT_SECRET is required'),
|
||||||
|
PLATFORM_JWKS_URL: z.string().url().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const config = envSchema.parse(process.env);
|
export const config = envSchema.parse(process.env);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user