// Client-credentials service token for portal → tenant-registry calls. // // tenant-registry's API is INTERNAL_SERVICE_ONLY: once its AUTH_ENABLED // flips, every route except /healthz and /readyz needs a Keycloak token // whose audience contains `tenant-registry`. The portal is a service // principal here — this is machine-to-machine, unrelated to the visitor's // SSO session (that one authenticates a human against `dev-portal`). // // Inert until configured: with no PORTAL_SVC_CLIENT_ID / _SECRET the // helper returns null and callers send no Authorization header, which is // exactly today's behaviour against a tenant-registry that is not yet // enforcing. Configure both to switch the portal over. // // Server-only: the client secret must never reach the browser. Every // caller (src/lib/tenant-registry.ts) already runs server-side. const REFRESH_MARGIN_SECONDS = 30; type CachedToken = { value: string; expiresAt: number }; let cached: CachedToken | null = null; // de-dupes concurrent fetches: many parallel renders share one request let inFlight: Promise | null = null; function config(): { issuer: string; clientId: string; secret: string } | null { const clientId = process.env.PORTAL_SVC_CLIENT_ID; const secret = process.env.PORTAL_SVC_CLIENT_SECRET; const issuer = process.env.KEYCLOAK_ISSUER; if (!clientId || !secret || !issuer) return null; return { issuer, clientId, secret }; } async function fetchToken(cfg: { issuer: string; clientId: string; secret: string; }): Promise { const res = await fetch(`${cfg.issuer}/protocol/openid-connect/token`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ grant_type: "client_credentials", client_id: cfg.clientId, client_secret: cfg.secret, }), cache: "no-store", }); if (!res.ok) { throw new Error(`service token request failed: ${res.status}`); } const body = (await res.json()) as { access_token?: string; expires_in?: number; }; if (!body.access_token) { throw new Error("service token response carried no access_token"); } return { value: body.access_token, expiresAt: Date.now() / 1000 + (body.expires_in ?? 300), }; } /** * A valid service token, or null when the portal is not configured to * send one. Cached in memory and refreshed shortly before expiry (realm * tokens live 5 minutes). */ export async function serviceToken(): Promise { const cfg = config(); if (!cfg) return null; const now = Date.now() / 1000; if (cached && now < cached.expiresAt - REFRESH_MARGIN_SECONDS) { return cached.value; } if (!inFlight) { inFlight = fetchToken(cfg).finally(() => { inFlight = null; }); } cached = await inFlight; return cached.value; } /** Authorization header for an outbound call, or {} when unconfigured. */ export async function serviceAuthHeader(): Promise> { const token = await serviceToken(); return token ? { authorization: `Bearer ${token}` } : {}; } /** Test seam: drop the cached token. */ export function resetServiceTokenCache(): void { cached = null; inFlight = null; }