fcm.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. import { createPrivateKey, sign, type KeyObject } from 'node:crypto'
  2. import { readFileSync } from 'node:fs'
  3. import type { FCMProviderConfig } from './config.js'
  4. import type { APNsSendResult } from './apns.js'
  5. export const FCM_ENDPOINT_ORIGIN = 'https://fcm.googleapis.com'
  6. export const FCM_OAUTH_SCOPE = 'https://www.googleapis.com/auth/firebase.messaging'
  7. export const FCM_MAX_DATA_BYTES = 4_096
  8. export interface FCMRequest {
  9. fid: string
  10. data: Record<string, string>
  11. collapseId?: string
  12. ttlSeconds?: number
  13. priority?: 'high' | 'normal'
  14. validateOnly?: boolean
  15. }
  16. export interface FCMTransportResponse {
  17. status: number
  18. headers?: Headers | Record<string, string | string[] | undefined>
  19. body?: string
  20. }
  21. export interface FCMTransport {
  22. request(url: URL, init: RequestInit): Promise<FCMTransportResponse>
  23. }
  24. export type FCMSendResult = APNsSendResult
  25. export class FCMProbeError extends Error {
  26. constructor(readonly result: FCMSendResult) {
  27. super(result.reason || `FCM_PROBE_HTTP_${result.status}`)
  28. this.name = 'FCMProbeError'
  29. }
  30. }
  31. interface ServiceAccountCredentials {
  32. clientEmail: string
  33. privateKey: string
  34. tokenUri: string
  35. }
  36. export interface FCMProviderOptions {
  37. transport?: FCMTransport
  38. now?: () => number
  39. }
  40. const ACCESS_TOKEN_REFRESH_SKEW_MS = 5 * 60 * 1_000
  41. const FCM_QUOTA_RETRY_MILLISECONDS = 60_000
  42. const FCM_TRANSIENT_RETRY_MILLISECONDS = 10_000
  43. function encodedJSON(value: unknown): string {
  44. return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url')
  45. }
  46. function clean(value: unknown): string {
  47. return typeof value === 'string' ? value.trim() : ''
  48. }
  49. function serviceAccount(path: string): ServiceAccountCredentials {
  50. const value = JSON.parse(readFileSync(path, 'utf8')) as unknown
  51. if (!value || typeof value !== 'object' || Array.isArray(value)) {
  52. throw new Error('FCM service account file must contain a JSON object')
  53. }
  54. const record = value as Record<string, unknown>
  55. const clientEmail = clean(record.client_email)
  56. const privateKey = clean(record.private_key)
  57. const tokenUri = clean(record.token_uri) || 'https://oauth2.googleapis.com/token'
  58. if (!clientEmail || !privateKey || !tokenUri) throw new Error('FCM service account credentials are incomplete')
  59. if (tokenUri !== 'https://oauth2.googleapis.com/token') throw new Error('FCM OAuth token URI is invalid')
  60. return { clientEmail, privateKey, tokenUri }
  61. }
  62. export function createFCMServiceAccountJWT(
  63. credentials: ServiceAccountCredentials,
  64. now = Date.now(),
  65. suppliedKey?: KeyObject,
  66. ): string {
  67. const issuedAt = Math.floor(now / 1_000)
  68. const header = encodedJSON({ alg: 'RS256', typ: 'JWT' })
  69. const claims = encodedJSON({
  70. iss: credentials.clientEmail,
  71. scope: FCM_OAUTH_SCOPE,
  72. aud: credentials.tokenUri,
  73. iat: issuedAt,
  74. exp: issuedAt + 3_600,
  75. })
  76. const signingInput = `${header}.${claims}`
  77. const key = suppliedKey ?? createPrivateKey(credentials.privateKey)
  78. const signature = sign('RSA-SHA256', Buffer.from(signingInput, 'ascii'), key).toString('base64url')
  79. return `${signingInput}.${signature}`
  80. }
  81. function firstHeader(headers: FCMTransportResponse['headers'], name: string): string | undefined {
  82. if (!headers) return undefined
  83. if (headers instanceof Headers) return headers.get(name) ?? undefined
  84. const value = headers[name] ?? headers[name.toLowerCase()]
  85. return Array.isArray(value) ? value[0] : value
  86. }
  87. function retryAfterMilliseconds(value: string | undefined, now: number): number | undefined {
  88. if (!value) return undefined
  89. const seconds = Number(value)
  90. if (Number.isFinite(seconds) && seconds >= 0) return Math.ceil(seconds * 1_000)
  91. const at = Date.parse(value)
  92. return Number.isNaN(at) ? undefined : Math.max(0, at - now)
  93. }
  94. function errorDetails(body: string | undefined): { reason?: string; status?: string } {
  95. if (!body) return {}
  96. try {
  97. const value = JSON.parse(body) as { error?: { message?: unknown; status?: unknown; details?: unknown[] } }
  98. const error = value.error
  99. const status = clean(error?.status)
  100. let reason = ''
  101. for (const detail of error?.details ?? []) {
  102. if (!detail || typeof detail !== 'object' || Array.isArray(detail)) continue
  103. const code = clean((detail as Record<string, unknown>).errorCode)
  104. if (code) {
  105. reason = code
  106. break
  107. }
  108. }
  109. return {
  110. ...(reason ? { reason: reason.slice(0, 128) } : {}),
  111. ...(status ? { status } : {}),
  112. }
  113. } catch {
  114. return { reason: 'FCM_ERROR_RESPONSE_INVALID' }
  115. }
  116. }
  117. export function classifyFCMResponse(
  118. response: FCMTransportResponse,
  119. now = Date.now(),
  120. ): FCMSendResult {
  121. const details = errorDetails(response.body)
  122. const common = {
  123. status: response.status,
  124. ...(details.reason ? { reason: details.reason } : {}),
  125. }
  126. if (response.status >= 200 && response.status < 300) return { disposition: 'success', ...common }
  127. if (details.reason === 'UNREGISTERED' || details.reason === 'INVALID_ARGUMENT') {
  128. return { disposition: 'unregister', ...common }
  129. }
  130. if (response.status === 429 || response.status >= 500
  131. || details.status === 'RESOURCE_EXHAUSTED' || details.status === 'UNAVAILABLE' || details.status === 'INTERNAL') {
  132. const quotaLimited = response.status === 429 || details.status === 'RESOURCE_EXHAUSTED'
  133. const retryAfterMs = retryAfterMilliseconds(firstHeader(response.headers, 'retry-after'), now)
  134. ?? (quotaLimited ? FCM_QUOTA_RETRY_MILLISECONDS : FCM_TRANSIENT_RETRY_MILLISECONDS)
  135. return { disposition: 'retry', ...common, retryAfterMs }
  136. }
  137. if (response.status === 401 || response.status === 403
  138. || details.reason === 'SENDER_ID_MISMATCH' || details.reason === 'THIRD_PARTY_AUTH_ERROR') {
  139. return { disposition: 'configuration', ...common }
  140. }
  141. return { disposition: 'failed', ...common }
  142. }
  143. export class FetchFCMTransport implements FCMTransport {
  144. constructor(
  145. readonly fetchImpl: typeof fetch = fetch,
  146. readonly timeoutMilliseconds = 15_000,
  147. ) {}
  148. async request(url: URL, init: RequestInit): Promise<FCMTransportResponse> {
  149. const response = await this.fetchImpl(url, {
  150. ...init,
  151. signal: init.signal ?? AbortSignal.timeout(this.timeoutMilliseconds),
  152. })
  153. const body = (await response.text()).slice(0, 64 * 1_024)
  154. return { status: response.status, headers: response.headers, body }
  155. }
  156. }
  157. export class FCMProvider {
  158. private readonly transport: FCMTransport
  159. private readonly now: () => number
  160. private readonly credentials: ServiceAccountCredentials
  161. private readonly privateKey: KeyObject
  162. private token?: { value: string; expiresAt: number }
  163. constructor(
  164. readonly config: FCMProviderConfig,
  165. options: FCMProviderOptions = {},
  166. ) {
  167. this.transport = options.transport ?? new FetchFCMTransport()
  168. this.now = options.now ?? Date.now
  169. this.credentials = serviceAccount(config.serviceAccountFile)
  170. this.privateKey = createPrivateKey(this.credentials.privateKey)
  171. }
  172. async probe(): Promise<void> {
  173. const result = await this.send({
  174. fid: 'probe-target-is-intentionally-invalid',
  175. data: { probe: '1' },
  176. priority: 'normal',
  177. validateOnly: true,
  178. })
  179. // validate_only may either accept a syntactically valid probe target or
  180. // reject the intentionally nonexistent FID after credentials are checked.
  181. if (result.disposition === 'success' || result.disposition === 'unregister') return
  182. throw new FCMProbeError(result)
  183. }
  184. async send(request: FCMRequest): Promise<FCMSendResult> {
  185. for (const [key, value] of Object.entries(request.data)) {
  186. const normalizedKey = key.toLowerCase()
  187. if (typeof value !== 'string') {
  188. return { disposition: 'failed', status: 400, reason: 'DataValueMustBeString' }
  189. }
  190. if (normalizedKey === 'from' || normalizedKey === 'message_type'
  191. || normalizedKey.startsWith('google.') || normalizedKey.startsWith('gcm.')) {
  192. return { disposition: 'failed', status: 400, reason: 'ReservedDataKey' }
  193. }
  194. }
  195. if (Buffer.byteLength(JSON.stringify(request.data), 'utf8') > FCM_MAX_DATA_BYTES) {
  196. return { disposition: 'failed', status: 400, reason: 'PayloadTooLarge' }
  197. }
  198. const authorization = await this.authorizationToken()
  199. if ('result' in authorization) return authorization.result
  200. const ttlSeconds = Math.max(0, Math.min(2_419_200, Math.trunc(request.ttlSeconds ?? 86_400)))
  201. const endpoint = new URL(
  202. `/v1/projects/${encodeURIComponent(this.config.projectId)}/messages:send`,
  203. FCM_ENDPOINT_ORIGIN,
  204. )
  205. const payload = {
  206. ...(request.validateOnly ? { validate_only: true } : {}),
  207. message: {
  208. fid: request.fid,
  209. data: request.data,
  210. android: {
  211. priority: request.priority === 'normal' ? 'NORMAL' : 'HIGH',
  212. ttl: `${ttlSeconds}s`,
  213. restricted_package_name: this.config.packageName,
  214. },
  215. },
  216. }
  217. try {
  218. const response = await this.transport.request(endpoint, {
  219. method: 'POST',
  220. headers: {
  221. authorization: `Bearer ${authorization.token}`,
  222. 'content-type': 'application/json; charset=utf-8',
  223. },
  224. body: JSON.stringify(payload),
  225. })
  226. const result = classifyFCMResponse(response, this.now())
  227. if (response.status === 401) this.token = undefined
  228. return result
  229. } catch {
  230. return { disposition: 'retry', status: 0, reason: 'FCM transport unavailable' }
  231. }
  232. }
  233. close(): void {
  234. this.token = undefined
  235. }
  236. private async authorizationToken(): Promise<
  237. { token: string } | { result: FCMSendResult }
  238. > {
  239. const now = this.now()
  240. if (this.token && now + ACCESS_TOKEN_REFRESH_SKEW_MS < this.token.expiresAt) {
  241. return { token: this.token.value }
  242. }
  243. const assertion = createFCMServiceAccountJWT(this.credentials, now, this.privateKey)
  244. try {
  245. const response = await this.transport.request(new URL(this.credentials.tokenUri), {
  246. method: 'POST',
  247. headers: { 'content-type': 'application/x-www-form-urlencoded; charset=utf-8' },
  248. body: new URLSearchParams({
  249. grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
  250. assertion,
  251. }).toString(),
  252. })
  253. if (response.status < 200 || response.status >= 300) {
  254. const result = classifyFCMResponse(response, now)
  255. return { result: result.disposition === 'failed' ? { ...result, disposition: 'configuration' } : result }
  256. }
  257. const value = JSON.parse(response.body || '{}') as Record<string, unknown>
  258. const token = clean(value.access_token)
  259. const expiresIn = Number(value.expires_in)
  260. if (!token || !Number.isFinite(expiresIn) || expiresIn <= 0) {
  261. return { result: { disposition: 'configuration', status: response.status, reason: 'FCM OAuth response is invalid' } }
  262. }
  263. this.token = { value: token, expiresAt: now + expiresIn * 1_000 }
  264. return { token }
  265. } catch {
  266. return { result: { disposition: 'retry', status: 0, reason: 'FCM OAuth transport unavailable' } }
  267. }
  268. }
  269. }