feat(reception): add passport device authentication flows and alert delivery management
This commit is contained in:
@@ -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();
|
||||
Reference in New Issue
Block a user