import { createPublicKey } from 'node:crypto'; 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. Verifies whatever token a caller presents: * * 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) — legacy per-app secrets from `APP_SECRETS`, keyed by * the `appId` claim. Unchanged; used by the dev IdP + tests. * * 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 issuers = new Map(); // keyed by issuer string constructor() { try { this.appSecrets = JSON.parse(process.env.APP_SECRETS ?? '{}'); } catch { this.appSecrets = {}; } 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 { await Promise.all([...this.issuers.values()].map((e) => this.refreshJwks(e))); } /** 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 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(); for (const jwk of keys ?? []) { const kid = jwk.kid as string | undefined; if (!kid) continue; try { next.set(kid, createPublicKey({ key: jwk as never, format: 'jwk' }).export({ type: 'spki', format: 'pem' }) as string); } catch { /* skip a key we can't import */ } } if (next.size) entry.kidToPem = next; } catch { /* keep whatever keys we already have */ } } 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.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 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(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: entry.issuer, audience: entry.audience }) as jwt.JwtPayload; } catch { 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 (stable + human-readable → mentions/directory work); RealMDM canonicalises `sub` later. const userId = email ?? String(payload.sub); return { userId, appId: entry.appId, orgId: entry.orgId, tenantId: undefined, displayName: meta.full_name ?? meta.name ?? email ?? userId, }; } /** Legacy per-app HS256 token (dev IdP / host apps). */ private verifyAppToken(token: string, decodedPayload: jwt.JwtPayload): MessagePrincipal { const appId = decodedPayload.appId ? String(decodedPayload.appId) : undefined; if (!appId) throw new UnauthorizedException('missing appId claim'); const secret = this.appSecrets[appId]; if (!secret) throw new UnauthorizedException(`unknown app: ${appId}`); let payload: jwt.JwtPayload; try { payload = jwt.verify(token, secret, { algorithms: ['HS256'] }) as jwt.JwtPayload; } catch { throw new UnauthorizedException('invalid app token'); } if (!payload.sub) throw new UnauthorizedException('missing sub claim'); return { userId: String(payload.sub), appId, orgId: payload.orgId ? String(payload.orgId) : `org_${appId}`, tenantId: payload.tenantId ? String(payload.tenantId) : 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$/, ''); } }