0ecc8a4ada
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>
76 lines
2.9 KiB
TypeScript
76 lines
2.9 KiB
TypeScript
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 + 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();
|
||
});
|
||
});
|