Files
app/ts/reception/classes.passportpushmanager.ts

232 lines
7.9 KiB
TypeScript

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<IApnsConfig | null> {
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<string, any>
) {
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;
}
}