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>
This commit is contained in:
2026-07-07 20:38:30 +05:30
parent e956ad3cb9
commit 0ecc8a4ada
2 changed files with 153 additions and 38 deletions
@@ -0,0 +1,75 @@
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();
});
});