// Node.js 18+; server-side only. Create ONE provider per credential/audience // at application startup, outside request handlers. Keep secrets in configuration. export function createTokenProvider({ clientId, clientSecret, audience }) { if (!clientId || !clientSecret || !audience) { throw new Error("Client ID, client secret and audience are required."); } let cached = null; let refreshInFlight = null; async function requestToken() { const response = await fetch("https://identity.vinquery.com/connect/token", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ clientId, clientSecret, audience }), signal: AbortSignal.timeout(30_000) }); if (!response.ok) { throw new Error(`VINquery token request failed (HTTP ${response.status}).`); } const result = await response.json(); const expiresAt = Date.parse(result.expiresUtc); const now = Date.now(); if (typeof result.jwtToken !== "string" || !result.jwtToken.trim() || !Number.isFinite(expiresAt) || expiresAt <= now) { throw new Error("VINquery returned an empty token or invalid expiry."); } // Normally renew one minute early; allow shorter token lifetimes too. const bufferMs = Math.min(60_000, (expiresAt - now) * 0.1); cached = { token: result.jwtToken, refreshAt: expiresAt - bufferMs }; return cached.token; } async function getToken() { if (cached && Date.now() < cached.refreshAt) return cached.token; // All concurrent callers await the SAME token request. if (!refreshInFlight) { refreshInFlight = requestToken().finally(() => { refreshInFlight = null; }); } return refreshInFlight; } function invalidate(rejectedToken) { // A late 401 for an old token must not remove a newer cached token. if (cached?.token === rejectedToken) cached = null; } return { getToken, invalidate }; }