Skip to content

Instantly share code, notes, and snippets.

@RyadPasha
Last active November 3, 2023 07:22
Show Gist options
  • Save RyadPasha/297ae21e94fe57eb210e7eba6cf1cc42 to your computer and use it in GitHub Desktop.
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.
/**
* 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