import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; import { generateKeyPairSync, type KeyObject } from 'node:crypto'; import jwt from 'jsonwebtoken'; import { SessionVerifier } from './session.verifier'; // Two independent fake IdPs (each its own EC signing key + kid). function makeIssuer(kid: string) { const { publicKey, privateKey } = generateKeyPairSync('ec', { namedCurve: 'P-256' }); const jwk = { ...(publicKey.export({ format: 'jwk' }) as Record), kid, use: 'sig', alg: 'ES256' }; return { privateKey, jwks: { keys: [jwk] } }; } const chat = makeIssuer('chat-kid'); const support = makeIssuer('support-kid'); const CHAT_URL = 'https://chat-proj.example.co'; const SUPPORT_URL = 'https://support-proj.example.co'; function sat(priv: KeyObject, url: string, kid: string, email: string, name: string): string { return jwt.sign( { email, user_metadata: { full_name: name }, role: 'authenticated' }, priv, { algorithm: 'ES256', issuer: `${url}/auth/v1`, audience: 'authenticated', subject: 'uuid-x', keyid: kid, expiresIn: '1h' }, ); } describe('SessionVerifier — multi-issuer (OIDC/JWKS registry)', () => { let verifier: SessionVerifier; beforeAll(async () => { process.env.AUTH_ISSUERS = JSON.stringify([ { url: CHAT_URL, appId: 'portal-demo' }, { url: SUPPORT_URL, appId: 'support-app' }, ]); // Serve each issuer's JWKS from its own well-known URL. vi.stubGlobal( 'fetch', vi.fn(async (u: string) => { const s = String(u); const body = s.includes('chat-proj') ? chat.jwks : s.includes('support-proj') ? support.jwks : { keys: [] }; return { ok: true, json: async () => body } as unknown as Response; }), ); verifier = new SessionVerifier(); await verifier.onModuleInit(); }); afterAll(() => { vi.unstubAllGlobals(); delete process.env.AUTH_ISSUERS; }); it('routes a token to the appId of its own issuer', () => { expect(verifier.verify(sat(chat.privateKey, CHAT_URL, 'chat-kid', 'Alice@x.com', 'Alice'))).toMatchObject({ userId: 'alice@x.com', appId: 'portal-demo', displayName: 'Alice', }); }); it('a second issuer maps to a DIFFERENT appId — isolated scope', () => { expect(verifier.verify(sat(support.privateKey, SUPPORT_URL, 'support-kid', 'Bob@x.com', 'Bob'))).toMatchObject({ userId: 'bob@x.com', appId: 'support-app', }); }); it('rejects a token from an untrusted issuer', () => { const rogue = makeIssuer('rogue-kid'); expect(() => verifier.verify(sat(rogue.privateKey, 'https://evil.example.co', 'rogue-kid', 'e@x.com', 'E'))).toThrow(); }); it('rejects a forgery: signed by issuer B but claiming issuer A + A’s kid', () => { const forged = sat(support.privateKey, CHAT_URL, 'chat-kid', 'x@x.com', 'X'); // wrong key for the claimed issuer expect(() => verifier.verify(forged)).toThrow(); }); });