Files
app/test/test.passport.node.ts
T

435 lines
15 KiB
TypeScript
Raw Normal View History

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();