diff --git a/packages/iios-service/src/messaging/message.service.ts b/packages/iios-service/src/messaging/message.service.ts index beaa0d1..0df9d97 100644 --- a/packages/iios-service/src/messaging/message.service.ts +++ b/packages/iios-service/src/messaging/message.service.ts @@ -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, diff --git a/packages/iios-service/src/platform/session.verifier.ts b/packages/iios-service/src/platform/session.verifier.ts index fa7e28d..63a38f3 100644 --- a/packages/iios-service/src/platform/session.verifier.ts +++ b/packages/iios-service/src/platform/session.verifier.ts @@ -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; +export class SessionVerifier implements OnModuleInit { + private readonly appSecrets: Record; + 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(); 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 { + 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 { + 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> }; + 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) 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;