import { createSign, createPrivateKey, type KeyObject } from 'node:crypto'; // FCM v1 authorization: unlike APNs, the signed JWT is not the credential Google accepts. It is an // *assertion* you exchange at the OAuth2 endpoint for a short-lived access token, and that token is what // goes in the Authorization header. So there are two things to cache, not one, and a network round trip // on the cold path that APNs does not have. // // The private key comes from a service-account JSON downloaded from the Firebase console. It arrives // with literal \n sequences inside the PEM, which have to be unescaped or createPrivateKey throws. const TOKEN_URL = 'https://oauth2.googleapis.com/token'; const SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'; /** Google issues 1-hour tokens; refresh early so a request never races the expiry. */ const REFRESH_MARGIN_MS = 5 * 60 * 1000; export type ServiceAccount = { project_id: string; client_email: string; private_key: string; }; export function parseServiceAccount(raw: string): ServiceAccount { const parsed = JSON.parse(raw) as Partial; if (!parsed.project_id || !parsed.client_email || !parsed.private_key) { throw new Error('FCM service account is missing project_id, client_email or private_key'); } return parsed as ServiceAccount; } const b64url = (input: object | Buffer): string => (Buffer.isBuffer(input) ? input : Buffer.from(JSON.stringify(input))).toString('base64url'); export class FcmAccessToken { private key: KeyObject; private cached: { token: string; expiresAt: number } | null = null; /** In-flight refresh, so a burst of pushes makes one token request rather than one each. */ private inflight: Promise | null = null; constructor(private account: ServiceAccount) { this.key = createPrivateKey({ key: account.private_key.replace(/\\n/g, '\n'), format: 'pem' }); } get projectId(): string { return this.account.project_id; } /** The signed assertion Google exchanges for an access token. Exposed for testing. */ buildAssertion(now = Date.now()): string { const iat = Math.floor(now / 1000); const header = b64url({ alg: 'RS256', typ: 'JWT' }); const claims = b64url({ iss: this.account.client_email, scope: SCOPE, aud: TOKEN_URL, iat, exp: iat + 3600, }); const signingInput = `${header}.${claims}`; const signer = createSign('RSA-SHA256'); signer.update(signingInput); return `${signingInput}.${b64url(signer.sign(this.key))}`; } async get(): Promise { const now = Date.now(); if (this.cached && this.cached.expiresAt - now > REFRESH_MARGIN_MS) return this.cached.token; if (this.inflight) return this.inflight; this.inflight = (async () => { try { const res = await fetch(TOKEN_URL, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: this.buildAssertion(now), }), signal: AbortSignal.timeout(10_000), }); if (!res.ok) { // Never log the body: it echoes the assertion, which is signed with the service-account key. throw new Error(`FCM token exchange failed: ${res.status}`); } const body = (await res.json()) as { access_token?: string; expires_in?: number }; if (!body.access_token) throw new Error('FCM token exchange returned no access_token'); this.cached = { token: body.access_token, expiresAt: now + (body.expires_in ?? 3600) * 1000, }; return this.cached.token; } finally { this.inflight = null; } })(); return this.inflight; } }