feat(iios): verify real Supabase access tokens (ES256 via JWKS)

SessionVerifier gains a Supabase mode (set SUPABASE_URL): user SATs are ES256,
verified against the project's public JWKS — no shared secret. Keys are fetched at
startup (onModuleInit) and cached as PEM so verify() stays synchronous; a rotated
kid triggers a background refresh. Claims map to MessagePrincipal with userId=email
(stable, human-readable → mentions/directory keep working; RealMDM canonicalises
later). The legacy HS256 app-token path is unchanged (dev/tests untouched).

senderName now prefers the actor display name over the raw handle, so real names
show on bubbles when identity is an email.

Verified end-to-end against a live project: signup → real ES256 SAT → GET /v1/threads
200 → thread created + owned by the resolved email principal. Full suite 182 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 20:14:28 +05:30
parent 8c814d9b86
commit e956ad3cb9
2 changed files with 96 additions and 13 deletions
@@ -506,7 +506,7 @@ export class MessageService {
id: interaction.id,
threadId,
senderActorId: interaction.actorId ?? '',
senderName: interaction.actor?.sourceHandle?.externalId ?? interaction.actor?.displayName ?? 'unknown',
senderName: interaction.actor?.displayName ?? interaction.actor?.sourceHandle?.externalId ?? 'unknown',
content: text?.bodyText ?? '',
contentRef: file?.contentRef ?? undefined,
parentInteractionId: interaction.parentInteractionId ?? undefined,
@@ -1,30 +1,113 @@
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { createPublicKey } from 'node:crypto';
import { Injectable, OnModuleInit, UnauthorizedException } from '@nestjs/common';
import jwt from 'jsonwebtoken';
import type { MessagePrincipal } from '../messaging/message.service';
/**
* The real `session` port for P2: verifies a host app's HS256 token (reuses the
* support-service AppTokenVerifier pattern). Per-app secrets come from the
* APP_SECRETS env (JSON map keyed by appId). Expected claims: { sub, name?,
* appId, orgId?, tenantId? }.
* The `session` port. Two verification modes, chosen by env:
*
* 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).
*
* 2. App token (dev / HS256) — the legacy path. Per-app secrets from `APP_SECRETS`
* (JSON keyed by appId); claims { sub, name?, appId, orgId?, tenantId? }.
*
* Same `MessagePrincipal` out either way, so nothing downstream changes.
*/
@Injectable()
export class SessionVerifier {
private readonly secrets: Record<string, string>;
export class SessionVerifier implements OnModuleInit {
private readonly appSecrets: Record<string, string>;
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<string, string>();
constructor() {
try {
this.secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
this.appSecrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
} catch {
this.secrets = {};
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}`;
}
async onModuleInit(): Promise<void> {
if (this.supabaseUrl) await this.refreshJwks();
}
/** Fetch the project JWKS and cache each key as a PEM so verify() can stay synchronous. */
private async refreshJwks(): Promise<void> {
try {
const res = await fetch(`${this.supabaseUrl}/auth/v1/.well-known/jwks.json`);
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) this.kidToPem = next;
} catch {
/* keep whatever keys we already have */
}
}
verify(token: string): MessagePrincipal {
const decoded = jwt.decode(token) as jwt.JwtPayload | null;
const appId = decoded?.appId ? String(decoded.appId) : undefined;
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);
}
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;
if (!pem) {
void this.refreshJwks(); // key rotated or not loaded yet — 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;
} catch {
throw new UnauthorizedException('invalid supabase 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.
const userId = email ?? String(payload.sub);
return {
userId,
appId: this.appId,
orgId: this.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.secrets[appId];
const secret = this.appSecrets[appId];
if (!secret) throw new UnauthorizedException(`unknown app: ${appId}`);
let payload: jwt.JwtPayload;