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,
|
id: interaction.id,
|
||||||
threadId,
|
threadId,
|
||||||
senderActorId: interaction.actorId ?? '',
|
senderActorId: interaction.actorId ?? '',
|
||||||
senderName: interaction.actor?.sourceHandle?.externalId ?? interaction.actor?.displayName ?? 'unknown',
|
senderName: interaction.actor?.displayName ?? interaction.actor?.sourceHandle?.externalId ?? 'unknown',
|
||||||
content: text?.bodyText ?? '',
|
content: text?.bodyText ?? '',
|
||||||
contentRef: file?.contentRef ?? undefined,
|
contentRef: file?.contentRef ?? undefined,
|
||||||
parentInteractionId: interaction.parentInteractionId ?? 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 jwt from 'jsonwebtoken';
|
||||||
import type { MessagePrincipal } from '../messaging/message.service';
|
import type { MessagePrincipal } from '../messaging/message.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The real `session` port for P2: verifies a host app's HS256 token (reuses the
|
* The `session` port. Two verification modes, chosen by env:
|
||||||
* support-service AppTokenVerifier pattern). Per-app secrets come from the
|
*
|
||||||
* APP_SECRETS env (JSON map keyed by appId). Expected claims: { sub, name?,
|
* 1. Supabase (real IdP) — set `SUPABASE_URL`. User access tokens (SATs) are
|
||||||
* appId, orgId?, tenantId? }.
|
* 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()
|
@Injectable()
|
||||||
export class SessionVerifier {
|
export class SessionVerifier implements OnModuleInit {
|
||||||
private readonly secrets: Record<string, string>;
|
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() {
|
constructor() {
|
||||||
try {
|
try {
|
||||||
this.secrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
this.appSecrets = JSON.parse(process.env.APP_SECRETS ?? '{}');
|
||||||
} catch {
|
} 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 {
|
verify(token: string): MessagePrincipal {
|
||||||
const decoded = jwt.decode(token) as jwt.JwtPayload | null;
|
const decoded = jwt.decode(token, { complete: true }) as { header?: { alg?: string; kid?: string }; payload?: jwt.JwtPayload } | null;
|
||||||
const appId = decoded?.appId ? String(decoded.appId) : undefined;
|
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');
|
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}`);
|
if (!secret) throw new UnauthorizedException(`unknown app: ${appId}`);
|
||||||
|
|
||||||
let payload: jwt.JwtPayload;
|
let payload: jwt.JwtPayload;
|
||||||
|
|||||||
Reference in New Issue
Block a user