Files
iios/packages/iios-service/src/platform/session.verifier.spec.ts
T
maaz519 0ecc8a4ada feat(iios): multi-issuer session verifier (many IdPs/apps → isolated appId scopes)
SessionVerifier now holds a REGISTRY of trusted OIDC issuers instead of a single
Supabase project. A token is routed by its `iss` claim to that issuer's entry,
verified against that issuer's JWKS (ES256, no secret), and stamped with that
entry's appId/orgId — so two Supabase projects / IdPs map to two isolated app
scopes on one IIOS (chat vs a future support app). App A's tokens can't reach B.

- Config: `AUTH_ISSUERS` (JSON array of { url, appId, orgId? }); the single
  `SUPABASE_URL` (+ SUPABASE_APP_ID) still works as a one-entry shorthand.
- Per-issuer JWKS cache; untrusted issuer → reject; forgery (right issuer claim,
  wrong key) → reject.
- HS256 app-token path (dev/tests) unchanged.

Tests: new session.verifier.spec (4) — routes to correct appId, second issuer →
different appId, untrusted issuer rejected, cross-issuer forgery rejected.

Note: outbox `replay.spec` has a pre-existing clock-skew flake (nextAttemptAt vs
Postgres now()) that surfaces under certain suite orderings — unrelated to this
change (fails in isolation on a clean tree; passes when another spec runs first).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 20:38:30 +05:30

76 lines
2.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<string, unknown>), 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 + As 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();
});
});