diff --git a/packages/iios-service/src/platform/session.verifier.spec.ts b/packages/iios-service/src/platform/session.verifier.spec.ts new file mode 100644 index 0000000..cbb3413 --- /dev/null +++ b/packages/iios-service/src/platform/session.verifier.spec.ts @@ -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), 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(); + }); +}); diff --git a/packages/iios-service/src/platform/session.verifier.ts b/packages/iios-service/src/platform/session.verifier.ts index 63a38f3..a218b3e 100644 --- a/packages/iios-service/src/platform/session.verifier.ts +++ b/packages/iios-service/src/platform/session.verifier.ts @@ -3,27 +3,34 @@ import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common' import jwt from 'jsonwebtoken'; 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://.supabase.co/auth/v1 + jwksUrl: string; + appId: string; + orgId: string; + audience: string; + kidToPem: Map; +} + /** - * 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 - * ES256, signed by the project's rotating key; we verify them against the - * project's public JWKS (no shared secret). This is the production identity - * plane's front door (a Session Broker would later exchange the SAT for a - * scoped PAT — until then we trust the SAT directly and scope from config). + * 1. OIDC / JWKS (real IdPs) — a REGISTRY of trusted issuers (env `AUTH_ISSUERS`, + * or the single `SUPABASE_URL` shorthand). A token is routed by its `iss` claim + * to that issuer's entry, verified against that issuer's public JWKS (ES256, no + * secret), and stamped with that entry's `appId`/`orgId`. So two projects/IdPs + * 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` - * (JSON keyed by appId); claims { sub, name?, appId, orgId?, tenantId? }. + * 2. App token (dev / HS256) — legacy per-app secrets from `APP_SECRETS`, keyed by + * 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() export class SessionVerifier implements OnModuleInit { private readonly appSecrets: Record; - private readonly supabaseUrl?: string; // base origin, no trailing slash / path - private readonly appId = process.env.SUPABASE_APP_ID?.trim() || 'portal-demo'; - private readonly orgId: string; - private kidToPem = new Map(); + private readonly issuers = new Map(); // keyed by issuer string constructor() { try { @@ -31,20 +38,49 @@ export class SessionVerifier implements OnModuleInit { } catch { this.appSecrets = {}; } - // Accept the project URL in any shape they paste (.../rest/v1, .../auth/v1, trailing slash). - const raw = process.env.SUPABASE_URL?.trim().replace(/\/+$/, '').replace(/\/(rest|auth)\/v1$/, ''); - this.supabaseUrl = raw || undefined; - this.orgId = process.env.SUPABASE_ORG_ID?.trim() || `org_${this.appId}`; + for (const cfg of this.readIssuerConfig()) { + const url = this.normalize(cfg.url); + const appId = cfg.appId?.trim() || 'portal-demo'; + 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 { - 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. */ - private async refreshJwks(): Promise { + /** Assemble the issuer registry from AUTH_ISSUERS (multi) or SUPABASE_URL (single, back-compat). */ + 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 { - 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 { + try { + const res = await fetch(entry.jwksUrl); if (!res.ok) return; const { keys } = (await res.json()) as { keys: Array> }; const next = new Map(); @@ -57,7 +93,7 @@ export class SessionVerifier implements OnModuleInit { /* skip a key we can't import */ } } - if (next.size) this.kidToPem = next; + if (next.size) entry.kidToPem = next; } catch { /* keep whatever keys we already have */ } @@ -66,38 +102,37 @@ export class SessionVerifier implements OnModuleInit { verify(token: string): MessagePrincipal { 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 (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); } - /** Verify a Supabase SAT (ES256) against the cached JWKS public key. */ - private verifySupabase(token: string, kid?: string): MessagePrincipal { - const pem = kid ? this.kidToPem.get(kid) : undefined; + /** Verify an ES256 SAT against its issuer's JWKS key, mapping to that issuer's app scope. */ + private verifyOidc(token: string, kid: string | undefined, entry: IssuerEntry): MessagePrincipal { + const pem = kid ? entry.kidToPem.get(kid) : undefined; 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'); } let payload: jwt.JwtPayload; try { - payload = jwt.verify(token, pem, { - algorithms: ['ES256'], - issuer: `${this.supabaseUrl}/auth/v1`, - audience: 'authenticated', - }) as jwt.JwtPayload; + payload = jwt.verify(token, pem, { algorithms: ['ES256'], issuer: entry.issuer, audience: entry.audience }) as jwt.JwtPayload; } catch { - throw new UnauthorizedException('invalid supabase token'); + throw new UnauthorizedException('invalid token'); } const email = payload.email ? String(payload.email).toLowerCase() : undefined; const meta = (payload.user_metadata ?? {}) as { full_name?: string; name?: string }; - // userId = email (unique + stable + human-readable → keeps mentions/directory working). - // The canonical UUID (sub) is what RealMDM would resolve later. + // userId = email (stable + human-readable → mentions/directory work); RealMDM canonicalises `sub` later. const userId = email ?? String(payload.sub); return { userId, - appId: this.appId, - orgId: this.orgId, + appId: entry.appId, + orgId: entry.orgId, tenantId: undefined, displayName: meta.full_name ?? meta.name ?? email ?? userId, }; @@ -126,4 +161,9 @@ export class SessionVerifier implements OnModuleInit { 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$/, ''); + } }