import * as plugins from '../plugins.js'; import { Alert } from './classes.alert.js'; import { logger } from './logging.js'; import { PassportChallenge } from './classes.passportchallenge.js'; import { PassportDevice } from './classes.passportdevice.js'; import type { Reception } from './classes.reception.js'; interface IApnsConfig { keyId: string; teamId: string; privateKey: string; } export class PassportPushManager { public receptionRef: Reception; constructor(receptionRefArg: Reception) { this.receptionRef = receptionRefArg; } private async getApnsConfig(): Promise { try { return { keyId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_KEY_ID'), teamId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_TEAM_ID'), privateKey: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_PRIVATE_KEY'), }; } catch { return null; } } private base64UrlEncode(valueArg: string | Buffer) { return Buffer.from(valueArg).toString('base64url'); } private createApnsJwt(configArg: IApnsConfig) { const nowSeconds = Math.floor(Date.now() / 1000); const header = this.base64UrlEncode( JSON.stringify({ alg: 'ES256', kid: configArg.keyId, typ: 'JWT' }) ); const payload = this.base64UrlEncode(JSON.stringify({ iss: configArg.teamId, iat: nowSeconds })); const unsignedToken = `${header}.${payload}`; const signature = plugins.crypto.sign('sha256', Buffer.from(unsignedToken, 'utf8'), { key: configArg.privateKey.replace(/\\n/g, '\n'), dsaEncoding: 'ieee-p1363', }); return `${unsignedToken}.${this.base64UrlEncode(signature)}`; } private async deliverApnsPayload( passportDeviceArg: PassportDevice, payloadArg: Record ) { if (!passportDeviceArg.data.pushRegistration) { return { ok: false, status: 0, text: async () => 'Passport device has no push registration', }; } const apnsConfig = await this.getApnsConfig(); if (!apnsConfig) { return { ok: false, status: 0, text: async () => 'APNs push transport is not configured', }; } const pushRegistration = passportDeviceArg.data.pushRegistration; const apnsHost = pushRegistration.environment === 'production' ? 'https://api.push.apple.com' : 'https://api.sandbox.push.apple.com'; const authorizationToken = this.createApnsJwt(apnsConfig); return fetch(`${apnsHost}/3/device/${pushRegistration.token}`, { method: 'POST', headers: { authorization: `bearer ${authorizationToken}`, 'apns-topic': pushRegistration.topic, 'apns-push-type': 'alert', 'content-type': 'application/json', }, body: JSON.stringify(payloadArg), }).catch((errorArg: Error) => { return { ok: false, status: 0, text: async () => errorArg.message, }; }); } public async deliverChallengeHint(passportDeviceArg: PassportDevice, passportChallengeArg: PassportChallenge) { if (!passportDeviceArg.data.pushRegistration) { passportChallengeArg.data.notification = { ...passportChallengeArg.data.notification, status: 'failed', attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1, lastError: 'Passport device has no push registration', }; await passportChallengeArg.save(); return false; } if (!(await this.getApnsConfig())) { passportChallengeArg.data.notification = { ...passportChallengeArg.data.notification, status: 'failed', attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1, lastError: 'APNs push transport is not configured', }; await passportChallengeArg.save(); logger.log('warn', 'passport push delivery skipped because APNs is not configured'); return false; } const response = await this.deliverApnsPayload(passportDeviceArg, { aps: { alert: { title: passportChallengeArg.data.metadata.notificationTitle || 'idp.global challenge', body: `Open idp.global to review your ${passportChallengeArg.data.type} request.`, }, sound: 'default', }, kind: 'passport_challenge', hintId: passportChallengeArg.data.notification?.hintId, challengeId: passportChallengeArg.id, severity: passportChallengeArg.data.type === 'physical_access' ? 'high' : passportChallengeArg.data.type, }); const responseText = await response.text(); if (response.ok) { passportDeviceArg.data.pushRegistration.lastDeliveredAt = Date.now(); passportDeviceArg.data.pushRegistration.lastError = undefined; passportChallengeArg.data.notification = { ...passportChallengeArg.data.notification, status: 'sent', attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1, deliveredAt: Date.now(), lastError: null, }; await passportDeviceArg.save(); await passportChallengeArg.save(); return true; } passportDeviceArg.data.pushRegistration.lastError = responseText || `APNs error ${response.status}`; passportChallengeArg.data.notification = { ...passportChallengeArg.data.notification, status: 'failed', attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1, lastError: responseText || `APNs error ${response.status}`, }; await passportDeviceArg.save(); await passportChallengeArg.save(); logger.log('warn', `passport push delivery failed: ${responseText || response.status}`); return false; } public async deliverAlertHint(passportDeviceArg: PassportDevice, alertArg: Alert) { if (!passportDeviceArg.data.pushRegistration) { alertArg.data.notification = { ...alertArg.data.notification, status: 'failed', attemptCount: alertArg.data.notification.attemptCount + 1, lastError: 'Passport device has no push registration', }; await alertArg.save(); return false; } if (!(await this.getApnsConfig())) { alertArg.data.notification = { ...alertArg.data.notification, status: 'failed', attemptCount: alertArg.data.notification.attemptCount + 1, lastError: 'APNs push transport is not configured', }; await alertArg.save(); return false; } const response = await this.deliverApnsPayload(passportDeviceArg, { aps: { alert: { title: alertArg.data.title, body: alertArg.data.body, }, sound: 'default', }, kind: 'passport_alert', hintId: alertArg.data.notification.hintId, alertId: alertArg.id, severity: alertArg.data.severity, eventType: alertArg.data.eventType, }); const responseText = await response.text(); if (response.ok) { passportDeviceArg.data.pushRegistration.lastDeliveredAt = Date.now(); passportDeviceArg.data.pushRegistration.lastError = undefined; alertArg.data.notification = { ...alertArg.data.notification, status: 'sent', attemptCount: alertArg.data.notification.attemptCount + 1, deliveredAt: Date.now(), lastError: null, }; await passportDeviceArg.save(); await alertArg.save(); return true; } passportDeviceArg.data.pushRegistration.lastError = responseText || `APNs error ${response.status}`; alertArg.data.notification = { ...alertArg.data.notification, status: 'failed', attemptCount: alertArg.data.notification.attemptCount + 1, lastError: responseText || `APNs error ${response.status}`, }; await passportDeviceArg.save(); await alertArg.save(); logger.log('warn', `passport alert push delivery failed: ${responseText || response.status}`); return false; } }