Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1a684ee81 | |||
| 6044928c70 |
@@ -1,5 +1,14 @@
|
||||
# Changelog
|
||||
|
||||
## 2026-04-20 - 1.21.0 - feat(reception)
|
||||
add passport device authentication flows and alert delivery management
|
||||
|
||||
- introduce passport device, challenge, and nonce models with typed request interfaces for enrollment, challenge approval, push token registration, and signed device requests
|
||||
- add alert and alert rule models plus alert manager endpoints for listing, resolving by hint, marking seen, and routing notifications to eligible recipients
|
||||
- send security and admin alerts for global admin dashboard access and global app credential regeneration
|
||||
- schedule housekeeping tasks to expire passport challenges and retry pending passport challenge and alert push deliveries
|
||||
- cover passport and alert workflows with new node tests
|
||||
|
||||
## 2026-04-20 - 1.20.0 - feat(auth)
|
||||
add abuse protection for login and OIDC flows with consent-based authorization handling
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@idp.global/idp.global",
|
||||
"version": "1.20.0",
|
||||
"version": "1.21.0",
|
||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||
"main": "dist_ts/index.js",
|
||||
"typings": "dist_ts/index.d.ts",
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import { Alert } from '../ts/reception/classes.alert.js';
|
||||
import { AlertManager } from '../ts/reception/classes.alertmanager.js';
|
||||
import { AlertRule } from '../ts/reception/classes.alertrule.js';
|
||||
import { PassportDevice } from '../ts/reception/classes.passportdevice.js';
|
||||
import { Role } from '../ts/reception/classes.role.js';
|
||||
import { User } from '../ts/reception/classes.user.js';
|
||||
|
||||
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||||
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||||
};
|
||||
|
||||
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||||
return Object.entries(queryArg).every(([keyArg, valueArg]) => getNestedValue(targetArg, keyArg) === valueArg);
|
||||
};
|
||||
|
||||
const createTestAlertManager = () => {
|
||||
const alerts = new Map<string, Alert>();
|
||||
const alertRules = new Map<string, AlertRule>();
|
||||
const users = new Map<string, User>();
|
||||
const roles = new Map<string, Role>();
|
||||
const passportDevices = new Map<string, PassportDevice>();
|
||||
const deliveredHints: string[] = [];
|
||||
|
||||
const manager = new AlertManager({
|
||||
db: { smartdataDb: {} },
|
||||
typedrouter: { addTypedRouter: () => undefined },
|
||||
jwtManager: {
|
||||
verifyJWTAndGetData: async (jwtArg: string) => ({
|
||||
data: {
|
||||
userId: jwtArg,
|
||||
},
|
||||
}),
|
||||
},
|
||||
userManager: {
|
||||
CUser: {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(users.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async () => Array.from(users.values()),
|
||||
},
|
||||
},
|
||||
roleManager: {
|
||||
CRole: {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(roles.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
},
|
||||
getAllRolesForOrg: async (organizationIdArg: string) =>
|
||||
Array.from(roles.values()).filter((roleArg) => roleArg.data.organizationId === organizationIdArg),
|
||||
},
|
||||
passportManager: {
|
||||
authenticatePassportDeviceRequest: async (requestArg: { deviceId: string }) => {
|
||||
return passportDevices.get(requestArg.deviceId)!;
|
||||
},
|
||||
getPassportDevicesForUser: async (userIdArg: string) =>
|
||||
Array.from(passportDevices.values()).filter(
|
||||
(deviceArg) => deviceArg.data.userId === userIdArg && deviceArg.data.status === 'active'
|
||||
),
|
||||
},
|
||||
passportPushManager: {
|
||||
deliverAlertHint: async (_passportDeviceArg: PassportDevice, alertArg: Alert) => {
|
||||
deliveredHints.push(alertArg.data.notification.hintId);
|
||||
alertArg.data.notification = {
|
||||
...alertArg.data.notification,
|
||||
status: 'sent',
|
||||
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||
deliveredAt: Date.now(),
|
||||
lastError: null,
|
||||
};
|
||||
await alertArg.save();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const originalAlertSave = Alert.prototype.save;
|
||||
const originalAlertDelete = Alert.prototype.delete;
|
||||
const originalAlertRuleSave = AlertRule.prototype.save;
|
||||
const originalAlertRuleDelete = AlertRule.prototype.delete;
|
||||
|
||||
(Alert.prototype as Alert & { save: () => Promise<void> }).save = async function () {
|
||||
alerts.set(this.id, this);
|
||||
};
|
||||
(Alert.prototype as Alert & { delete: () => Promise<void> }).delete = async function () {
|
||||
alerts.delete(this.id);
|
||||
};
|
||||
(AlertRule.prototype as AlertRule & { save: () => Promise<void> }).save = async function () {
|
||||
alertRules.set(this.id, this);
|
||||
};
|
||||
(AlertRule.prototype as AlertRule & { delete: () => Promise<void> }).delete = async function () {
|
||||
alertRules.delete(this.id);
|
||||
};
|
||||
|
||||
(manager as any).CAlert = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(alerts.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(alerts.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||
},
|
||||
};
|
||||
(manager as any).CAlertRule = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(alertRules.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async () => Array.from(alertRules.values()),
|
||||
};
|
||||
|
||||
return {
|
||||
manager,
|
||||
alerts,
|
||||
alertRules,
|
||||
users,
|
||||
roles,
|
||||
passportDevices,
|
||||
deliveredHints,
|
||||
restore: () => {
|
||||
Alert.prototype.save = originalAlertSave;
|
||||
Alert.prototype.delete = originalAlertDelete;
|
||||
AlertRule.prototype.save = originalAlertRuleSave;
|
||||
AlertRule.prototype.delete = originalAlertRuleDelete;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const addUser = (
|
||||
usersArg: Map<string, User>,
|
||||
optionsArg: { id: string; email: string; isGlobalAdmin?: boolean }
|
||||
) => {
|
||||
const user = new User();
|
||||
user.id = optionsArg.id;
|
||||
user.data = {
|
||||
name: optionsArg.email,
|
||||
username: optionsArg.email,
|
||||
email: optionsArg.email,
|
||||
status: 'active',
|
||||
connectedOrgs: [],
|
||||
isGlobalAdmin: optionsArg.isGlobalAdmin,
|
||||
};
|
||||
usersArg.set(user.id, user);
|
||||
return user;
|
||||
};
|
||||
|
||||
const addPassportDevice = (
|
||||
passportDevicesArg: Map<string, PassportDevice>,
|
||||
optionsArg: { id: string; userId: string; label: string }
|
||||
) => {
|
||||
const device = new PassportDevice();
|
||||
device.id = optionsArg.id;
|
||||
device.data = {
|
||||
userId: optionsArg.userId,
|
||||
label: optionsArg.label,
|
||||
platform: 'ios',
|
||||
status: 'active',
|
||||
publicKeyAlgorithm: 'p256',
|
||||
publicKeyX963Base64: 'public-key',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: true,
|
||||
push: true,
|
||||
},
|
||||
pushRegistration: {
|
||||
provider: 'apns',
|
||||
token: `${optionsArg.id}-token`,
|
||||
topic: 'global.idp.swiftapp',
|
||||
environment: 'development',
|
||||
registeredAt: Date.now(),
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
lastSeenAt: Date.now(),
|
||||
};
|
||||
passportDevicesArg.set(device.id, device);
|
||||
return device;
|
||||
};
|
||||
|
||||
tap.test('creates global admin access alerts with the built-in fallback rule', async () => {
|
||||
const { manager, users, passportDevices, alerts, deliveredHints, restore } = createTestAlertManager();
|
||||
|
||||
try {
|
||||
addUser(users, { id: 'admin-1', email: 'admin-1@example.com', isGlobalAdmin: true });
|
||||
addPassportDevice(passportDevices, { id: 'device-1', userId: 'admin-1', label: 'Admin Phone' });
|
||||
|
||||
const createdAlerts = await manager.createAlertsForEvent({
|
||||
category: 'admin',
|
||||
eventType: 'global_admin_access',
|
||||
severity: 'high',
|
||||
title: 'Global admin console accessed',
|
||||
body: 'A global admin accessed the console.',
|
||||
actorUserId: 'admin-1',
|
||||
relatedEntityType: 'global-admin-console',
|
||||
});
|
||||
|
||||
expect(createdAlerts).toHaveLength(1);
|
||||
expect(alerts.size).toEqual(1);
|
||||
expect(createdAlerts[0].data.notification.status).toEqual('sent');
|
||||
expect(deliveredHints).toHaveLength(1);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('routes organization-scoped alerts to org admins by rule', async () => {
|
||||
const { manager, users, roles, passportDevices, restore } = createTestAlertManager();
|
||||
|
||||
try {
|
||||
addUser(users, { id: 'owner-1', email: 'owner@example.com' });
|
||||
addUser(users, { id: 'viewer-1', email: 'viewer@example.com' });
|
||||
addPassportDevice(passportDevices, { id: 'owner-device', userId: 'owner-1', label: 'Owner Phone' });
|
||||
|
||||
const ownerRole = new Role();
|
||||
ownerRole.id = 'role-owner';
|
||||
ownerRole.data = {
|
||||
userId: 'owner-1',
|
||||
organizationId: 'org-1',
|
||||
roles: ['owner'],
|
||||
};
|
||||
roles.set(ownerRole.id, ownerRole);
|
||||
|
||||
const viewerRole = new Role();
|
||||
viewerRole.id = 'role-viewer';
|
||||
viewerRole.data = {
|
||||
userId: 'viewer-1',
|
||||
organizationId: 'org-1',
|
||||
roles: ['viewer'],
|
||||
};
|
||||
roles.set(viewerRole.id, viewerRole);
|
||||
|
||||
const rule = new AlertRule();
|
||||
rule.id = 'org-admin-rule';
|
||||
rule.data = {
|
||||
scope: 'organization',
|
||||
organizationId: 'org-1',
|
||||
eventType: 'org_security_notice',
|
||||
minimumSeverity: 'medium',
|
||||
recipientMode: 'org_admins',
|
||||
recipientUserIds: [],
|
||||
push: true,
|
||||
enabled: true,
|
||||
createdByUserId: 'owner-1',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await rule.save();
|
||||
|
||||
const createdAlerts = await manager.createAlertsForEvent({
|
||||
category: 'security',
|
||||
eventType: 'org_security_notice',
|
||||
severity: 'high',
|
||||
title: 'Organization security event',
|
||||
body: 'A sensitive organization event occurred.',
|
||||
actorUserId: 'viewer-1',
|
||||
organizationId: 'org-1',
|
||||
});
|
||||
|
||||
expect(createdAlerts).toHaveLength(1);
|
||||
expect(createdAlerts[0].data.recipientUserId).toEqual('owner-1');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('lists alerts, resolves hint lookups, and marks alerts seen', async () => {
|
||||
const { manager, alerts, restore } = createTestAlertManager();
|
||||
|
||||
try {
|
||||
const alert = new Alert();
|
||||
alert.id = 'alert-1';
|
||||
alert.data = {
|
||||
recipientUserId: 'user-1',
|
||||
category: 'security',
|
||||
eventType: 'global_admin_access',
|
||||
severity: 'high',
|
||||
title: 'Important alert',
|
||||
body: 'Please inspect this alert.',
|
||||
notification: {
|
||||
hintId: 'hint-1',
|
||||
status: 'sent',
|
||||
attemptCount: 1,
|
||||
createdAt: Date.now(),
|
||||
deliveredAt: Date.now(),
|
||||
seenAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
createdAt: Date.now(),
|
||||
seenAt: null,
|
||||
dismissedAt: null,
|
||||
};
|
||||
await alert.save();
|
||||
|
||||
const listedAlerts = await manager.listAlertsForUser('user-1');
|
||||
expect(listedAlerts).toHaveLength(1);
|
||||
|
||||
const hintAlert = await manager.getAlertByHint('user-1', 'hint-1');
|
||||
expect(hintAlert?.id).toEqual('alert-1');
|
||||
|
||||
const seenAlert = await manager.markAlertSeen('user-1', 'hint-1');
|
||||
expect(seenAlert.data.notification.status).toEqual('seen');
|
||||
expect(seenAlert.data.seenAt).toBeGreaterThan(0);
|
||||
expect(alerts.get('alert-1')?.data.notification.status).toEqual('seen');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -0,0 +1,434 @@
|
||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||
|
||||
import * as plugins from '../ts/plugins.js';
|
||||
import { PassportChallenge } from '../ts/reception/classes.passportchallenge.js';
|
||||
import { PassportDevice } from '../ts/reception/classes.passportdevice.js';
|
||||
import { PassportManager } from '../ts/reception/classes.passportmanager.js';
|
||||
import { PassportNonce } from '../ts/reception/classes.passportnonce.js';
|
||||
|
||||
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||||
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||||
};
|
||||
|
||||
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||||
return Object.entries(queryArg).every(([keyArg, valueArg]) => {
|
||||
return getNestedValue(targetArg, keyArg) === valueArg;
|
||||
});
|
||||
};
|
||||
|
||||
const createTestPassportManager = () => {
|
||||
const passportDevices = new Map<string, PassportDevice>();
|
||||
const passportChallenges = new Map<string, PassportChallenge>();
|
||||
const passportNonces = new Map<string, PassportNonce>();
|
||||
const activityLogCalls: Array<{
|
||||
userId: string;
|
||||
action: string;
|
||||
description: string;
|
||||
}> = [];
|
||||
const deliveredHintIds: string[] = [];
|
||||
|
||||
const manager = new PassportManager({
|
||||
db: { smartdataDb: {} },
|
||||
typedrouter: { addTypedRouter: () => undefined },
|
||||
options: { baseUrl: 'https://idp.global' },
|
||||
jwtManager: { verifyJWTAndGetData: async () => null },
|
||||
activityLogManager: {
|
||||
logActivity: async (userIdArg: string, actionArg: string, descriptionArg: string) => {
|
||||
activityLogCalls.push({
|
||||
userId: userIdArg,
|
||||
action: actionArg,
|
||||
description: descriptionArg,
|
||||
});
|
||||
},
|
||||
},
|
||||
passportPushManager: {
|
||||
deliverChallengeHint: async (_passportDeviceArg: PassportDevice, passportChallengeArg: PassportChallenge) => {
|
||||
deliveredHintIds.push(passportChallengeArg.data.notification!.hintId);
|
||||
passportChallengeArg.data.notification = {
|
||||
...passportChallengeArg.data.notification!,
|
||||
status: 'sent',
|
||||
attemptCount: passportChallengeArg.data.notification!.attemptCount + 1,
|
||||
deliveredAt: Date.now(),
|
||||
lastError: null,
|
||||
};
|
||||
await passportChallengeArg.save();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
} as any);
|
||||
|
||||
const originalPassportDeviceSave = PassportDevice.prototype.save;
|
||||
const originalPassportDeviceDelete = PassportDevice.prototype.delete;
|
||||
const originalPassportChallengeSave = PassportChallenge.prototype.save;
|
||||
const originalPassportChallengeDelete = PassportChallenge.prototype.delete;
|
||||
const originalPassportNonceSave = PassportNonce.prototype.save;
|
||||
const originalPassportNonceDelete = PassportNonce.prototype.delete;
|
||||
|
||||
(PassportDevice.prototype as PassportDevice & { save: () => Promise<void> }).save = async function () {
|
||||
passportDevices.set(this.id, this);
|
||||
};
|
||||
(PassportDevice.prototype as PassportDevice & { delete: () => Promise<void> }).delete = async function () {
|
||||
passportDevices.delete(this.id);
|
||||
};
|
||||
(PassportChallenge.prototype as PassportChallenge & { save: () => Promise<void> }).save = async function () {
|
||||
passportChallenges.set(this.id, this);
|
||||
};
|
||||
(PassportChallenge.prototype as PassportChallenge & { delete: () => Promise<void> }).delete = async function () {
|
||||
passportChallenges.delete(this.id);
|
||||
};
|
||||
(PassportNonce.prototype as PassportNonce & { save: () => Promise<void> }).save = async function () {
|
||||
passportNonces.set(this.id, this);
|
||||
};
|
||||
(PassportNonce.prototype as PassportNonce & { delete: () => Promise<void> }).delete = async function () {
|
||||
passportNonces.delete(this.id);
|
||||
};
|
||||
|
||||
(manager as any).CPassportDevice = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(passportDevices.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(passportDevices.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||
},
|
||||
};
|
||||
|
||||
(manager as any).CPassportChallenge = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return (
|
||||
Array.from(passportChallenges.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null
|
||||
);
|
||||
},
|
||||
getInstances: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(passportChallenges.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||
},
|
||||
};
|
||||
|
||||
(manager as any).CPassportNonce = {
|
||||
getInstance: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(passportNonces.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||
},
|
||||
getInstances: async (queryArg: Record<string, any>) => {
|
||||
return Array.from(passportNonces.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
manager,
|
||||
passportDevices,
|
||||
passportChallenges,
|
||||
passportNonces,
|
||||
activityLogCalls,
|
||||
deliveredHintIds,
|
||||
restore: () => {
|
||||
PassportDevice.prototype.save = originalPassportDeviceSave;
|
||||
PassportDevice.prototype.delete = originalPassportDeviceDelete;
|
||||
PassportChallenge.prototype.save = originalPassportChallengeSave;
|
||||
PassportChallenge.prototype.delete = originalPassportChallengeDelete;
|
||||
PassportNonce.prototype.save = originalPassportNonceSave;
|
||||
PassportNonce.prototype.delete = originalPassportNonceDelete;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createRawPassportSigner = async () => {
|
||||
const subtle = plugins.crypto.webcrypto.subtle;
|
||||
const keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
||||
'sign',
|
||||
'verify',
|
||||
]);
|
||||
const publicKeyRaw = Buffer.from(await subtle.exportKey('raw', keyPair.publicKey)).toString('base64');
|
||||
|
||||
return {
|
||||
publicKeyX963Base64: publicKeyRaw,
|
||||
sign: async (payloadArg: string) => {
|
||||
const signature = await subtle.sign(
|
||||
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||
keyPair.privateKey,
|
||||
Buffer.from(payloadArg, 'utf8')
|
||||
);
|
||||
return Buffer.from(signature).toString('base64');
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createDerPassportSigner = () => {
|
||||
const keyPair = plugins.crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||
const publicJwk = keyPair.publicKey.export({ format: 'jwk' }) as JsonWebKey;
|
||||
const publicKeyX963Base64 = Buffer.concat([
|
||||
Buffer.from([4]),
|
||||
Buffer.from(publicJwk.x!, 'base64url'),
|
||||
Buffer.from(publicJwk.y!, 'base64url'),
|
||||
]).toString('base64');
|
||||
|
||||
return {
|
||||
publicKeyX963Base64,
|
||||
sign: (payloadArg: string) => {
|
||||
return plugins.crypto.sign('sha256', Buffer.from(payloadArg, 'utf8'), keyPair.privateKey).toString('base64');
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const createSignedDeviceRequest = async (
|
||||
managerArg: PassportManager,
|
||||
signerArg: { sign: (payloadArg: string) => Promise<string> | string },
|
||||
requestArg: {
|
||||
deviceId: string;
|
||||
action: string;
|
||||
signedFields?: string[];
|
||||
}
|
||||
) => {
|
||||
const baseRequest = {
|
||||
deviceId: requestArg.deviceId,
|
||||
timestamp: Date.now(),
|
||||
nonce: plugins.crypto.randomUUID(),
|
||||
};
|
||||
const payload = (managerArg as any).buildDeviceRequestSigningPayload(
|
||||
baseRequest,
|
||||
requestArg.action,
|
||||
requestArg.signedFields || []
|
||||
);
|
||||
|
||||
return {
|
||||
...baseRequest,
|
||||
signatureBase64: await signerArg.sign(payload),
|
||||
signatureFormat: 'raw' as const,
|
||||
};
|
||||
};
|
||||
|
||||
tap.test('enrolls a passport device from a pairing challenge', async () => {
|
||||
const { manager, passportDevices, passportChallenges, activityLogCalls, restore } =
|
||||
createTestPassportManager();
|
||||
|
||||
try {
|
||||
const enrollment = await manager.createEnrollmentChallengeForUser('user-1', {
|
||||
deviceLabel: 'Phil iPhone',
|
||||
platform: 'ios',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: true,
|
||||
push: true,
|
||||
},
|
||||
});
|
||||
|
||||
const signer = await createRawPassportSigner();
|
||||
const signatureBase64 = await signer.sign(enrollment.signingPayload);
|
||||
|
||||
const passportDevice = await manager.completeEnrollment({
|
||||
pairingToken: enrollment.pairingToken,
|
||||
deviceLabel: 'Phil iPhone',
|
||||
platform: 'ios',
|
||||
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||
signatureBase64,
|
||||
signatureFormat: 'raw',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: true,
|
||||
push: true,
|
||||
},
|
||||
appVersion: '1.0.0',
|
||||
});
|
||||
|
||||
expect(passportDevice.data.userId).toEqual('user-1');
|
||||
expect(passportDevice.data.label).toEqual('Phil iPhone');
|
||||
expect(passportDevices.size).toEqual(1);
|
||||
expect(passportChallenges.size).toEqual(1);
|
||||
expect(Array.from(passportChallenges.values())[0].data.status).toEqual('approved');
|
||||
expect(activityLogCalls[0].action).toEqual('passport_device_enrolled');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('creates and approves a passport challenge with DER signatures and evidence', async () => {
|
||||
const { manager, activityLogCalls, deliveredHintIds, restore } = createTestPassportManager();
|
||||
|
||||
try {
|
||||
const enrollment = await manager.createEnrollmentChallengeForUser('user-2', {
|
||||
deviceLabel: 'Office iPhone',
|
||||
platform: 'ios',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: true,
|
||||
push: true,
|
||||
},
|
||||
});
|
||||
|
||||
const signer = createDerPassportSigner();
|
||||
const passportDevice = await manager.completeEnrollment({
|
||||
pairingToken: enrollment.pairingToken,
|
||||
deviceLabel: 'Office iPhone',
|
||||
platform: 'ios',
|
||||
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||
signatureBase64: signer.sign(enrollment.signingPayload),
|
||||
signatureFormat: 'der',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: true,
|
||||
push: true,
|
||||
},
|
||||
});
|
||||
|
||||
const challengeResult = await manager.createPassportChallengeForUser('user-2', {
|
||||
type: 'physical_access',
|
||||
preferredDeviceId: passportDevice.id,
|
||||
audience: 'hq-door-a',
|
||||
notificationTitle: 'Office entry request',
|
||||
requireLocation: true,
|
||||
requireNfc: true,
|
||||
});
|
||||
|
||||
expect(deliveredHintIds).toHaveLength(1);
|
||||
expect(challengeResult.challenge.data.notification?.status).toEqual('sent');
|
||||
|
||||
await expect(
|
||||
manager.approvePassportChallenge({
|
||||
challengeId: challengeResult.challenge.id,
|
||||
deviceId: passportDevice.id,
|
||||
signatureBase64: signer.sign(challengeResult.signingPayload),
|
||||
signatureFormat: 'der',
|
||||
})
|
||||
).rejects.toThrow();
|
||||
|
||||
const approvedChallenge = await manager.approvePassportChallenge({
|
||||
challengeId: challengeResult.challenge.id,
|
||||
deviceId: passportDevice.id,
|
||||
signatureBase64: signer.sign(challengeResult.signingPayload),
|
||||
signatureFormat: 'der',
|
||||
location: {
|
||||
latitude: 53.0793,
|
||||
longitude: 8.8017,
|
||||
accuracyMeters: 12,
|
||||
capturedAt: Date.now(),
|
||||
},
|
||||
nfc: {
|
||||
readerId: 'door-reader-a',
|
||||
},
|
||||
});
|
||||
|
||||
expect(approvedChallenge.data.status).toEqual('approved');
|
||||
expect(approvedChallenge.data.evidence?.signatureFormat).toEqual('der');
|
||||
expect(approvedChallenge.data.evidence?.location?.accuracyMeters).toEqual(12);
|
||||
expect(approvedChallenge.data.evidence?.nfc?.readerId).toEqual('door-reader-a');
|
||||
expect(activityLogCalls.at(-1)?.action).toEqual('passport_challenge_approved');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
tap.test('registers push tokens and loads pending challenges through signed device requests', async () => {
|
||||
const { manager, passportNonces, restore } = createTestPassportManager();
|
||||
|
||||
try {
|
||||
const enrollment = await manager.createEnrollmentChallengeForUser('user-3', {
|
||||
deviceLabel: 'Work iPhone',
|
||||
platform: 'ios',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: false,
|
||||
push: true,
|
||||
},
|
||||
});
|
||||
|
||||
const signer = await createRawPassportSigner();
|
||||
const passportDevice = await manager.completeEnrollment({
|
||||
pairingToken: enrollment.pairingToken,
|
||||
deviceLabel: 'Work iPhone',
|
||||
platform: 'ios',
|
||||
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||
signatureBase64: await signer.sign(enrollment.signingPayload),
|
||||
signatureFormat: 'raw',
|
||||
capabilities: {
|
||||
gps: true,
|
||||
nfc: false,
|
||||
push: true,
|
||||
},
|
||||
});
|
||||
|
||||
const pushRequest = await createSignedDeviceRequest(manager, signer, {
|
||||
deviceId: passportDevice.id,
|
||||
action: 'registerPassportPushToken',
|
||||
signedFields: [
|
||||
'provider=apns',
|
||||
'token=device-token-1',
|
||||
'topic=global.idp.swiftapp',
|
||||
'environment=development',
|
||||
],
|
||||
});
|
||||
|
||||
const registeredPassportDevice = await (manager as any).authenticatePassportDeviceRequest(
|
||||
{
|
||||
...pushRequest,
|
||||
},
|
||||
{
|
||||
action: 'registerPassportPushToken',
|
||||
signedFields: [
|
||||
'provider=apns',
|
||||
'token=device-token-1',
|
||||
'topic=global.idp.swiftapp',
|
||||
'environment=development',
|
||||
],
|
||||
}
|
||||
);
|
||||
registeredPassportDevice.data.pushRegistration = {
|
||||
provider: 'apns',
|
||||
token: 'device-token-1',
|
||||
topic: 'global.idp.swiftapp',
|
||||
environment: 'development',
|
||||
registeredAt: Date.now(),
|
||||
};
|
||||
await registeredPassportDevice.save();
|
||||
|
||||
const challengeResult = await manager.createPassportChallengeForUser('user-3', {
|
||||
type: 'authentication',
|
||||
preferredDeviceId: passportDevice.id,
|
||||
audience: 'office-saas',
|
||||
notificationTitle: 'Office sign-in verification',
|
||||
});
|
||||
|
||||
const listRequest = await createSignedDeviceRequest(manager, signer, {
|
||||
deviceId: passportDevice.id,
|
||||
action: 'listPendingPassportChallenges',
|
||||
});
|
||||
|
||||
const authenticatedDevice = await (manager as any).authenticatePassportDeviceRequest(listRequest, {
|
||||
action: 'listPendingPassportChallenges',
|
||||
});
|
||||
const pendingChallenges = await manager.listPendingChallengesForDevice(authenticatedDevice.id);
|
||||
expect(pendingChallenges).toHaveLength(1);
|
||||
expect(pendingChallenges[0].id).toEqual(challengeResult.challenge.id);
|
||||
|
||||
const hintId = challengeResult.challenge.data.notification!.hintId;
|
||||
const getRequest = await createSignedDeviceRequest(manager, signer, {
|
||||
deviceId: passportDevice.id,
|
||||
action: 'getPassportChallengeByHint',
|
||||
signedFields: [`hint_id=${hintId}`],
|
||||
});
|
||||
const hintChallenge = await manager.getPassportChallengeByHint(
|
||||
(
|
||||
await (manager as any).authenticatePassportDeviceRequest(getRequest, {
|
||||
action: 'getPassportChallengeByHint',
|
||||
signedFields: [`hint_id=${hintId}`],
|
||||
})
|
||||
).id,
|
||||
hintId
|
||||
);
|
||||
expect(hintChallenge?.id).toEqual(challengeResult.challenge.id);
|
||||
|
||||
const seenRequest = await createSignedDeviceRequest(manager, signer, {
|
||||
deviceId: passportDevice.id,
|
||||
action: 'markPassportChallengeSeen',
|
||||
signedFields: [`hint_id=${hintId}`],
|
||||
});
|
||||
await (manager as any).authenticatePassportDeviceRequest(seenRequest, {
|
||||
action: 'markPassportChallengeSeen',
|
||||
signedFields: [`hint_id=${hintId}`],
|
||||
});
|
||||
const seenChallenge = await manager.markPassportChallengeSeen(passportDevice.id, hintId);
|
||||
expect(seenChallenge.data.notification?.status).toEqual('seen');
|
||||
expect(passportNonces.size).toEqual(4);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
export default tap.start();
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.20.0',
|
||||
version: '1.21.0',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -3,6 +3,9 @@ export type TActivityAction =
|
||||
| 'logout'
|
||||
| 'session_created'
|
||||
| 'session_revoked'
|
||||
| 'passport_device_enrolled'
|
||||
| 'passport_device_revoked'
|
||||
| 'passport_challenge_approved'
|
||||
| 'org_created'
|
||||
| 'org_joined'
|
||||
| 'org_left'
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
export type TAlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export type TAlertStatus = 'pending' | 'seen' | 'dismissed';
|
||||
|
||||
export type TAlertCategory = 'security' | 'admin' | 'system';
|
||||
|
||||
export type TAlertNotificationStatus = 'pending' | 'sent' | 'failed' | 'seen';
|
||||
|
||||
export interface IAlert {
|
||||
id: string;
|
||||
data: {
|
||||
recipientUserId: string;
|
||||
organizationId?: string;
|
||||
category: TAlertCategory;
|
||||
eventType: string;
|
||||
severity: TAlertSeverity;
|
||||
title: string;
|
||||
body: string;
|
||||
actorUserId?: string;
|
||||
relatedEntityId?: string;
|
||||
relatedEntityType?: string;
|
||||
notification: {
|
||||
hintId: string;
|
||||
status: TAlertNotificationStatus;
|
||||
attemptCount: number;
|
||||
createdAt: number;
|
||||
deliveredAt?: number | null;
|
||||
seenAt?: number | null;
|
||||
lastError?: string | null;
|
||||
};
|
||||
createdAt: number;
|
||||
seenAt?: number | null;
|
||||
dismissedAt?: number | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { TAlertSeverity } from './alert.js';
|
||||
|
||||
export type TAlertRuleScope = 'global' | 'organization';
|
||||
|
||||
export type TAlertRuleRecipientMode = 'global_admins' | 'org_admins' | 'specific_users';
|
||||
|
||||
export interface IAlertRule {
|
||||
id: string;
|
||||
data: {
|
||||
scope: TAlertRuleScope;
|
||||
organizationId?: string;
|
||||
eventType: string;
|
||||
minimumSeverity: TAlertSeverity;
|
||||
recipientMode: TAlertRuleRecipientMode;
|
||||
recipientUserIds?: string[];
|
||||
push: boolean;
|
||||
enabled: boolean;
|
||||
createdByUserId: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './abusewindow.js';
|
||||
export * from './activity.js';
|
||||
export * from './alert.js';
|
||||
export * from './alertrule.js';
|
||||
export * from './app.js';
|
||||
export * from './emailactiontoken.js';
|
||||
export * from './oidc.js';
|
||||
@@ -10,6 +12,9 @@ export * from './jwt.js';
|
||||
export * from './loginsession.js';
|
||||
export * from './organization.js';
|
||||
export * from './paddlecheckoutdata.js';
|
||||
export * from './passportchallenge.js';
|
||||
export * from './passportdevice.js';
|
||||
export * from './passportnonce.js';
|
||||
export * from './registrationsession.js';
|
||||
export * from './role.js';
|
||||
export * from './user.js';
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { IPassportCapabilities } from './passportdevice.js';
|
||||
|
||||
export type TPassportChallengeType =
|
||||
| 'device_enrollment'
|
||||
| 'authentication'
|
||||
| 'step_up'
|
||||
| 'physical_access';
|
||||
|
||||
export type TPassportChallengeStatus = 'pending' | 'approved' | 'expired' | 'rejected';
|
||||
|
||||
export type TPassportChallengeDeliveryStatus = 'pending' | 'sent' | 'failed' | 'seen';
|
||||
|
||||
export type TPassportSignatureFormat = 'raw' | 'der';
|
||||
|
||||
export interface IPassportLocationEvidence {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracyMeters: number;
|
||||
capturedAt: number;
|
||||
}
|
||||
|
||||
export interface IPassportNfcEvidence {
|
||||
tagId?: string;
|
||||
readerId?: string;
|
||||
}
|
||||
|
||||
export interface IPassportChallenge {
|
||||
id: string;
|
||||
data: {
|
||||
userId: string;
|
||||
deviceId?: string | null;
|
||||
type: TPassportChallengeType;
|
||||
status: TPassportChallengeStatus;
|
||||
tokenHash?: string | null;
|
||||
challenge: string;
|
||||
metadata: {
|
||||
originHost?: string;
|
||||
audience?: string;
|
||||
notificationTitle?: string;
|
||||
deviceLabel?: string;
|
||||
requireLocation: boolean;
|
||||
requireNfc: boolean;
|
||||
requestedCapabilities?: Partial<IPassportCapabilities>;
|
||||
};
|
||||
evidence?: {
|
||||
signatureFormat?: TPassportSignatureFormat;
|
||||
location?: IPassportLocationEvidence;
|
||||
nfc?: IPassportNfcEvidence;
|
||||
};
|
||||
notification?: {
|
||||
hintId: string;
|
||||
status: TPassportChallengeDeliveryStatus;
|
||||
attemptCount: number;
|
||||
createdAt: number;
|
||||
deliveredAt?: number | null;
|
||||
seenAt?: number | null;
|
||||
lastError?: string | null;
|
||||
};
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
completedAt?: number | null;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
export type TPassportDevicePlatform =
|
||||
| 'ios'
|
||||
| 'ipados'
|
||||
| 'macos'
|
||||
| 'watchos'
|
||||
| 'android'
|
||||
| 'web'
|
||||
| 'unknown';
|
||||
|
||||
export type TPassportDeviceStatus = 'active' | 'revoked';
|
||||
|
||||
export type TPassportPushProvider = 'apns';
|
||||
|
||||
export type TPassportPushEnvironment = 'development' | 'production';
|
||||
|
||||
export interface IPassportCapabilities {
|
||||
gps: boolean;
|
||||
nfc: boolean;
|
||||
push: boolean;
|
||||
}
|
||||
|
||||
export interface IPassportDevice {
|
||||
id: string;
|
||||
data: {
|
||||
userId: string;
|
||||
label: string;
|
||||
platform: TPassportDevicePlatform;
|
||||
status: TPassportDeviceStatus;
|
||||
publicKeyAlgorithm: 'p256';
|
||||
publicKeyX963Base64: string;
|
||||
capabilities: IPassportCapabilities;
|
||||
pushRegistration?: {
|
||||
provider: TPassportPushProvider;
|
||||
token: string;
|
||||
topic: string;
|
||||
environment: TPassportPushEnvironment;
|
||||
registeredAt: number;
|
||||
lastDeliveredAt?: number;
|
||||
lastError?: string;
|
||||
};
|
||||
appVersion?: string;
|
||||
createdAt: number;
|
||||
lastSeenAt?: number;
|
||||
lastChallengeAt?: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export interface IPassportNonce {
|
||||
id: string;
|
||||
data: {
|
||||
deviceId: string;
|
||||
nonceHash: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as data from '../data/index.js';
|
||||
import type { IPassportDeviceSignedRequest } from './passport.js';
|
||||
|
||||
export interface IReq_ListPassportAlerts
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_ListPassportAlerts
|
||||
> {
|
||||
method: 'listPassportAlerts';
|
||||
request: IPassportDeviceSignedRequest;
|
||||
response: {
|
||||
alerts: data.IAlert[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetPassportAlertByHint
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetPassportAlertByHint
|
||||
> {
|
||||
method: 'getPassportAlertByHint';
|
||||
request: IPassportDeviceSignedRequest & {
|
||||
hintId: string;
|
||||
};
|
||||
response: {
|
||||
alert?: data.IAlert;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_MarkPassportAlertSeen
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_MarkPassportAlertSeen
|
||||
> {
|
||||
method: 'markPassportAlertSeen';
|
||||
request: IPassportDeviceSignedRequest & {
|
||||
hintId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_UpsertAlertRule
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_UpsertAlertRule
|
||||
> {
|
||||
method: 'upsertAlertRule';
|
||||
request: {
|
||||
jwt: string;
|
||||
ruleId?: string;
|
||||
scope: data.TAlertRuleScope;
|
||||
organizationId?: string;
|
||||
eventType: string;
|
||||
minimumSeverity: data.TAlertSeverity;
|
||||
recipientMode: data.TAlertRuleRecipientMode;
|
||||
recipientUserIds?: string[];
|
||||
push: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
response: {
|
||||
rule: data.IAlertRule;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetAlertRules
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetAlertRules
|
||||
> {
|
||||
method: 'getAlertRules';
|
||||
request: {
|
||||
jwt: string;
|
||||
scope?: data.TAlertRuleScope;
|
||||
organizationId?: string;
|
||||
};
|
||||
response: {
|
||||
rules: data.IAlertRule[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_DeleteAlertRule
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_DeleteAlertRule
|
||||
> {
|
||||
method: 'deleteAlertRule';
|
||||
request: {
|
||||
jwt: string;
|
||||
ruleId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
export * from './admin.js';
|
||||
export * from './apitoken.js';
|
||||
export * from './alert.js';
|
||||
export * from './app.js';
|
||||
export * from './authorization.js';
|
||||
export * from './billingplan.js';
|
||||
export * from './jwt.js';
|
||||
export * from './login.js';
|
||||
export * from './organization.js';
|
||||
export * from './passport.js';
|
||||
export * from './plan.js';
|
||||
export * from './registration.js';
|
||||
export * from './user.js';
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import * as plugins from '../plugins.js';
|
||||
import * as data from '../data/index.js';
|
||||
|
||||
export interface IPassportDeviceSignedRequest {
|
||||
deviceId: string;
|
||||
timestamp: number;
|
||||
nonce: string;
|
||||
signatureBase64: string;
|
||||
signatureFormat?: data.TPassportSignatureFormat;
|
||||
}
|
||||
|
||||
export interface IReq_CreatePassportEnrollmentChallenge
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_CreatePassportEnrollmentChallenge
|
||||
> {
|
||||
method: 'createPassportEnrollmentChallenge';
|
||||
request: {
|
||||
jwt: string;
|
||||
deviceLabel: string;
|
||||
platform: data.TPassportDevicePlatform;
|
||||
appVersion?: string;
|
||||
capabilities?: Partial<data.IPassportCapabilities>;
|
||||
};
|
||||
response: {
|
||||
challengeId: string;
|
||||
pairingToken: string;
|
||||
pairingPayload: string;
|
||||
signingPayload: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CompletePassportEnrollment
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_CompletePassportEnrollment
|
||||
> {
|
||||
method: 'completePassportEnrollment';
|
||||
request: {
|
||||
pairingToken: string;
|
||||
deviceLabel: string;
|
||||
platform: data.TPassportDevicePlatform;
|
||||
publicKeyX963Base64: string;
|
||||
signatureBase64: string;
|
||||
signatureFormat?: data.TPassportSignatureFormat;
|
||||
appVersion?: string;
|
||||
capabilities?: Partial<data.IPassportCapabilities>;
|
||||
};
|
||||
response: {
|
||||
device: data.IPassportDevice;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetPassportDevices
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetPassportDevices
|
||||
> {
|
||||
method: 'getPassportDevices';
|
||||
request: {
|
||||
jwt: string;
|
||||
};
|
||||
response: {
|
||||
devices: data.IPassportDevice[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_RevokePassportDevice
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_RevokePassportDevice
|
||||
> {
|
||||
method: 'revokePassportDevice';
|
||||
request: {
|
||||
jwt: string;
|
||||
deviceId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_CreatePassportChallenge
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_CreatePassportChallenge
|
||||
> {
|
||||
method: 'createPassportChallenge';
|
||||
request: {
|
||||
jwt: string;
|
||||
type?: Exclude<data.TPassportChallengeType, 'device_enrollment'>;
|
||||
preferredDeviceId?: string;
|
||||
audience?: string;
|
||||
notificationTitle?: string;
|
||||
requireLocation?: boolean;
|
||||
requireNfc?: boolean;
|
||||
};
|
||||
response: {
|
||||
challengeId: string;
|
||||
challenge: string;
|
||||
signingPayload: string;
|
||||
deviceId: string;
|
||||
expiresAt: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_ApprovePassportChallenge
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_ApprovePassportChallenge
|
||||
> {
|
||||
method: 'approvePassportChallenge';
|
||||
request: {
|
||||
challengeId: string;
|
||||
deviceId: string;
|
||||
signatureBase64: string;
|
||||
signatureFormat?: data.TPassportSignatureFormat;
|
||||
location?: data.IPassportLocationEvidence;
|
||||
nfc?: data.IPassportNfcEvidence;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
challenge: data.IPassportChallenge;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_RegisterPassportPushToken
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_RegisterPassportPushToken
|
||||
> {
|
||||
method: 'registerPassportPushToken';
|
||||
request: IPassportDeviceSignedRequest & {
|
||||
provider: data.TPassportPushProvider;
|
||||
token: string;
|
||||
topic: string;
|
||||
environment: data.TPassportPushEnvironment;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_ListPendingPassportChallenges
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_ListPendingPassportChallenges
|
||||
> {
|
||||
method: 'listPendingPassportChallenges';
|
||||
request: IPassportDeviceSignedRequest;
|
||||
response: {
|
||||
challenges: data.IPassportChallenge[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_GetPassportChallengeByHint
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_GetPassportChallengeByHint
|
||||
> {
|
||||
method: 'getPassportChallengeByHint';
|
||||
request: IPassportDeviceSignedRequest & {
|
||||
hintId: string;
|
||||
};
|
||||
response: {
|
||||
challenge?: data.IPassportChallenge;
|
||||
};
|
||||
}
|
||||
|
||||
export interface IReq_MarkPassportChallengeSeen
|
||||
extends plugins.typedRequestInterfaces.implementsTR<
|
||||
plugins.typedRequestInterfaces.ITypedRequest,
|
||||
IReq_MarkPassportChallengeSeen
|
||||
> {
|
||||
method: 'markPassportChallengeSeen';
|
||||
request: IPassportDeviceSignedRequest & {
|
||||
hintId: string;
|
||||
};
|
||||
response: {
|
||||
success: boolean;
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@idp.global/idp.global',
|
||||
version: '1.20.0',
|
||||
version: '1.21.0',
|
||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user