/** * enfonica-credentials.ts — Enfonica service account authentication (reference implementation) * * Exchanges an Enfonica service account key for an OAuth2 bearer access token * using the JWT Bearer Grant (RFC 7523), and caches the token in memory. * Copy this single file into your project. * * Requires: Node.js 18+ (uses the built-in crypto module and fetch). No npm packages. * Written as plain TypeScript with no enums or parameter properties, so Node 22.18+ * can run it directly; delete the type annotations to use it as JavaScript. * * Creating credentials (pick one): * * const creds = EnfonicaCredentials.fromEnv(); // path in ENFONICA_APPLICATION_CREDENTIALS * const creds = EnfonicaCredentials.fromServiceAccountFile('key.json'); * const creds = EnfonicaCredentials.fromServiceAccountInfo(jsonStringOrObject); // e.g. from a secret manager * * Using them on each API request: * * headers: { Authorization: await creds.getAuthorizationHeader() } // "Bearer " * const token = await creds.getAccessToken(); // just the token * * Notes: * - Create ONE instance and reuse it for the life of your application. Tokens * are cached in memory and refreshed automatically after 30 minutes (the * server issues 60-minute tokens), so a new instance per request means a * token request per API call. * - Concurrent calls share a single in-flight token request. * - A 401 from the API is a real error (bad key, revoked key, missing * permission). Do not retry it by fetching a new token. * - Tokens are never written to disk. */ import { createPrivateKey, createSign, type KeyObject } from 'node:crypto'; import { readFileSync } from 'node:fs'; const SCOPE = 'https://api.enfonica.com/auth/platform'; const GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:jwt-bearer'; const ENV_VAR = 'ENFONICA_APPLICATION_CREDENTIALS'; const JWT_LIFETIME_SECONDS = 3600; const TOKEN_REFRESH_MS = 30 * 60 * 1000; const HTTP_TIMEOUT_MS = 30_000; const REQUIRED_FIELDS = ['type', 'project_id', 'private_key_id', 'private_key', 'client_email', 'token_uri'] as const; export interface ServiceAccountKey { type: string; project_id: string; private_key_id: string; private_key: string; client_email: string; token_uri: string; } /** Thrown when a key file is missing or invalid, or a token request fails. */ export class EnfonicaCredentialsError extends Error { constructor(message: string) { super(message); this.name = 'EnfonicaCredentialsError'; } } /** Service account credentials that produce cached Enfonica access tokens. */ export class EnfonicaCredentials { readonly projectId: string; readonly clientEmail: string; /** Always read from the key file, never hardcoded. */ readonly tokenUri: string; private readonly privateKeyId: string; private readonly privateKey: KeyObject; private token: string | null = null; private fetchedAt = 0; private inFlight: Promise | null = null; /** Load the key file named by the ENFONICA_APPLICATION_CREDENTIALS environment variable. */ static fromEnv(): EnfonicaCredentials { const path = process.env[ENV_VAR]; if (!path) { throw new EnfonicaCredentialsError( `${ENV_VAR} is not set. Set it to the path of a service account key file, ` + 'or use fromServiceAccountFile() / fromServiceAccountInfo().', ); } return EnfonicaCredentials.fromServiceAccountFile(path); } /** Load a service account key file from disk. */ static fromServiceAccountFile(path: string): EnfonicaCredentials { let text: string; try { text = readFileSync(path, 'utf8'); } catch (e) { throw new EnfonicaCredentialsError(`Could not read key file '${path}': ${(e as Error).message}`); } return EnfonicaCredentials.fromServiceAccountInfo(text); } /** Load a service account key from its JSON text or an already-parsed object. */ static fromServiceAccountInfo(info: string | ServiceAccountKey): EnfonicaCredentials { let parsed: unknown = info; if (typeof info === 'string') { try { parsed = JSON.parse(info); } catch (e) { throw new EnfonicaCredentialsError(`Invalid key file: not valid JSON: ${(e as Error).message}`); } } return new EnfonicaCredentials(parsed); } constructor(info: unknown) { if (typeof info !== 'object' || info === null || Array.isArray(info)) { throw new EnfonicaCredentialsError('Invalid key file: expected a JSON object'); } const key = info as Record; if (key.type !== 'service_account') { throw new EnfonicaCredentialsError(`Invalid key file: expected type 'service_account', got '${String(key.type)}'`); } const missing = REQUIRED_FIELDS.filter((f) => typeof key[f] !== 'string' || key[f] === ''); if (missing.length > 0) { throw new EnfonicaCredentialsError(`Invalid key file: missing field(s): ${missing.join(', ')}`); } this.projectId = key.project_id as string; this.clientEmail = key.client_email as string; this.tokenUri = key.token_uri as string; this.privateKeyId = key.private_key_id as string; this.privateKey = loadPrivateKey(key.private_key as string); } /** Return a valid access token, fetching a new one if none is cached or it is 30+ minutes old. */ async getAccessToken(): Promise { if (this.token !== null && Date.now() - this.fetchedAt < TOKEN_REFRESH_MS) { return this.token; } if (this.inFlight === null) { this.inFlight = this.fetchToken() .then((token) => { this.token = token; this.fetchedAt = Date.now(); return token; }) .finally(() => { this.inFlight = null; }); } return this.inFlight; } /** Return the value for the HTTP Authorization header: "Bearer ". */ async getAuthorizationHeader(): Promise { return `Bearer ${await this.getAccessToken()}`; } private async fetchToken(): Promise { let response: Response; try { response = await fetch(this.tokenUri, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' }, body: new URLSearchParams({ grant_type: GRANT_TYPE, assertion: this.signedAssertion() }), signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), }); } catch (e) { throw new EnfonicaCredentialsError(`Token request to ${this.tokenUri} failed: ${(e as Error).message}`); } const text = await response.text(); if (!response.ok) { throw new EnfonicaCredentialsError(`Token request failed: HTTP ${response.status} from ${this.tokenUri}: ${text}`); } let token: unknown; try { token = JSON.parse(text).access_token; } catch { throw new EnfonicaCredentialsError(`Token response was malformed: ${text}`); } if (typeof token !== 'string' || token === '') { throw new EnfonicaCredentialsError(`Token response had no access_token: ${text}`); } return token; } /** Build the RS256-signed JWT assertion (RFC 7523 §3). */ private signedAssertion(): string { const now = Math.floor(Date.now() / 1000); const header = { alg: 'RS256', typ: 'JWT', kid: this.privateKeyId }; const payload = { iss: this.clientEmail, aud: this.tokenUri, scope: SCOPE, iat: now, exp: now + JWT_LIFETIME_SECONDS, }; const signingInput = `${b64urlJson(header)}.${b64urlJson(payload)}`; const signature = createSign('RSA-SHA256').update(signingInput).sign(this.privateKey); return `${signingInput}.${signature.toString('base64url')}`; } } function loadPrivateKey(pem: string): KeyObject { // Decode the PEM body ourselves so keys wrapped at any line width (or not // at all) load correctly; some PEM parsers insist on 64-character lines. const body = pem.replace(/-----(BEGIN|END) [A-Z ]+-----|\s+/g, ''); try { return createPrivateKey({ key: Buffer.from(body, 'base64'), format: 'der', type: 'pkcs8' }); } catch (e) { throw new EnfonicaCredentialsError(`Invalid key file: private_key is not a valid PKCS#8 RSA key: ${(e as Error).message}`); } } function b64urlJson(obj: object): string { return Buffer.from(JSON.stringify(obj), 'utf8').toString('base64url'); }