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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user