feat(reception): add passport device authentication flows and alert delivery management

This commit is contained in:
2026-04-20 10:26:22 +00:00
parent 3cd7499f3f
commit 6044928c70
26 changed files with 2943 additions and 4 deletions
+39
View File
@@ -0,0 +1,39 @@
import * as plugins from '../plugins.js';
import type { AlertManager } from './classes.alertmanager.js';
@plugins.smartdata.Manager()
export class Alert extends plugins.smartdata.SmartDataDbDoc<
Alert,
plugins.idpInterfaces.data.IAlert,
AlertManager
> {
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IAlert['data'] = {
recipientUserId: '',
organizationId: undefined,
category: 'security',
eventType: '',
severity: 'medium',
title: '',
body: '',
actorUserId: undefined,
relatedEntityId: undefined,
relatedEntityType: undefined,
notification: {
hintId: '',
status: 'pending',
attemptCount: 0,
createdAt: 0,
deliveredAt: null,
seenAt: null,
lastError: null,
},
createdAt: 0,
seenAt: null,
dismissedAt: null,
};
}
+425
View File
@@ -0,0 +1,425 @@
import * as plugins from '../plugins.js';
import { Alert } from './classes.alert.js';
import { AlertRule } from './classes.alertrule.js';
import type { Reception } from './classes.reception.js';
const severityOrder: Record<plugins.idpInterfaces.data.TAlertSeverity, number> = {
low: 1,
medium: 2,
high: 3,
critical: 4,
};
export class AlertManager {
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public typedRouter = new plugins.typedrequest.TypedRouter();
public CAlert = plugins.smartdata.setDefaultManagerForDoc(this, Alert);
public CAlertRule = plugins.smartdata.setDefaultManagerForDoc(this, AlertRule);
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ListPassportAlerts>(
'listPassportAlerts',
async (requestArg) => {
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
requestArg,
{
action: 'listPassportAlerts',
}
);
const alerts = await this.listAlertsForUser(passportDevice.data.userId);
return {
alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })),
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportAlertByHint>(
'getPassportAlertByHint',
async (requestArg) => {
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
requestArg,
{
action: 'getPassportAlertByHint',
signedFields: [`hint_id=${requestArg.hintId}`],
}
);
const alert = await this.getAlertByHint(passportDevice.data.userId, requestArg.hintId);
return {
alert: alert ? { id: alert.id, data: alert.data } : undefined,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MarkPassportAlertSeen>(
'markPassportAlertSeen',
async (requestArg) => {
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
requestArg,
{
action: 'markPassportAlertSeen',
signedFields: [`hint_id=${requestArg.hintId}`],
}
);
await this.markAlertSeen(passportDevice.data.userId, requestArg.hintId);
return {
success: true,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpsertAlertRule>(
'upsertAlertRule',
async (requestArg) => {
const actorUserId = await this.verifyAlertRuleAccess(
requestArg.jwt,
requestArg.scope,
requestArg.organizationId
);
const rule = requestArg.ruleId
? await this.CAlertRule.getInstance({ id: requestArg.ruleId })
: new AlertRule();
if (!rule) {
throw new plugins.typedrequest.TypedResponseError('Alert rule not found');
}
rule.id = rule.id || plugins.smartunique.shortId();
rule.data = {
scope: requestArg.scope,
organizationId: requestArg.organizationId,
eventType: requestArg.eventType,
minimumSeverity: requestArg.minimumSeverity,
recipientMode: requestArg.recipientMode,
recipientUserIds: requestArg.recipientUserIds || [],
push: requestArg.push,
enabled: requestArg.enabled,
createdByUserId: rule.data?.createdByUserId || actorUserId,
createdAt: rule.data?.createdAt || Date.now(),
updatedAt: Date.now(),
};
await rule.save();
return {
rule: {
id: rule.id,
data: rule.data,
},
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetAlertRules>(
'getAlertRules',
async (requestArg) => {
await this.verifyAlertRuleAccess(requestArg.jwt, requestArg.scope || 'global', requestArg.organizationId);
const rules = await this.CAlertRule.getInstances({});
return {
rules: rules
.filter((ruleArg) => {
if (requestArg.scope && ruleArg.data.scope !== requestArg.scope) {
return false;
}
if (requestArg.organizationId && ruleArg.data.organizationId !== requestArg.organizationId) {
return false;
}
return true;
})
.map((ruleArg) => ({ id: ruleArg.id, data: ruleArg.data })),
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteAlertRule>(
'deleteAlertRule',
async (requestArg) => {
const rule = await this.CAlertRule.getInstance({ id: requestArg.ruleId });
if (!rule) {
throw new plugins.typedrequest.TypedResponseError('Alert rule not found');
}
await this.verifyAlertRuleAccess(requestArg.jwt, rule.data.scope, rule.data.organizationId);
await rule.delete();
return {
success: true,
};
}
)
);
}
private async verifyAlertRuleAccess(
jwtArg: string,
scopeArg: plugins.idpInterfaces.data.TAlertRuleScope,
organizationIdArg?: string
) {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
if (scopeArg === 'global') {
const user = await this.receptionRef.userManager.CUser.getInstance({ id: jwt.data.userId });
if (!user?.data?.isGlobalAdmin) {
throw new plugins.typedrequest.TypedResponseError('Global admin privileges required');
}
return jwt.data.userId;
}
if (!organizationIdArg) {
throw new plugins.typedrequest.TypedResponseError('organizationId is required');
}
const role = await this.receptionRef.roleManager.CRole.getInstance({
data: {
userId: jwt.data.userId,
organizationId: organizationIdArg,
},
});
if (!role || !role.data.roles.some((roleArg) => ['owner', 'admin'].includes(roleArg))) {
throw new plugins.typedrequest.TypedResponseError('Organization admin privileges required');
}
return jwt.data.userId;
}
private async resolveGlobalAdminRecipients() {
const users = await this.receptionRef.userManager.CUser.getInstances({});
return users.filter((userArg) => !!userArg.data.isGlobalAdmin);
}
private async resolveOrganizationAdminRecipients(organizationIdArg: string) {
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organizationIdArg);
const adminUserIds = [...new Set(
roles
.filter((roleArg) => roleArg.data.roles.some((roleNameArg) => ['owner', 'admin'].includes(roleNameArg)))
.map((roleArg) => roleArg.data.userId)
)];
const users = await Promise.all(
adminUserIds.map((userIdArg) => this.receptionRef.userManager.CUser.getInstance({ id: userIdArg }))
);
return users.filter(Boolean);
}
private async resolveRuleRecipients(ruleArg: AlertRule) {
switch (ruleArg.data.recipientMode) {
case 'global_admins':
return this.resolveGlobalAdminRecipients();
case 'org_admins':
if (!ruleArg.data.organizationId) {
return [];
}
return this.resolveOrganizationAdminRecipients(ruleArg.data.organizationId);
case 'specific_users':
if (!ruleArg.data.recipientUserIds?.length) {
return [];
}
const users = await Promise.all(
ruleArg.data.recipientUserIds.map((userIdArg) =>
this.receptionRef.userManager.CUser.getInstance({ id: userIdArg })
)
);
return users.filter(Boolean);
}
}
private async getMatchingRules(optionsArg: {
eventType: string;
severity: plugins.idpInterfaces.data.TAlertSeverity;
organizationId?: string;
}) {
const rules = await this.CAlertRule.getInstances({});
const matchingRules = rules.filter((ruleArg) => {
if (!ruleArg.data.enabled) {
return false;
}
if (ruleArg.data.eventType !== optionsArg.eventType) {
return false;
}
if (ruleArg.data.scope === 'organization' && ruleArg.data.organizationId !== optionsArg.organizationId) {
return false;
}
return severityOrder[optionsArg.severity] >= severityOrder[ruleArg.data.minimumSeverity];
});
if (matchingRules.length > 0) {
return matchingRules;
}
if (optionsArg.eventType === 'global_admin_access') {
const fallbackRule = new AlertRule();
fallbackRule.id = 'builtin-global-admin-access';
fallbackRule.data = {
scope: 'global',
organizationId: undefined,
eventType: 'global_admin_access',
minimumSeverity: 'high',
recipientMode: 'global_admins',
recipientUserIds: [],
push: true,
enabled: true,
createdByUserId: 'system',
createdAt: 0,
updatedAt: 0,
};
return [fallbackRule];
}
if (optionsArg.eventType === 'global_app_credentials_regenerated') {
const fallbackRule = new AlertRule();
fallbackRule.id = 'builtin-global-app-credentials-regenerated';
fallbackRule.data = {
scope: 'global',
organizationId: undefined,
eventType: 'global_app_credentials_regenerated',
minimumSeverity: 'critical',
recipientMode: 'global_admins',
recipientUserIds: [],
push: true,
enabled: true,
createdByUserId: 'system',
createdAt: 0,
updatedAt: 0,
};
return [fallbackRule];
}
return [];
}
public async createAlertsForEvent(optionsArg: {
category: plugins.idpInterfaces.data.TAlertCategory;
eventType: string;
severity: plugins.idpInterfaces.data.TAlertSeverity;
title: string;
body: string;
actorUserId?: string;
organizationId?: string;
relatedEntityId?: string;
relatedEntityType?: string;
}) {
const matchingRules = await this.getMatchingRules(optionsArg);
if (matchingRules.length === 0) {
return [];
}
const recipientIds = new Set<string>();
for (const rule of matchingRules) {
const recipients = await this.resolveRuleRecipients(rule);
for (const recipient of recipients) {
recipientIds.add(recipient.id);
}
}
const createdAlerts: Alert[] = [];
for (const recipientUserId of recipientIds) {
const alert = new Alert();
alert.id = plugins.smartunique.shortId();
alert.data = {
recipientUserId,
organizationId: optionsArg.organizationId,
category: optionsArg.category,
eventType: optionsArg.eventType,
severity: optionsArg.severity,
title: optionsArg.title,
body: optionsArg.body,
actorUserId: optionsArg.actorUserId,
relatedEntityId: optionsArg.relatedEntityId,
relatedEntityType: optionsArg.relatedEntityType,
notification: {
hintId: plugins.crypto.randomUUID(),
status: 'pending',
attemptCount: 0,
createdAt: Date.now(),
deliveredAt: null,
seenAt: null,
lastError: null,
},
createdAt: Date.now(),
seenAt: null,
dismissedAt: null,
};
await alert.save();
createdAlerts.push(alert);
const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(recipientUserId);
let delivered = false;
for (const device of devices) {
const result = await this.receptionRef.passportPushManager.deliverAlertHint(device, alert);
delivered = delivered || result;
}
if (!delivered && devices.length === 0) {
alert.data.notification = {
...alert.data.notification,
status: 'failed',
attemptCount: alert.data.notification.attemptCount + 1,
lastError: 'Recipient has no active passport device',
};
await alert.save();
}
}
return createdAlerts;
}
public async listAlertsForUser(userIdArg: string) {
const alerts = await this.CAlert.getInstances({
'data.recipientUserId': userIdArg,
});
return alerts.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt);
}
public async getAlertByHint(userIdArg: string, hintIdArg: string) {
return this.CAlert.getInstance({
'data.recipientUserId': userIdArg,
'data.notification.hintId': hintIdArg,
});
}
public async markAlertSeen(userIdArg: string, hintIdArg: string) {
const alert = await this.getAlertByHint(userIdArg, hintIdArg);
if (!alert) {
throw new plugins.typedrequest.TypedResponseError('Alert not found');
}
alert.data.seenAt = Date.now();
alert.data.notification = {
...alert.data.notification,
status: 'seen',
seenAt: Date.now(),
};
await alert.save();
return alert;
}
public async reDeliverPendingAlerts() {
const alerts = await this.CAlert.getInstances({});
for (const alert of alerts) {
if (alert.data.notification.status === 'sent' || alert.data.notification.status === 'seen') {
continue;
}
const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(
alert.data.recipientUserId
);
for (const device of devices) {
await this.receptionRef.passportPushManager.deliverAlertHint(device, alert);
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
import * as plugins from '../plugins.js';
import type { AlertManager } from './classes.alertmanager.js';
@plugins.smartdata.Manager()
export class AlertRule extends plugins.smartdata.SmartDataDbDoc<
AlertRule,
plugins.idpInterfaces.data.IAlertRule,
AlertManager
> {
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IAlertRule['data'] = {
scope: 'global',
organizationId: undefined,
eventType: '',
minimumSeverity: 'medium',
recipientMode: 'global_admins',
recipientUserIds: [],
push: true,
enabled: true,
createdByUserId: '',
createdAt: 0,
updatedAt: 0,
};
}
+26 -2
View File
@@ -59,7 +59,20 @@ export class AppManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
'getGlobalAppStats',
async (requestArg) => {
await this.verifyGlobalAdmin(requestArg.jwt);
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
const user = await this.receptionRef.userManager.CUser.getInstance({
id: jwtData.data.userId,
});
await this.receptionRef.alertManager.createAlertsForEvent({
category: 'admin',
eventType: 'global_admin_access',
severity: 'high',
title: 'Global admin console accessed',
body: `${user?.data?.email || 'A global admin'} accessed the global app administration dashboard.`,
actorUserId: jwtData.data.userId,
relatedEntityType: 'global-admin-console',
});
// Get all global apps (including inactive)
const globalApps = await this.CApp.getInstances({
@@ -198,7 +211,7 @@ export class AppManager {
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
'regenerateAppCredentials',
async (requestArg) => {
await this.verifyGlobalAdmin(requestArg.jwt);
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
const app = await this.CApp.getInstance({ id: requestArg.appId });
if (!app) {
@@ -214,6 +227,17 @@ export class AppManager {
app.data.oauthCredentials.clientSecretHash = clientSecretHash;
await app.save();
await this.receptionRef.alertManager.createAlertsForEvent({
category: 'security',
eventType: 'global_app_credentials_regenerated',
severity: 'critical',
title: 'Global app credentials regenerated',
body: `OAuth credentials for ${app.data.name} were regenerated.`,
actorUserId: jwtData.data.userId,
relatedEntityId: app.id,
relatedEntityType: 'global-app',
});
return {
clientId,
clientSecret, // Only shown once
+30
View File
@@ -94,6 +94,36 @@ export class ReceptionHousekeeping {
'2 * * * * *'
);
this.taskmanager.addAndScheduleTask(
new plugins.taskbuffer.Task({
name: 'expiredPassportChallenges',
taskFunction: async () => {
await this.receptionRef.passportManager.cleanupExpiredChallenges();
},
}),
'2 * * * * *'
);
this.taskmanager.addAndScheduleTask(
new plugins.taskbuffer.Task({
name: 'redeliverPassportChallengeHints',
taskFunction: async () => {
await this.receptionRef.passportManager.reDeliverPendingChallengeHints();
},
}),
'7 * * * * *'
);
this.taskmanager.addAndScheduleTask(
new plugins.taskbuffer.Task({
name: 'redeliverAlertHints',
taskFunction: async () => {
await this.receptionRef.alertManager.reDeliverPendingAlerts();
},
}),
'12 * * * * *'
);
this.taskmanager.start();
logger.log('info', 'housekeeping started');
}
+59
View File
@@ -0,0 +1,59 @@
import * as plugins from '../plugins.js';
import type { PassportManager } from './classes.passportmanager.js';
@plugins.smartdata.Manager()
export class PassportChallenge extends plugins.smartdata.SmartDataDbDoc<
PassportChallenge,
plugins.idpInterfaces.data.IPassportChallenge,
PassportManager
> {
public static hashToken(tokenArg: string) {
return plugins.smarthash.sha256FromStringSync(tokenArg);
}
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IPassportChallenge['data'] = {
userId: '',
deviceId: null,
type: 'device_enrollment',
status: 'pending',
tokenHash: null,
challenge: '',
metadata: {
originHost: undefined,
audience: undefined,
notificationTitle: undefined,
deviceLabel: undefined,
requireLocation: false,
requireNfc: false,
requestedCapabilities: undefined,
},
evidence: undefined,
notification: undefined,
createdAt: 0,
expiresAt: 0,
completedAt: null,
};
public isExpired(nowArg = Date.now()) {
return this.data.expiresAt < nowArg;
}
public async markApproved(
evidenceArg?: plugins.idpInterfaces.data.IPassportChallenge['data']['evidence']
) {
this.data.status = 'approved';
this.data.completedAt = Date.now();
this.data.evidence = evidenceArg;
await this.save();
}
public async markExpired() {
this.data.status = 'expired';
await this.save();
}
}
+37
View File
@@ -0,0 +1,37 @@
import * as plugins from '../plugins.js';
import type { PassportManager } from './classes.passportmanager.js';
@plugins.smartdata.Manager()
export class PassportDevice extends plugins.smartdata.SmartDataDbDoc<
PassportDevice,
plugins.idpInterfaces.data.IPassportDevice,
PassportManager
> {
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IPassportDevice['data'] = {
userId: '',
label: '',
platform: 'unknown',
status: 'active',
publicKeyAlgorithm: 'p256',
publicKeyX963Base64: '',
capabilities: {
gps: false,
nfc: false,
push: false,
},
pushRegistration: undefined,
appVersion: undefined,
createdAt: 0,
lastSeenAt: undefined,
lastChallengeAt: undefined,
};
public isActive() {
return this.data.status === 'active';
}
}
+816
View File
@@ -0,0 +1,816 @@
import * as plugins from '../plugins.js';
import { PassportChallenge } from './classes.passportchallenge.js';
import { PassportDevice } from './classes.passportdevice.js';
import { PassportNonce } from './classes.passportnonce.js';
import { logger } from './logging.js';
import { Reception } from './classes.reception.js';
export class PassportManager {
private readonly enrollmentChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({
minutes: 10,
});
private readonly assertionChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({
minutes: 5,
});
private readonly deviceRequestWindowMillis = plugins.smarttime.getMilliSecondsFromUnits({
minutes: 5,
});
public receptionRef: Reception;
public get db() {
return this.receptionRef.db.smartdataDb;
}
public typedRouter = new plugins.typedrequest.TypedRouter();
public CPassportDevice = plugins.smartdata.setDefaultManagerForDoc(this, PassportDevice);
public CPassportChallenge = plugins.smartdata.setDefaultManagerForDoc(this, PassportChallenge);
public CPassportNonce = plugins.smartdata.setDefaultManagerForDoc(this, PassportNonce);
constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreatePassportEnrollmentChallenge>(
'createPassportEnrollmentChallenge',
async (requestArg) => {
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
const enrollmentChallenge = await this.createEnrollmentChallengeForUser(userId, {
deviceLabel: requestArg.deviceLabel,
platform: requestArg.platform,
appVersion: requestArg.appVersion,
capabilities: requestArg.capabilities,
});
return {
challengeId: enrollmentChallenge.challenge.id,
pairingToken: enrollmentChallenge.pairingToken,
pairingPayload: enrollmentChallenge.pairingPayload,
signingPayload: enrollmentChallenge.signingPayload,
expiresAt: enrollmentChallenge.challenge.data.expiresAt,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CompletePassportEnrollment>(
'completePassportEnrollment',
async (requestArg) => {
const passportDevice = await this.completeEnrollment({
pairingToken: requestArg.pairingToken,
deviceLabel: requestArg.deviceLabel,
platform: requestArg.platform,
publicKeyX963Base64: requestArg.publicKeyX963Base64,
signatureBase64: requestArg.signatureBase64,
signatureFormat: requestArg.signatureFormat,
appVersion: requestArg.appVersion,
capabilities: requestArg.capabilities,
});
return {
device: {
id: passportDevice.id,
data: passportDevice.data,
},
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportDevices>(
'getPassportDevices',
async (requestArg) => {
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
const devices = await this.getPassportDevicesForUser(userId);
return {
devices: devices.map((deviceArg) => ({
id: deviceArg.id,
data: deviceArg.data,
})),
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RevokePassportDevice>(
'revokePassportDevice',
async (requestArg) => {
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
await this.revokePassportDeviceForUser(userId, requestArg.deviceId);
return {
success: true,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreatePassportChallenge>(
'createPassportChallenge',
async (requestArg) => {
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
const challengeResult = await this.createPassportChallengeForUser(userId, {
type: requestArg.type,
preferredDeviceId: requestArg.preferredDeviceId,
audience: requestArg.audience,
notificationTitle: requestArg.notificationTitle,
requireLocation: requestArg.requireLocation,
requireNfc: requestArg.requireNfc,
});
return {
challengeId: challengeResult.challenge.id,
challenge: challengeResult.challenge.data.challenge,
signingPayload: challengeResult.signingPayload,
deviceId: challengeResult.challenge.data.deviceId!,
expiresAt: challengeResult.challenge.data.expiresAt,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ApprovePassportChallenge>(
'approvePassportChallenge',
async (requestArg) => {
const passportChallenge = await this.approvePassportChallenge({
challengeId: requestArg.challengeId,
deviceId: requestArg.deviceId,
signatureBase64: requestArg.signatureBase64,
signatureFormat: requestArg.signatureFormat,
location: requestArg.location,
nfc: requestArg.nfc,
});
return {
success: true,
challenge: {
id: passportChallenge.id,
data: passportChallenge.data,
},
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegisterPassportPushToken>(
'registerPassportPushToken',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'registerPassportPushToken',
signedFields: [
`provider=${requestArg.provider}`,
`token=${requestArg.token}`,
`topic=${requestArg.topic}`,
`environment=${requestArg.environment}`,
],
});
passportDevice.data.pushRegistration = {
provider: requestArg.provider,
token: requestArg.token,
topic: requestArg.topic,
environment: requestArg.environment,
registeredAt: Date.now(),
lastDeliveredAt: passportDevice.data.pushRegistration?.lastDeliveredAt,
lastError: undefined,
};
passportDevice.data.lastSeenAt = Date.now();
await passportDevice.save();
return {
success: true,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ListPendingPassportChallenges>(
'listPendingPassportChallenges',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'listPendingPassportChallenges',
});
const challenges = await this.listPendingChallengesForDevice(passportDevice.id);
return {
challenges: challenges.map((challengeArg) => ({
id: challengeArg.id,
data: challengeArg.data,
})),
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportChallengeByHint>(
'getPassportChallengeByHint',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'getPassportChallengeByHint',
signedFields: [`hint_id=${requestArg.hintId}`],
});
const passportChallenge = await this.getPassportChallengeByHint(passportDevice.id, requestArg.hintId);
return {
challenge: passportChallenge
? {
id: passportChallenge.id,
data: passportChallenge.data,
}
: undefined,
};
}
)
);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MarkPassportChallengeSeen>(
'markPassportChallengeSeen',
async (requestArg) => {
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
action: 'markPassportChallengeSeen',
signedFields: [`hint_id=${requestArg.hintId}`],
});
await this.markPassportChallengeSeen(passportDevice.id, requestArg.hintId);
return {
success: true,
};
}
)
);
}
private async getAuthenticatedUserId(jwtArg: string) {
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg);
if (!jwt) {
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
}
return jwt.data.userId;
}
private getOriginHost() {
return new URL(this.receptionRef.options.baseUrl).host;
}
private createOpaqueToken(prefixArg: string) {
return `${prefixArg}${plugins.crypto.randomBytes(32).toString('base64url')}`;
}
private buildDeviceRequestSigningPayload(
requestArg: plugins.idpInterfaces.request.IPassportDeviceSignedRequest,
actionArg: string,
signedFieldsArg: string[] = []
) {
return [
'purpose=passport-device-request',
`origin=${this.getOriginHost()}`,
`action=${actionArg}`,
`device_id=${requestArg.deviceId}`,
`timestamp=${requestArg.timestamp}`,
`nonce=${requestArg.nonce}`,
...signedFieldsArg,
].join('\n');
}
private async consumePassportNonce(deviceIdArg: string, nonceArg: string, timestampArg: number) {
const now = Date.now();
if (Math.abs(now - timestampArg) > this.deviceRequestWindowMillis) {
throw new plugins.typedrequest.TypedResponseError('Passport device request timestamp expired');
}
const existingNonce = await this.CPassportNonce.getInstance({
id: PassportNonce.hashNonce(`${deviceIdArg}:${nonceArg}`),
});
if (existingNonce && !existingNonce.isExpired(now)) {
throw new plugins.typedrequest.TypedResponseError('Passport device request replay detected');
}
const passportNonce = existingNonce || new PassportNonce();
passportNonce.id = PassportNonce.hashNonce(`${deviceIdArg}:${nonceArg}`);
passportNonce.data = {
deviceId: deviceIdArg,
nonceHash: PassportNonce.hashNonce(nonceArg),
createdAt: now,
expiresAt: now + this.deviceRequestWindowMillis,
};
await passportNonce.save();
}
public async authenticatePassportDeviceRequest(
requestArg: plugins.idpInterfaces.request.IPassportDeviceSignedRequest,
optionsArg: {
action: string;
signedFields?: string[];
}
) {
const passportDevice = await this.CPassportDevice.getInstance({
id: requestArg.deviceId,
'data.status': 'active',
});
if (!passportDevice) {
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
}
const verified = this.verifyPassportSignature(
passportDevice.data.publicKeyX963Base64,
requestArg.signatureBase64,
requestArg.signatureFormat || 'raw',
this.buildDeviceRequestSigningPayload(
requestArg,
optionsArg.action,
optionsArg.signedFields || []
)
);
if (!verified) {
throw new plugins.typedrequest.TypedResponseError('Passport device signature invalid');
}
await this.consumePassportNonce(requestArg.deviceId, requestArg.nonce, requestArg.timestamp);
passportDevice.data.lastSeenAt = Date.now();
await passportDevice.save();
return passportDevice;
}
private normalizeCapabilities(
capabilitiesArg?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>
): plugins.idpInterfaces.data.IPassportCapabilities {
return {
gps: !!capabilitiesArg?.gps,
nfc: !!capabilitiesArg?.nfc,
push: !!capabilitiesArg?.push,
};
}
private buildEnrollmentSigningPayload(pairingTokenArg: string, challengeArg: PassportChallenge) {
return [
'purpose=passport-enrollment',
`origin=${this.getOriginHost()}`,
`token=${pairingTokenArg}`,
`challenge=${challengeArg.data.challenge}`,
`challenge_id=${challengeArg.id}`,
].join('\n');
}
private buildChallengeSigningPayload(challengeArg: PassportChallenge) {
return [
'purpose=passport-challenge',
`origin=${this.getOriginHost()}`,
`challenge=${challengeArg.data.challenge}`,
`challenge_id=${challengeArg.id}`,
`type=${challengeArg.data.type}`,
`device_id=${challengeArg.data.deviceId || ''}`,
`audience=${challengeArg.data.metadata.audience || ''}`,
`require_location=${challengeArg.data.metadata.requireLocation}`,
`require_nfc=${challengeArg.data.metadata.requireNfc}`,
].join('\n');
}
private createPairingPayload(
pairingTokenArg: string,
challengeArg: PassportChallenge,
deviceLabelArg: string
) {
const searchParams = new URLSearchParams({
token: pairingTokenArg,
challenge: challengeArg.data.challenge,
challenge_id: challengeArg.id,
origin: this.getOriginHost(),
device: deviceLabelArg,
});
return `idp.global://pair?${searchParams.toString()}`;
}
private createP256JwkFromX963(publicKeyX963Base64Arg: string) {
const rawPublicKey = Buffer.from(publicKeyX963Base64Arg, 'base64');
if (rawPublicKey.length !== 65 || rawPublicKey[0] !== 4) {
throw new plugins.typedrequest.TypedResponseError('Invalid passport public key');
}
return {
kty: 'EC',
crv: 'P-256',
x: rawPublicKey.subarray(1, 33).toString('base64url'),
y: rawPublicKey.subarray(33, 65).toString('base64url'),
ext: true,
} as JsonWebKey;
}
private verifyPassportSignature(
publicKeyX963Base64Arg: string,
signatureBase64Arg: string,
signatureFormatArg: plugins.idpInterfaces.data.TPassportSignatureFormat,
payloadArg: string
) {
const publicKey = plugins.crypto.createPublicKey({
key: this.createP256JwkFromX963(publicKeyX963Base64Arg),
format: 'jwk',
});
const signature = Buffer.from(signatureBase64Arg, 'base64');
const payload = Buffer.from(payloadArg, 'utf8');
return signatureFormatArg === 'raw'
? plugins.crypto.verify('sha256', payload, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature)
: plugins.crypto.verify('sha256', payload, publicKey, signature);
}
public async createEnrollmentChallengeForUser(
userIdArg: string,
optionsArg: {
deviceLabel: string;
platform: plugins.idpInterfaces.data.TPassportDevicePlatform;
appVersion?: string;
capabilities?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>;
}
) {
const pairingToken = this.createOpaqueToken('passport_pair_');
const passportChallenge = new PassportChallenge();
passportChallenge.id = plugins.smartunique.shortId();
passportChallenge.data = {
userId: userIdArg,
deviceId: null,
type: 'device_enrollment',
status: 'pending',
tokenHash: PassportChallenge.hashToken(pairingToken),
challenge: this.createOpaqueToken('challenge_'),
metadata: {
originHost: this.getOriginHost(),
deviceLabel: optionsArg.deviceLabel,
requireLocation: false,
requireNfc: false,
requestedCapabilities: this.normalizeCapabilities(optionsArg.capabilities),
},
evidence: undefined,
notification: undefined,
createdAt: Date.now(),
expiresAt: Date.now() + this.enrollmentChallengeMillis,
completedAt: null,
};
await passportChallenge.save();
return {
challenge: passportChallenge,
pairingToken,
pairingPayload: this.createPairingPayload(
pairingToken,
passportChallenge,
optionsArg.deviceLabel
),
signingPayload: this.buildEnrollmentSigningPayload(pairingToken, passportChallenge),
};
}
public async completeEnrollment(optionsArg: {
pairingToken: string;
deviceLabel: string;
platform: plugins.idpInterfaces.data.TPassportDevicePlatform;
publicKeyX963Base64: string;
signatureBase64: string;
signatureFormat?: plugins.idpInterfaces.data.TPassportSignatureFormat;
appVersion?: string;
capabilities?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>;
}) {
const passportChallenge = await this.CPassportChallenge.getInstance({
'data.tokenHash': PassportChallenge.hashToken(optionsArg.pairingToken),
'data.type': 'device_enrollment',
'data.status': 'pending',
});
if (!passportChallenge) {
throw new plugins.typedrequest.TypedResponseError('Pairing token not found');
}
if (passportChallenge.isExpired()) {
await passportChallenge.markExpired();
throw new plugins.typedrequest.TypedResponseError('Pairing token expired');
}
const existingPassportDevice = await this.CPassportDevice.getInstance({
'data.publicKeyX963Base64': optionsArg.publicKeyX963Base64,
'data.status': 'active',
});
if (existingPassportDevice) {
throw new plugins.typedrequest.TypedResponseError('Passport device already enrolled');
}
const verified = this.verifyPassportSignature(
optionsArg.publicKeyX963Base64,
optionsArg.signatureBase64,
optionsArg.signatureFormat || 'raw',
this.buildEnrollmentSigningPayload(optionsArg.pairingToken, passportChallenge)
);
if (!verified) {
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
}
const passportDevice = new PassportDevice();
passportDevice.id = plugins.smartunique.shortId();
passportDevice.data = {
userId: passportChallenge.data.userId,
label: optionsArg.deviceLabel,
platform: optionsArg.platform,
status: 'active',
publicKeyAlgorithm: 'p256',
publicKeyX963Base64: optionsArg.publicKeyX963Base64,
capabilities: this.normalizeCapabilities(
optionsArg.capabilities || passportChallenge.data.metadata.requestedCapabilities
),
pushRegistration: undefined,
appVersion: optionsArg.appVersion,
createdAt: Date.now(),
lastSeenAt: Date.now(),
lastChallengeAt: undefined,
};
await passportDevice.save();
passportChallenge.data.deviceId = passportDevice.id;
passportChallenge.data.tokenHash = null;
await passportChallenge.markApproved({
signatureFormat: optionsArg.signatureFormat || 'raw',
});
await this.receptionRef.activityLogManager.logActivity(
passportChallenge.data.userId,
'passport_device_enrolled',
`Enrolled passport device ${passportDevice.data.label}`,
{
targetId: passportDevice.id,
targetType: 'passport-device',
}
);
return passportDevice;
}
public async getPassportDevicesForUser(userIdArg: string) {
const devices = await this.CPassportDevice.getInstances({
'data.userId': userIdArg,
'data.status': 'active',
});
return devices.sort(
(leftArg, rightArg) =>
(rightArg.data.lastSeenAt || rightArg.data.createdAt) -
(leftArg.data.lastSeenAt || leftArg.data.createdAt)
);
}
public async revokePassportDeviceForUser(userIdArg: string, deviceIdArg: string) {
const passportDevice = await this.CPassportDevice.getInstance({
id: deviceIdArg,
'data.userId': userIdArg,
'data.status': 'active',
});
if (!passportDevice) {
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
}
passportDevice.data.status = 'revoked';
await passportDevice.save();
await this.receptionRef.activityLogManager.logActivity(
userIdArg,
'passport_device_revoked',
`Revoked passport device ${passportDevice.data.label}`,
{
targetId: passportDevice.id,
targetType: 'passport-device',
}
);
}
public async createPassportChallengeForUser(
userIdArg: string,
optionsArg: {
type?: Exclude<plugins.idpInterfaces.data.TPassportChallengeType, 'device_enrollment'>;
preferredDeviceId?: string;
audience?: string;
notificationTitle?: string;
requireLocation?: boolean;
requireNfc?: boolean;
}
) {
const passportDevices = await this.getPassportDevicesForUser(userIdArg);
if (passportDevices.length === 0) {
throw new plugins.typedrequest.TypedResponseError('No passport device enrolled');
}
const targetDevice = optionsArg.preferredDeviceId
? passportDevices.find((deviceArg) => deviceArg.id === optionsArg.preferredDeviceId)
: passportDevices[0];
if (!targetDevice) {
throw new plugins.typedrequest.TypedResponseError('Target passport device not found');
}
const passportChallenge = new PassportChallenge();
passportChallenge.id = plugins.smartunique.shortId();
passportChallenge.data = {
userId: userIdArg,
deviceId: targetDevice.id,
type: optionsArg.type || 'step_up',
status: 'pending',
tokenHash: null,
challenge: this.createOpaqueToken('passport_challenge_'),
metadata: {
originHost: this.getOriginHost(),
audience: optionsArg.audience,
notificationTitle: optionsArg.notificationTitle,
deviceLabel: targetDevice.data.label,
requireLocation: !!optionsArg.requireLocation,
requireNfc: !!optionsArg.requireNfc,
},
evidence: undefined,
notification: {
hintId: plugins.crypto.randomUUID(),
status: 'pending',
attemptCount: 0,
createdAt: Date.now(),
deliveredAt: null,
seenAt: null,
lastError: null,
},
createdAt: Date.now(),
expiresAt: Date.now() + this.assertionChallengeMillis,
completedAt: null,
};
await passportChallenge.save();
targetDevice.data.lastChallengeAt = Date.now();
await targetDevice.save();
await this.receptionRef.passportPushManager.deliverChallengeHint(targetDevice, passportChallenge);
return {
challenge: passportChallenge,
signingPayload: this.buildChallengeSigningPayload(passportChallenge),
};
}
public async approvePassportChallenge(optionsArg: {
challengeId: string;
deviceId: string;
signatureBase64: string;
signatureFormat?: plugins.idpInterfaces.data.TPassportSignatureFormat;
location?: plugins.idpInterfaces.data.IPassportLocationEvidence;
nfc?: plugins.idpInterfaces.data.IPassportNfcEvidence;
}) {
const passportChallenge = await this.CPassportChallenge.getInstance({
id: optionsArg.challengeId,
'data.status': 'pending',
});
if (!passportChallenge) {
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
}
if (passportChallenge.isExpired()) {
await passportChallenge.markExpired();
throw new plugins.typedrequest.TypedResponseError('Passport challenge expired');
}
if (passportChallenge.data.deviceId && passportChallenge.data.deviceId !== optionsArg.deviceId) {
throw new plugins.typedrequest.TypedResponseError('Passport challenge not assigned to this device');
}
const passportDevice = await this.CPassportDevice.getInstance({
id: optionsArg.deviceId,
'data.status': 'active',
});
if (!passportDevice) {
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
}
if (passportDevice.data.userId !== passportChallenge.data.userId) {
throw new plugins.typedrequest.TypedResponseError('Passport device user mismatch');
}
if (passportChallenge.data.metadata.requireLocation && !optionsArg.location) {
throw new plugins.typedrequest.TypedResponseError('Location evidence required');
}
if (passportChallenge.data.metadata.requireNfc && !optionsArg.nfc) {
throw new plugins.typedrequest.TypedResponseError('NFC evidence required');
}
const verified = this.verifyPassportSignature(
passportDevice.data.publicKeyX963Base64,
optionsArg.signatureBase64,
optionsArg.signatureFormat || 'raw',
this.buildChallengeSigningPayload(passportChallenge)
);
if (!verified) {
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
}
await passportChallenge.markApproved({
signatureFormat: optionsArg.signatureFormat || 'raw',
location: optionsArg.location,
nfc: optionsArg.nfc,
});
passportDevice.data.lastSeenAt = Date.now();
await passportDevice.save();
await this.receptionRef.activityLogManager.logActivity(
passportChallenge.data.userId,
'passport_challenge_approved',
`Approved passport challenge ${passportChallenge.data.type}`,
{
targetId: passportChallenge.id,
targetType: 'passport-challenge',
}
);
return passportChallenge;
}
public async listPendingChallengesForDevice(deviceIdArg: string) {
const passportChallenges = await this.CPassportChallenge.getInstances({
'data.deviceId': deviceIdArg,
'data.status': 'pending',
});
return passportChallenges.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt);
}
public async getPassportChallengeByHint(deviceIdArg: string, hintIdArg: string) {
return this.CPassportChallenge.getInstance({
'data.deviceId': deviceIdArg,
'data.status': 'pending',
'data.notification.hintId': hintIdArg,
});
}
public async markPassportChallengeSeen(deviceIdArg: string, hintIdArg: string) {
const passportChallenge = await this.getPassportChallengeByHint(deviceIdArg, hintIdArg);
if (!passportChallenge) {
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
}
passportChallenge.data.notification = {
...passportChallenge.data.notification!,
status: 'seen',
seenAt: Date.now(),
};
await passportChallenge.save();
return passportChallenge;
}
public async cleanupExpiredChallenges() {
const passportChallenges = await this.CPassportChallenge.getInstances({});
for (const passportChallenge of passportChallenges) {
if (passportChallenge.data.status === 'pending' && passportChallenge.isExpired()) {
await passportChallenge.markExpired();
}
}
const passportNonces = await this.CPassportNonce.getInstances({});
for (const passportNonce of passportNonces) {
if (passportNonce.isExpired()) {
await passportNonce.delete();
}
}
}
public async reDeliverPendingChallengeHints() {
const passportChallenges = await this.CPassportChallenge.getInstances({
'data.status': 'pending',
});
for (const passportChallenge of passportChallenges) {
if (!passportChallenge.data.notification || passportChallenge.data.notification.status === 'sent') {
continue;
}
if (!passportChallenge.data.deviceId) {
continue;
}
const passportDevice = await this.CPassportDevice.getInstance({
id: passportChallenge.data.deviceId,
'data.status': 'active',
});
if (!passportDevice) {
continue;
}
try {
await this.receptionRef.passportPushManager.deliverChallengeHint(passportDevice, passportChallenge);
} catch (errorArg) {
logger.log('warn', `passport hint redelivery failed: ${(errorArg as Error).message}`);
}
}
}
}
+29
View File
@@ -0,0 +1,29 @@
import * as plugins from '../plugins.js';
import type { PassportManager } from './classes.passportmanager.js';
@plugins.smartdata.Manager()
export class PassportNonce extends plugins.smartdata.SmartDataDbDoc<
PassportNonce,
plugins.idpInterfaces.data.IPassportNonce,
PassportManager
> {
public static hashNonce(nonceArg: string) {
return plugins.smarthash.sha256FromStringSync(nonceArg);
}
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.idpInterfaces.data.IPassportNonce['data'] = {
deviceId: '',
nonceHash: '',
createdAt: 0,
expiresAt: 0,
};
public isExpired(nowArg = Date.now()) {
return this.data.expiresAt < nowArg;
}
}
+231
View File
@@ -0,0 +1,231 @@
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;
}
}
+6
View File
@@ -18,6 +18,9 @@ import { ActivityLogManager } from './classes.activitylogmanager.js';
import { UserInvitationManager } from './classes.userinvitationmanager.js';
import { OidcManager } from './classes.oidcmanager.js';
import { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
import { AlertManager } from './classes.alertmanager.js';
import { PassportManager } from './classes.passportmanager.js';
import { PassportPushManager } from './classes.passportpushmanager.js';
export interface IReceptionOptions {
/**
@@ -48,8 +51,11 @@ export class Reception {
public appManager = new AppManager(this);
public appConnectionManager = new AppConnectionManager(this);
public activityLogManager = new ActivityLogManager(this);
public alertManager = new AlertManager(this);
public userInvitationManager = new UserInvitationManager(this);
public abuseProtectionManager = new AbuseProtectionManager(this);
public passportPushManager = new PassportPushManager(this);
public passportManager = new PassportManager(this);
public oidcManager = new OidcManager(this);
housekeeping = new ReceptionHousekeeping(this);