3298401772
The socket used to accept any valid token, so a full REST/actor token could open the live stream. Now it can require a narrowly-scoped delegated token (aud=iios-message), so a leaked socket token can't drive privileged REST, and vice-versa. - MessagePrincipal gains `audience`, surfaced from both verify paths (OIDC aud, and the app-token `aud` claim). - message.gateway: when IIOS_REALTIME_AUDIENCE is set, handleConnection accepts only a token whose aud matches (opt-in, like IIOS_REQUIRE_ATTESTATION; unset = no change). - spec: verifier surfaces aud; gateway accepts iios-message, rejects iios-core / no-aud when enforcing, passes through when off. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
172 lines
7.2 KiB
TypeScript
172 lines
7.2 KiB
TypeScript
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://<ref>.supabase.co/auth/v1
|
|
jwksUrl: string;
|
|
appId: string;
|
|
orgId: string;
|
|
audience: string;
|
|
kidToPem: Map<string, string>;
|
|
}
|
|
|
|
/**
|
|
* 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<string, string>;
|
|
private readonly issuers = new Map<string, IssuerEntry>(); // 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<void> {
|
|
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<void> {
|
|
try {
|
|
const res = await fetch(entry.jwksUrl);
|
|
if (!res.ok) return;
|
|
const { keys } = (await res.json()) as { keys: Array<Record<string, unknown>> };
|
|
const next = new Map<string, string>();
|
|
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,
|
|
audience: typeof payload.aud === 'string' ? payload.aud : entry.audience,
|
|
};
|
|
}
|
|
|
|
/** 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,
|
|
audience: typeof payload.aud === 'string' ? payload.aud : 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$/, '');
|
|
}
|
|
}
|