diff --git a/src/lib/service-token.test.ts b/src/lib/service-token.test.ts new file mode 100644 index 0000000..82c7967 --- /dev/null +++ b/src/lib/service-token.test.ts @@ -0,0 +1,145 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +import { + resetServiceTokenCache, + serviceAuthHeader, + serviceToken, +} from "./service-token"; + +const ISSUER = "https://auth.breakpilot.com/realms/breakpilot-dev"; + +function tokenResponse(value: string, expiresIn = 300) { + return { + ok: true, + status: 200, + json: async () => ({ access_token: value, expires_in: expiresIn }), + } as Response; +} + +function configure() { + process.env.KEYCLOAK_ISSUER = ISSUER; + process.env.PORTAL_SVC_CLIENT_ID = "portal-svc"; + process.env.PORTAL_SVC_CLIENT_SECRET = "shh"; +} + +beforeEach(() => { + resetServiceTokenCache(); + delete process.env.PORTAL_SVC_CLIENT_ID; + delete process.env.PORTAL_SVC_CLIENT_SECRET; + delete process.env.KEYCLOAK_ISSUER; +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("serviceToken", () => { + test("returns null and makes no request when unconfigured", async () => { + const fetchSpy = vi.spyOn(globalThis, "fetch"); + expect(await serviceToken()).toBeNull(); + expect(await serviceAuthHeader()).toEqual({}); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + test("requests a client_credentials token against the realm", async () => { + configure(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(tokenResponse("tok-1")); + + expect(await serviceToken()).toBe("tok-1"); + + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(`${ISSUER}/protocol/openid-connect/token`); + expect(init.method).toBe("POST"); + const body = new URLSearchParams(init.body as string); + expect(body.get("grant_type")).toBe("client_credentials"); + expect(body.get("client_id")).toBe("portal-svc"); + expect(body.get("client_secret")).toBe("shh"); + }); + + test("caches the token across calls", async () => { + configure(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(tokenResponse("tok-1")); + + await serviceToken(); + await serviceToken(); + await serviceToken(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + test("de-dupes concurrent fetches into one request", async () => { + configure(); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValue(tokenResponse("tok-1")); + + const results = await Promise.all([ + serviceToken(), + serviceToken(), + serviceToken(), + ]); + expect(results).toEqual(["tok-1", "tok-1", "tok-1"]); + expect(fetchSpy).toHaveBeenCalledTimes(1); + }); + + test("refreshes shortly before expiry", async () => { + configure(); + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-25T10:00:00Z")); + const fetchSpy = vi + .spyOn(globalThis, "fetch") + .mockResolvedValueOnce(tokenResponse("tok-1", 300)) + .mockResolvedValueOnce(tokenResponse("tok-2", 300)); + + expect(await serviceToken()).toBe("tok-1"); + // 4 minutes in: still inside the window + vi.setSystemTime(new Date("2026-08-25T10:04:00Z")); + expect(await serviceToken()).toBe("tok-1"); + // 4:40 — inside the 30s refresh margin + vi.setSystemTime(new Date("2026-08-25T10:04:40Z")); + expect(await serviceToken()).toBe("tok-2"); + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + test("surfaces a rejected token request instead of calling unauthenticated", async () => { + configure(); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 401, + json: async () => ({}), + } as Response); + + await expect(serviceToken()).rejects.toThrow("service token request failed: 401"); + }); + + test("surfaces a malformed token response", async () => { + configure(); + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + } as Response); + + await expect(serviceToken()).rejects.toThrow("no access_token"); + }); + + test("a failed fetch does not poison the cache", async () => { + configure(); + vi.spyOn(globalThis, "fetch") + .mockResolvedValueOnce({ ok: false, status: 503, json: async () => ({}) } as Response) + .mockResolvedValueOnce(tokenResponse("tok-ok")); + + await expect(serviceToken()).rejects.toThrow(); + expect(await serviceToken()).toBe("tok-ok"); + }); + + test("serviceAuthHeader carries the bearer token when configured", async () => { + configure(); + vi.spyOn(globalThis, "fetch").mockResolvedValue(tokenResponse("tok-1")); + expect(await serviceAuthHeader()).toEqual({ authorization: "Bearer tok-1" }); + }); +}); diff --git a/src/lib/service-token.ts b/src/lib/service-token.ts new file mode 100644 index 0000000..5f8a9c8 --- /dev/null +++ b/src/lib/service-token.ts @@ -0,0 +1,96 @@ +// 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; +} diff --git a/src/lib/tenant-registry.ts b/src/lib/tenant-registry.ts index 1165b41..9fd29f9 100644 --- a/src/lib/tenant-registry.ts +++ b/src/lib/tenant-registry.ts @@ -1,6 +1,8 @@ // Tenant Registry client — covers everything the portal needs to call // from server components and server actions. +import { serviceAuthHeader } from "./service-token"; + export type Tenant = { id: string; slug: string; @@ -74,9 +76,12 @@ async function req( path: string, body?: unknown, ): Promise<{ status: number; data: T | null }> { + // tenant-registry is INTERNAL_SERVICE_ONLY — the portal calls it as a + // service principal. Unconfigured ⇒ {} ⇒ unchanged, header-less calls. + const auth = await serviceAuthHeader(); const init: RequestInit = { method, - headers: { accept: "application/json" }, + headers: { accept: "application/json", ...auth }, cache: "no-store", }; if (body !== undefined) {