feat(auth): send a client_credentials service token to tenant-registry
tenant-registry's API is INTERNAL_SERVICE_ONLY: once its AUTH_ENABLED flips, every route but /healthz and /readyz requires a Keycloak token whose audience contains `tenant-registry`. The portal calls it as a service principal — machine-to-machine, distinct from the visitor's SSO session, which authenticates a human against dev-portal. New src/lib/service-token.ts fetches and caches a client_credentials token for the realm's portal-svc client, refreshing 30s before the 5-minute expiry and de-duping concurrent fetches so parallel renders share one request. A failed request throws rather than silently falling back to an unauthenticated call, and does not poison the cache. Wired into the single req() choke point in tenant-registry.ts, so every call is covered. Inert until configured: with PORTAL_SVC_CLIENT_ID / PORTAL_SVC_CLIENT_SECRET unset the helper returns null, no header is sent, and behaviour is exactly as today — which is what keeps this deployable before tenant-registry starts enforcing. 9 new tests; full suite 79 green; typecheck, lint and build clean. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
7e62c0162f
commit
26b5b0299f
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<CachedToken> | 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<CachedToken> {
|
||||||
|
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<string | null> {
|
||||||
|
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<Record<string, string>> {
|
||||||
|
const token = await serviceToken();
|
||||||
|
return token ? { authorization: `Bearer ${token}` } : {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test seam: drop the cached token. */
|
||||||
|
export function resetServiceTokenCache(): void {
|
||||||
|
cached = null;
|
||||||
|
inFlight = null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
// Tenant Registry client — covers everything the portal needs to call
|
// Tenant Registry client — covers everything the portal needs to call
|
||||||
// from server components and server actions.
|
// from server components and server actions.
|
||||||
|
|
||||||
|
import { serviceAuthHeader } from "./service-token";
|
||||||
|
|
||||||
export type Tenant = {
|
export type Tenant = {
|
||||||
id: string;
|
id: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -74,9 +76,12 @@ async function req<T>(
|
|||||||
path: string,
|
path: string,
|
||||||
body?: unknown,
|
body?: unknown,
|
||||||
): Promise<{ status: number; data: T | null }> {
|
): 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 = {
|
const init: RequestInit = {
|
||||||
method,
|
method,
|
||||||
headers: { accept: "application/json" },
|
headers: { accept: "application/json", ...auth },
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
};
|
};
|
||||||
if (body !== undefined) {
|
if (body !== undefined) {
|
||||||
|
|||||||
Reference in New Issue
Block a user