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:
@@ -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 + 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -3,27 +3,34 @@ import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common'
|
|||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import type { MessagePrincipal } from '../messaging/message.service';
|
import type { MessagePrincipal } from '../messaging/message.service';
|
||||||
|
|
||||||
|
/** One trusted OIDC issuer (a Supabase project / IdP) → the app scope it maps to. */
|
||||||
|
interface IssuerEntry {
|
||||||
|
issuer: string; // the token `iss` claim, e.g. https://<ref>.supabase.co/auth/v1
|
||||||
|
jwksUrl: string;
|
||||||
|
appId: string;
|
||||||
|
orgId: string;
|
||||||
|
audience: string;
|
||||||
|
kidToPem: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `session` port. Two verification modes, chosen by env:
|
* The `session` port. Verifies whatever token a caller presents:
|
||||||
*
|
*
|
||||||
* 1. Supabase (real IdP) — set `SUPABASE_URL`. User access tokens (SATs) are
|
* 1. OIDC / JWKS (real IdPs) — a REGISTRY of trusted issuers (env `AUTH_ISSUERS`,
|
||||||
* ES256, signed by the project's rotating key; we verify them against the
|
* or the single `SUPABASE_URL` shorthand). A token is routed by its `iss` claim
|
||||||
* project's public JWKS (no shared secret). This is the production identity
|
* to that issuer's entry, verified against that issuer's public JWKS (ES256, no
|
||||||
* plane's front door (a Session Broker would later exchange the SAT for a
|
* secret), and stamped with that entry's `appId`/`orgId`. So two projects/IdPs
|
||||||
* scoped PAT — until then we trust the SAT directly and scope from config).
|
* map to two isolated app scopes on one IIOS — app A's tokens can't reach app B.
|
||||||
*
|
*
|
||||||
* 2. App token (dev / HS256) — the legacy path. Per-app secrets from `APP_SECRETS`
|
* 2. App token (dev / HS256) — legacy per-app secrets from `APP_SECRETS`, keyed by
|
||||||
* (JSON keyed by appId); claims { sub, name?, appId, orgId?, tenantId? }.
|
* the `appId` claim. Unchanged; used by the dev IdP + tests.
|
||||||
*
|
*
|
||||||
* Same `MessagePrincipal` out either way, so nothing downstream changes.
|
* A Session Broker would later collapse case 1 to a single issuer (the Broker's PAT).
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SessionVerifier implements OnModuleInit {
|
export class SessionVerifier implements OnModuleInit {
|
||||||
private readonly appSecrets: Record<string, string>;
|
private readonly appSecrets: Record<string, string>;
|
||||||
private readonly supabaseUrl?: string; // base origin, no trailing slash / path
|
private readonly issuers = new Map<string, IssuerEntry>(); // keyed by issuer string
|
||||||
private readonly appId = process.env.SUPABASE_APP_ID?.trim() || 'portal-demo';
|
|
||||||
private readonly orgId: string;
|
|
||||||
private kidToPem = new Map<string, string>();
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
try {
|
try {
|
||||||
@@ -31,20 +38,49 @@ export class SessionVerifier implements OnModuleInit {
|
|||||||
} catch {
|
} catch {
|
||||||
this.appSecrets = {};
|
this.appSecrets = {};
|
||||||
}
|
}
|
||||||
// Accept the project URL in any shape they paste (.../rest/v1, .../auth/v1, trailing slash).
|
for (const cfg of this.readIssuerConfig()) {
|
||||||
const raw = process.env.SUPABASE_URL?.trim().replace(/\/+$/, '').replace(/\/(rest|auth)\/v1$/, '');
|
const url = this.normalize(cfg.url);
|
||||||
this.supabaseUrl = raw || undefined;
|
const appId = cfg.appId?.trim() || 'portal-demo';
|
||||||
this.orgId = process.env.SUPABASE_ORG_ID?.trim() || `org_${this.appId}`;
|
const issuer = cfg.issuer?.trim() || `${url}/auth/v1`;
|
||||||
|
const jwksUrl = cfg.jwksUrl?.trim() || `${url}/auth/v1/.well-known/jwks.json`;
|
||||||
|
this.issuers.set(issuer, {
|
||||||
|
issuer,
|
||||||
|
jwksUrl,
|
||||||
|
appId,
|
||||||
|
orgId: cfg.orgId?.trim() || `org_${appId}`,
|
||||||
|
audience: cfg.audience?.trim() || 'authenticated',
|
||||||
|
kidToPem: new Map(),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
if (this.supabaseUrl) await this.refreshJwks();
|
await Promise.all([...this.issuers.values()].map((e) => this.refreshJwks(e)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch the project JWKS and cache each key as a PEM so verify() can stay synchronous. */
|
/** Assemble the issuer registry from AUTH_ISSUERS (multi) or SUPABASE_URL (single, back-compat). */
|
||||||
private async refreshJwks(): Promise<void> {
|
private readIssuerConfig(): Array<{ url: string; appId?: string; orgId?: string; issuer?: string; jwksUrl?: string; audience?: string }> {
|
||||||
|
const out: Array<{ url: string; appId?: string; orgId?: string; issuer?: string; jwksUrl?: string; audience?: string }> = [];
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${this.supabaseUrl}/auth/v1/.well-known/jwks.json`);
|
const raw = process.env.AUTH_ISSUERS;
|
||||||
|
if (raw) {
|
||||||
|
const parsed = JSON.parse(raw) as Array<{ url: string; appId?: string; orgId?: string; issuer?: string; jwksUrl?: string; audience?: string }>;
|
||||||
|
if (Array.isArray(parsed)) out.push(...parsed.filter((e) => e && (e.url || e.issuer)));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore malformed AUTH_ISSUERS */
|
||||||
|
}
|
||||||
|
const single = process.env.SUPABASE_URL?.trim();
|
||||||
|
if (single && !out.length) {
|
||||||
|
out.push({ url: single, appId: process.env.SUPABASE_APP_ID?.trim(), orgId: process.env.SUPABASE_ORG_ID?.trim() });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch one issuer's JWKS and cache each key as PEM (so verify() stays synchronous). */
|
||||||
|
private async refreshJwks(entry: IssuerEntry): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(entry.jwksUrl);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const { keys } = (await res.json()) as { keys: Array<Record<string, unknown>> };
|
const { keys } = (await res.json()) as { keys: Array<Record<string, unknown>> };
|
||||||
const next = new Map<string, string>();
|
const next = new Map<string, string>();
|
||||||
@@ -57,7 +93,7 @@ export class SessionVerifier implements OnModuleInit {
|
|||||||
/* skip a key we can't import */
|
/* skip a key we can't import */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (next.size) this.kidToPem = next;
|
if (next.size) entry.kidToPem = next;
|
||||||
} catch {
|
} catch {
|
||||||
/* keep whatever keys we already have */
|
/* keep whatever keys we already have */
|
||||||
}
|
}
|
||||||
@@ -66,38 +102,37 @@ export class SessionVerifier implements OnModuleInit {
|
|||||||
verify(token: string): MessagePrincipal {
|
verify(token: string): MessagePrincipal {
|
||||||
const decoded = jwt.decode(token, { complete: true }) as { header?: { alg?: string; kid?: string }; payload?: jwt.JwtPayload } | null;
|
const decoded = jwt.decode(token, { complete: true }) as { header?: { alg?: string; kid?: string }; payload?: jwt.JwtPayload } | null;
|
||||||
if (!decoded?.payload) throw new UnauthorizedException('invalid token');
|
if (!decoded?.payload) throw new UnauthorizedException('invalid token');
|
||||||
if (this.supabaseUrl && decoded.header?.alg === 'ES256') {
|
|
||||||
return this.verifySupabase(token, decoded.header.kid);
|
if (this.issuers.size && decoded.header?.alg === 'ES256') {
|
||||||
|
const iss = decoded.payload.iss ? String(decoded.payload.iss) : '';
|
||||||
|
const entry = this.issuers.get(iss);
|
||||||
|
if (!entry) throw new UnauthorizedException(`untrusted issuer: ${iss || '(none)'}`);
|
||||||
|
return this.verifyOidc(token, decoded.header.kid, entry);
|
||||||
}
|
}
|
||||||
return this.verifyAppToken(token, decoded.payload);
|
return this.verifyAppToken(token, decoded.payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Verify a Supabase SAT (ES256) against the cached JWKS public key. */
|
/** Verify an ES256 SAT against its issuer's JWKS key, mapping to that issuer's app scope. */
|
||||||
private verifySupabase(token: string, kid?: string): MessagePrincipal {
|
private verifyOidc(token: string, kid: string | undefined, entry: IssuerEntry): MessagePrincipal {
|
||||||
const pem = kid ? this.kidToPem.get(kid) : undefined;
|
const pem = kid ? entry.kidToPem.get(kid) : undefined;
|
||||||
if (!pem) {
|
if (!pem) {
|
||||||
void this.refreshJwks(); // key rotated or not loaded yet — pull fresh for next time
|
void this.refreshJwks(entry); // rotated / not loaded — pull fresh for next time
|
||||||
throw new UnauthorizedException('unknown signing key — retry');
|
throw new UnauthorizedException('unknown signing key — retry');
|
||||||
}
|
}
|
||||||
let payload: jwt.JwtPayload;
|
let payload: jwt.JwtPayload;
|
||||||
try {
|
try {
|
||||||
payload = jwt.verify(token, pem, {
|
payload = jwt.verify(token, pem, { algorithms: ['ES256'], issuer: entry.issuer, audience: entry.audience }) as jwt.JwtPayload;
|
||||||
algorithms: ['ES256'],
|
|
||||||
issuer: `${this.supabaseUrl}/auth/v1`,
|
|
||||||
audience: 'authenticated',
|
|
||||||
}) as jwt.JwtPayload;
|
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException('invalid supabase token');
|
throw new UnauthorizedException('invalid token');
|
||||||
}
|
}
|
||||||
const email = payload.email ? String(payload.email).toLowerCase() : undefined;
|
const email = payload.email ? String(payload.email).toLowerCase() : undefined;
|
||||||
const meta = (payload.user_metadata ?? {}) as { full_name?: string; name?: string };
|
const meta = (payload.user_metadata ?? {}) as { full_name?: string; name?: string };
|
||||||
// userId = email (unique + stable + human-readable → keeps mentions/directory working).
|
// userId = email (stable + human-readable → mentions/directory work); RealMDM canonicalises `sub` later.
|
||||||
// The canonical UUID (sub) is what RealMDM would resolve later.
|
|
||||||
const userId = email ?? String(payload.sub);
|
const userId = email ?? String(payload.sub);
|
||||||
return {
|
return {
|
||||||
userId,
|
userId,
|
||||||
appId: this.appId,
|
appId: entry.appId,
|
||||||
orgId: this.orgId,
|
orgId: entry.orgId,
|
||||||
tenantId: undefined,
|
tenantId: undefined,
|
||||||
displayName: meta.full_name ?? meta.name ?? email ?? userId,
|
displayName: meta.full_name ?? meta.name ?? email ?? userId,
|
||||||
};
|
};
|
||||||
@@ -126,4 +161,9 @@ export class SessionVerifier implements OnModuleInit {
|
|||||||
displayName: payload.name ? String(payload.name) : undefined,
|
displayName: payload.name ? String(payload.name) : undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accept a project URL in any shape (…/rest/v1, …/auth/v1, trailing slash). */
|
||||||
|
private normalize(url: string): string {
|
||||||
|
return url.trim().replace(/\/+$/, '').replace(/\/(rest|auth)\/v1$/, '');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user