notify: the FCM channel
Step 4, and the last transport. Google directly via FCM HTTP v1 — no Expo, no firebase-admin. Unlike APNs the signed JWT is not the credential: it is an assertion exchanged at oauth2.googleapis.com for a 1-hour access token, so there are two things to cache and a network round trip on the cold path. Concurrent pushes share one in-flight exchange rather than each starting their own, and the token refreshes five minutes early so a request cannot race the expiry. The detail that breaks most first FCM integrations: `message.data` values must all be STRINGS. A number or boolean is rejected with a bare INVALID_ARGUMENT that does not say which field. Everything is stringified on the way out — id, ok and count included — and a test walks every value asserting its type rather than trusting the code that wrote it. Also v1-specific: there is no multicast (the /batch endpoint is deprecated), so N devices is N requests, which happens to match the APNs shape anyway. Errors are read for `error.status` only. FCM's `message` field can echo the device token, so logging the whole body would put device addresses in the logs. UNREGISTERED / INVALID_ARGUMENT / NOT_FOUND delete the row; anything else counts a strike. 21 tests across both channels, and verified against the real endpoint: a throwaway key gets 400 invalid_grant "account not found" from Google, meaning the endpoint, form encoding, grant_type, RS256 signature and claim structure were all accepted and only the account is missing. A malformed assertion would have failed earlier, with a different error. The doorbell rule is asserted on this channel too: a producer passing subject/from cannot get either into the serialized message. Still needed for a real send: FCM_SERVICE_ACCOUNT, a Firebase project, and google-services.json in the Android build. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
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<ServiceAccount>;
|
||||
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<string> | 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<string> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user