Last active
November 3, 2023 07:22
-
-
Save RyadPasha/297ae21e94fe57eb210e7eba6cf1cc42 to your computer and use it in GitHub Desktop.
A Node.js class for validating * the authenticity of FCM and APN device/push tokens.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/** | |
* PushTokensValidator: A Node.js class for validating | |
* the authenticity of FCM and APN device/push tokens. | |
* | |
* @class PushTokensValidator | |
* @author Mohamed Riyad <m@ryad.dev> | |
* @link https://RyadPasha.com | |
* @link https://gist.github.com/RyadPasha/297ae21e94fe57eb210e7eba6cf1cc42 | |
* @copyright Copyright (C) 2023 RyadPasha. All rights reserved. | |
* @license MIT | |
* @version 1.0.2-2023.11.03 | |
* @since 1.0.0-2023.07.14 | |
*/ | |
const fs = require('fs') | |
const axios = require('axios') | |
const http2 = require('http2') | |
const jwt = require('jsonwebtoken') | |
class PushTokensValidator { | |
/** | |
* Create a new instance of TokenValidator. | |
* | |
* @param {string} fcmApiKey - The FCM API key. | |
* @param {boolean} [productionEnvironment=false] - Indicates whether to use the production environment for APN. | |
* @param {string} apnKey - The path to the APN key file or the APN key as a string or a buffer. | |
* @param {string} keyId - The APN key ID. | |
* @param {string} teamId - The APN team ID. | |
* @param {string} bundleId - The bundle ID of your app. | |
*/ | |
constructor(fcmApiKey, productionEnvironment = false, apnKey, keyId, teamId, bundleId) { | |
/** | |
* The FCM API key. | |
* @type {string} | |
*/ | |
this.fcmApiKey = fcmApiKey | |
/** | |
* The FCM API host. | |
* @type {string} | |
*/ | |
this.fcmHost = 'fcm.googleapis.com' | |
/** | |
* The FCM API path. | |
* @type {string} | |
*/ | |
this.fcmPath = '/fcm/send' | |
/** | |
* The FCM API URL. | |
* @type {string} | |
*/ | |
this.fcmUrl = `https://${this.fcmHost}${this.fcmPath}` | |
/** | |
* The batch size for sending FCM messages. | |
* @type {number} | |
*/ | |
this.fcmBatchSize = 1000 | |
/** | |
* The APN service URL. | |
* @type {string} | |
*/ | |
this.apnUrl = `https://${productionEnvironment ? 'api.push.apple.com' : 'api.sandbox.push.apple.com'}` | |
/** | |
* The path to the APN token file. | |
* @type {string} | |
*/ | |
this.apnPath = '/3/device/' | |
/** | |
* The path to the APN key file. | |
* @type {string} | |
*/ | |
this.apnKey = (() => { | |
if (!apnKey) return apnKey | |
if (/-----BEGIN ([A-Z\s*]+)-----/.test(apnKey)) { | |
// Provided key directly | |
return apnKey | |
} else if (Buffer.isBuffer(apnKey)) { | |
// Provided key as buffer | |
return apnKey | |
} else { | |
// Provided key as a file path | |
return fs.readFileSync(apnKey) | |
} | |
})() | |
/** | |
* The APN key ID. | |
* @type {string} | |
*/ | |
this.keyId = keyId | |
/** | |
* The APN team ID. | |
* @type {string} | |
*/ | |
this.teamId = teamId | |
/** | |
* The bundle ID of your app. | |
* @type {string} | |
*/ | |
this.bundleId = bundleId | |
} | |
log(...args) { | |
if (0) { | |
console.log(...args) | |
} | |
} | |
/** | |
* Validate Firebase FCM tokens in batches and capture invalid tokens. | |
* | |
* @param {string|string[]} tokens - An array of device tokens or a single device token. | |
* @param {string|null} [fcmApiKey=null] - The FCM API key. Optional if already set in the instance or class. | |
* @returns {Promise<Object>} Object containing grouped invalid tokens by error type. | |
*/ | |
async validateFcmTokens(tokens, fcmApiKey = null) { | |
if (!Array.isArray(tokens)) { | |
// Convert the single string token to array | |
tokens = [tokens] | |
} | |
const tokensLength = tokens.length | |
if (!tokensLength) { | |
this.log('No device tokens provided.') | |
return Promise.resolve({}) | |
} | |
const invalidTokens = {} | |
let totalInvalid = 0 | |
const batchSize = Math.min(tokensLength, this.fcmBatchSize) | |
const batches = Math.ceil(tokensLength / batchSize) | |
for (let i = 0; i < batches; i++) { | |
const batchTokens = tokens.slice(i * batchSize, (i + 1) * batchSize) | |
const response = await this.sendFcmBatchMessage(batchTokens, fcmApiKey || this.fcmApiKey) | |
if (response) { | |
if (response.failure) { | |
const resultLength = response.results.length | |
for (let j = 0; j < resultLength; j++) { | |
const errorResult = response.results[j].error | |
const deviceToken = batchTokens[j] | |
if (errorResult && (errorResult === 'InvalidRegistration' || errorResult === 'NotRegistered')) { | |
if (!invalidTokens[errorResult]) { | |
invalidTokens[errorResult] = [] | |
} | |
invalidTokens[errorResult].push(deviceToken) | |
totalInvalid++ | |
} | |
} | |
} | |
} else { | |
this.log('No response from FCM.') | |
} | |
} | |
return { | |
totalTokens: tokensLength, | |
totalInvalid, | |
invalidTokens | |
} | |
} | |
/** | |
* Send a batch message to FCM for multiple device tokens. | |
* | |
* @param {string[]} deviceTokens - Firebase FCM device token(s). | |
* @param {string|null} [fcmApiKey=null] - The FCM API key. Optional if already set in the instance or class. | |
* @throws {Error} Will throw an error if 'fcmApiKey' is required but not provided. | |
* @returns {Promise<object>} A promise that resolves with the JSON response from FCM. | |
*/ | |
async sendFcmBatchMessage(deviceTokens, fcmApiKey = null) { | |
if (!fcmApiKey && !this.fcmApiKey) { | |
throw new Error('fcmApiKey is required.') | |
} | |
const payload = { | |
registration_ids: deviceTokens | |
} | |
const headers = { | |
'Content-Type': 'application/json', | |
Authorization: `Bearer ${fcmApiKey || this.fcmApiKey}` | |
} | |
try { | |
const response = await axios.post(this.fcmUrl, payload, { headers }) | |
this.log('Push test sent successfully:', response.data || response) | |
return response.data || {} | |
} catch (error) { | |
console.error('Error sending push test:', error?.response?.data || error.response || error.message || error) | |
return {} | |
} | |
} | |
/** | |
* Validate Apple Push Notification (APN) tokens. | |
* | |
* @param {string|string[]} deviceTokens - Apple APN device token(s). | |
* @param {string|null} apnKey - Private key for generating the JWT. | |
* @param {string|null} teamId - Team ID of the developer actotalCount. | |
* @param {string|null} keyId - Key ID of the private key (Issuer Key ID of the .p8 file). | |
* @param {string|null} bundleId - Bundle ID of the application. | |
* @returns {Promise<string[]>} Array of invalid APN tokens. | |
* @throws {Error} If no APN key, team ID, key ID or bundle ID is provided. | |
*/ | |
validateApnTokens(deviceTokens, apnKey = null, teamId = null, keyId = null, bundleId = null) { | |
if (!(apnKey || this.apnKey) && !(teamId || this.teamId) && !(keyId || this.keyId) && !(bundleId || this.bundleId)) { | |
throw new Error('APN key, team ID, key ID and bundle ID are required.') | |
} | |
if (!Array.isArray(deviceTokens)) { | |
// Convert the single string token to array | |
deviceTokens = [deviceTokens] | |
} | |
const tokensLength = deviceTokens.length | |
if (!tokensLength) { | |
this.log('No device tokens provided.') | |
return Promise.resolve({}) | |
} | |
const unix_epoch = Math.round(new Date().getTime() / 1000) | |
const token = jwt.sign( | |
{ | |
iss: teamId || this.teamId, // `Team ID` of the developer account | |
iat: unix_epoch | |
}, | |
apnKey || this.apnKey, | |
{ | |
header: { | |
alg: 'ES256', | |
kid: keyId || this.keyId // Issuer key `Key ID` of the p8 file | |
} | |
} | |
) | |
const client = http2.connect(this.apnUrl) | |
client.on('error', (err) => console.error('APN error:', err)) | |
const body = { | |
aps: { | |
// alert: 'testing', | |
// 'content-available': 1 | |
} | |
} | |
const headers = { | |
':method': 'POST', | |
'apns-topic': bundleId || this.bundleId, // Application bundle ID | |
':scheme': 'https', | |
authorization: `bearer ${token}` | |
} | |
const invalidTokens = {} | |
let totalInvalid = 0 | |
return new Promise((resolve) => { | |
let completedRequests = 0 | |
deviceTokens.forEach((deviceToken) => { | |
const request = client.request({ ...headers, ...{ ':path': this.apnPath + deviceToken } }) | |
// request.on('response', (headers, flags) => { | |
// for (const name in headers) this.log(`- ${name}: ${headers[name]}`) | |
// }) | |
// Set request encoding to utf8 | |
request.setEncoding('utf8') | |
let data = '' | |
request.on('data', (chunk) => { | |
data += chunk | |
}) | |
request.on('end', () => { | |
if (data) { | |
try { | |
// Parse the JSON response | |
const parsedData = JSON.parse(data) | |
// Check if there was an error with the token | |
if (parsedData.reason && (parsedData.reason === 'BadDeviceToken' || parsedData.reason === 'Unregistered')) { | |
const errorType = parsedData.reason | |
if (!invalidTokens[errorType]) { | |
invalidTokens[errorType] = [] | |
} | |
invalidTokens[errorType].push(deviceToken) | |
totalInvalid++ | |
} | |
} catch (error) { | |
console.error(`Error parsing response: ${data};`, error) | |
} | |
} | |
completedRequests++ | |
if (completedRequests === tokensLength) { | |
this.log('All requests completed') | |
client.close() | |
resolve({ | |
totalTokens: tokensLength, | |
totalInvalid, | |
invalidTokens | |
}) | |
} else { | |
this.log(`Completed requests: ${completedRequests}/${tokensLength}`) | |
} | |
}) | |
request.write(JSON.stringify(body)) | |
request.end() | |
}) | |
client.on('close', () => { | |
this.log('Connection closed') | |
this.log('Invalid tokens:', invalidTokens) | |
resolve({ | |
totalTokens: tokensLength, | |
totalInvalid, | |
invalidTokens | |
}) | |
}) | |
}) | |
} | |
/** | |
* Check tokens and validate them based on the platform (Android or iOS). | |
* | |
* @param {string[]} tokens - Array of tokens. | |
* @returns {Promise<Object>} Object containing grouped invalid tokens by platform. | |
*/ | |
async checkTokens(tokens) { | |
const invalidTokens = {} | |
// const totalCount = { | |
// android: { | |
// total: 0, | |
// invalid: 0 | |
// }, | |
// ios: { | |
// total: 0, | |
// invalid: 0 | |
// }, | |
// unknown: { | |
// total: 0 | |
// } | |
// } | |
// Unique tokens only | |
tokens = [...new Set(tokens)] | |
const androidTokens = tokens.filter((token) => token.length === 163) | |
const iosTokens = tokens.filter((token) => token.length === 64) | |
const unknownTokens = tokens.filter((token) => token.length !== 163 && token.length !== 64) | |
if (androidTokens.length) { | |
this.log(`Checking ${androidTokens.length} FCM tokens...`) | |
invalidTokens.android = await this.validateFcmTokens(androidTokens) | |
// totalCount.android.total = androidTokens.length | |
// totalCount.android.invalid = invalidTokens.android.totalInvalid | |
} | |
if (iosTokens.length) { | |
this.log(`Checking ${iosTokens.length} APN tokens...`) | |
invalidTokens.ios = await this.validateApnTokens(iosTokens) | |
// totalCount.ios.total = iosTokens.length | |
// totalCount.ios.invalid = invalidTokens.ios.totalInvalid | |
} | |
if (unknownTokens.length) { | |
this.log(`There are ${unknownTokens.length} unknown tokens.`) | |
invalidTokens.unknown = unknownTokens.length | |
// totalCount.unknown.total = unknownTokens.length | |
} | |
return invalidTokens | |
// return { | |
// invalidTokens, | |
// count | |
// } | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment