97 lines
3.2 KiB
TypeScript
97 lines
3.2 KiB
TypeScript
// 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;
|
|
}
|