diff --git a/changelog.md b/changelog.md index 4bf3157..d496176 100644 --- a/changelog.md +++ b/changelog.md @@ -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 diff --git a/test/test.alerts.node.ts b/test/test.alerts.node.ts new file mode 100644 index 0000000..87a4618 --- /dev/null +++ b/test/test.alerts.node.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) => { + return Object.entries(queryArg).every(([keyArg, valueArg]) => getNestedValue(targetArg, keyArg) === valueArg); +}; + +const createTestAlertManager = () => { + const alerts = new Map(); + const alertRules = new Map(); + const users = new Map(); + const roles = new Map(); + const passportDevices = new Map(); + 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) => { + return Array.from(users.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null; + }, + getInstances: async () => Array.from(users.values()), + }, + }, + roleManager: { + CRole: { + getInstance: async (queryArg: Record) => { + 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 }).save = async function () { + alerts.set(this.id, this); + }; + (Alert.prototype as Alert & { delete: () => Promise }).delete = async function () { + alerts.delete(this.id); + }; + (AlertRule.prototype as AlertRule & { save: () => Promise }).save = async function () { + alertRules.set(this.id, this); + }; + (AlertRule.prototype as AlertRule & { delete: () => Promise }).delete = async function () { + alertRules.delete(this.id); + }; + + (manager as any).CAlert = { + getInstance: async (queryArg: Record) => { + return Array.from(alerts.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null; + }, + getInstances: async (queryArg: Record) => { + return Array.from(alerts.values()).filter((docArg) => matchesQuery(docArg, queryArg)); + }, + }; + (manager as any).CAlertRule = { + getInstance: async (queryArg: Record) => { + 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, + 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, + 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(); diff --git a/test/test.passport.node.ts b/test/test.passport.node.ts new file mode 100644 index 0000000..773aa3f --- /dev/null +++ b/test/test.passport.node.ts @@ -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) => { + return Object.entries(queryArg).every(([keyArg, valueArg]) => { + return getNestedValue(targetArg, keyArg) === valueArg; + }); +}; + +const createTestPassportManager = () => { + const passportDevices = new Map(); + const passportChallenges = new Map(); + const passportNonces = new Map(); + 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 }).save = async function () { + passportDevices.set(this.id, this); + }; + (PassportDevice.prototype as PassportDevice & { delete: () => Promise }).delete = async function () { + passportDevices.delete(this.id); + }; + (PassportChallenge.prototype as PassportChallenge & { save: () => Promise }).save = async function () { + passportChallenges.set(this.id, this); + }; + (PassportChallenge.prototype as PassportChallenge & { delete: () => Promise }).delete = async function () { + passportChallenges.delete(this.id); + }; + (PassportNonce.prototype as PassportNonce & { save: () => Promise }).save = async function () { + passportNonces.set(this.id, this); + }; + (PassportNonce.prototype as PassportNonce & { delete: () => Promise }).delete = async function () { + passportNonces.delete(this.id); + }; + + (manager as any).CPassportDevice = { + getInstance: async (queryArg: Record) => { + return Array.from(passportDevices.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null; + }, + getInstances: async (queryArg: Record) => { + return Array.from(passportDevices.values()).filter((docArg) => matchesQuery(docArg, queryArg)); + }, + }; + + (manager as any).CPassportChallenge = { + getInstance: async (queryArg: Record) => { + return ( + Array.from(passportChallenges.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null + ); + }, + getInstances: async (queryArg: Record) => { + return Array.from(passportChallenges.values()).filter((docArg) => matchesQuery(docArg, queryArg)); + }, + }; + + (manager as any).CPassportNonce = { + getInstance: async (queryArg: Record) => { + return Array.from(passportNonces.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null; + }, + getInstances: async (queryArg: Record) => { + 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 }, + 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(); diff --git a/ts/00_commitinfo_data.ts b/ts/00_commitinfo_data.ts index 0aea42a..6ea67bb 100644 --- a/ts/00_commitinfo_data.ts +++ b/ts/00_commitinfo_data.ts @@ -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.' } diff --git a/ts/reception/classes.alert.ts b/ts/reception/classes.alert.ts new file mode 100644 index 0000000..2b51cf7 --- /dev/null +++ b/ts/reception/classes.alert.ts @@ -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, + }; +} diff --git a/ts/reception/classes.alertmanager.ts b/ts/reception/classes.alertmanager.ts new file mode 100644 index 0000000..c97ff8d --- /dev/null +++ b/ts/reception/classes.alertmanager.ts @@ -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 = { + 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( + '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( + '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( + '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( + '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( + '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( + '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(); + 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); + } + } + } +} diff --git a/ts/reception/classes.alertrule.ts b/ts/reception/classes.alertrule.ts new file mode 100644 index 0000000..ad94f5e --- /dev/null +++ b/ts/reception/classes.alertrule.ts @@ -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, + }; +} diff --git a/ts/reception/classes.appmanager.ts b/ts/reception/classes.appmanager.ts index 0ca77e4..7c38524 100644 --- a/ts/reception/classes.appmanager.ts +++ b/ts/reception/classes.appmanager.ts @@ -59,7 +59,20 @@ export class AppManager { new plugins.typedrequest.TypedHandler( '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( '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 diff --git a/ts/reception/classes.housekeeping.ts b/ts/reception/classes.housekeeping.ts index 0d8c9be..96ba910 100644 --- a/ts/reception/classes.housekeeping.ts +++ b/ts/reception/classes.housekeeping.ts @@ -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'); } diff --git a/ts/reception/classes.passportchallenge.ts b/ts/reception/classes.passportchallenge.ts new file mode 100644 index 0000000..ff75460 --- /dev/null +++ b/ts/reception/classes.passportchallenge.ts @@ -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(); + } +} diff --git a/ts/reception/classes.passportdevice.ts b/ts/reception/classes.passportdevice.ts new file mode 100644 index 0000000..c83f202 --- /dev/null +++ b/ts/reception/classes.passportdevice.ts @@ -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'; + } +} diff --git a/ts/reception/classes.passportmanager.ts b/ts/reception/classes.passportmanager.ts new file mode 100644 index 0000000..99a4a7c --- /dev/null +++ b/ts/reception/classes.passportmanager.ts @@ -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( + '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( + '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( + '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( + '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( + '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( + '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( + '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( + '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( + '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( + '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 { + 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; + } + ) { + 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; + }) { + 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; + 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}`); + } + } + } +} diff --git a/ts/reception/classes.passportnonce.ts b/ts/reception/classes.passportnonce.ts new file mode 100644 index 0000000..5e8e40c --- /dev/null +++ b/ts/reception/classes.passportnonce.ts @@ -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; + } +} diff --git a/ts/reception/classes.passportpushmanager.ts b/ts/reception/classes.passportpushmanager.ts new file mode 100644 index 0000000..92e821f --- /dev/null +++ b/ts/reception/classes.passportpushmanager.ts @@ -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 { + 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 + ) { + 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; + } +} diff --git a/ts/reception/classes.reception.ts b/ts/reception/classes.reception.ts index 2b05c18..7e5edc6 100644 --- a/ts/reception/classes.reception.ts +++ b/ts/reception/classes.reception.ts @@ -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); diff --git a/ts_interfaces/data/activity.ts b/ts_interfaces/data/activity.ts index d5f5266..def6b81 100644 --- a/ts_interfaces/data/activity.ts +++ b/ts_interfaces/data/activity.ts @@ -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' diff --git a/ts_interfaces/data/alert.ts b/ts_interfaces/data/alert.ts new file mode 100644 index 0000000..fa14018 --- /dev/null +++ b/ts_interfaces/data/alert.ts @@ -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; + }; +} diff --git a/ts_interfaces/data/alertrule.ts b/ts_interfaces/data/alertrule.ts new file mode 100644 index 0000000..32ee7c2 --- /dev/null +++ b/ts_interfaces/data/alertrule.ts @@ -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; + }; +} diff --git a/ts_interfaces/data/index.ts b/ts_interfaces/data/index.ts index 11204c5..355e4ce 100644 --- a/ts_interfaces/data/index.ts +++ b/ts_interfaces/data/index.ts @@ -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'; diff --git a/ts_interfaces/data/passportchallenge.ts b/ts_interfaces/data/passportchallenge.ts new file mode 100644 index 0000000..1d8d056 --- /dev/null +++ b/ts_interfaces/data/passportchallenge.ts @@ -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; + }; + 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; + }; +} diff --git a/ts_interfaces/data/passportdevice.ts b/ts_interfaces/data/passportdevice.ts new file mode 100644 index 0000000..3a20335 --- /dev/null +++ b/ts_interfaces/data/passportdevice.ts @@ -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; + }; +} diff --git a/ts_interfaces/data/passportnonce.ts b/ts_interfaces/data/passportnonce.ts new file mode 100644 index 0000000..b219eee --- /dev/null +++ b/ts_interfaces/data/passportnonce.ts @@ -0,0 +1,9 @@ +export interface IPassportNonce { + id: string; + data: { + deviceId: string; + nonceHash: string; + createdAt: number; + expiresAt: number; + }; +} diff --git a/ts_interfaces/request/alert.ts b/ts_interfaces/request/alert.ts new file mode 100644 index 0000000..1aa5e87 --- /dev/null +++ b/ts_interfaces/request/alert.ts @@ -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; + }; +} diff --git a/ts_interfaces/request/index.ts b/ts_interfaces/request/index.ts index 92cf45e..c29328b 100644 --- a/ts_interfaces/request/index.ts +++ b/ts_interfaces/request/index.ts @@ -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'; diff --git a/ts_interfaces/request/passport.ts b/ts_interfaces/request/passport.ts new file mode 100644 index 0000000..fed551b --- /dev/null +++ b/ts_interfaces/request/passport.ts @@ -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; + }; + 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; + }; + 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; + 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; + }; +} diff --git a/ts_web/00_commitinfo_data.ts b/ts_web/00_commitinfo_data.ts index 0aea42a..6ea67bb 100644 --- a/ts_web/00_commitinfo_data.ts +++ b/ts_web/00_commitinfo_data.ts @@ -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.' }