232 lines
7.9 KiB
TypeScript
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;
|
||
|
|
}
|
||
|
|
}
|