Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1a684ee81 | |||
| 6044928c70 | |||
| 3cd7499f3f | |||
| 29a21fd3b3 | |||
| 21f5abb49b | |||
| 68469b0740 | |||
| 525a72b73b | |||
| d913dfaeb1 | |||
| fe9da65437 | |||
| 28d30fe392 |
@@ -1,5 +1,47 @@
|
|||||||
# Changelog
|
# 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
|
||||||
|
|
||||||
|
- introduces AbuseProtectionManager and AbuseWindow storage to rate limit password login, magic link, password reset, and OIDC token exchange attempts
|
||||||
|
- adds housekeeping cleanup for expired abuse protection windows
|
||||||
|
- adds typed OIDC prepare/complete authorization requests plus consent evaluation and redirect URL generation
|
||||||
|
- updates the login prompt to support OIDC authorization continuation after user login or consent
|
||||||
|
- includes tests for abuse protection behavior and OIDC authorization preparation/completion flows
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.19.1 - fix(ts_interfaces)
|
||||||
|
rename generated TypeScript interface files to remove the loint-reception prefix
|
||||||
|
|
||||||
|
- Moves data and request interface files from loint-reception.* names to clean module names under ts_interfaces
|
||||||
|
- Renames the shared plugins export to ts_interfaces/plugins.ts
|
||||||
|
- Preserves interface contents while standardizing the generated file naming layout
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.19.0 - feat(oidc)
|
||||||
|
persist hashed OIDC tokens, authorization codes, and user consent in smartdata storage
|
||||||
|
|
||||||
|
- replace in-memory OIDC authorization code, access token, refresh token, and consent stores with SmartData document classes
|
||||||
|
- store authorization codes and tokens as hashes instead of persisting plaintext values, with helpers for matching, expiration, and revocation
|
||||||
|
- persist and merge user consent scopes when issuing authorization codes
|
||||||
|
- add cleanup lifecycle management for expired OIDC state and stop the cleanup task when reception shuts down
|
||||||
|
- add tests covering hashed code/token matching, authorization code usage, refresh token revocation, and consent scope merging
|
||||||
|
|
||||||
|
## 2026-04-20 - 1.18.0 - feat(reception)
|
||||||
|
persist email action tokens and registration sessions for authentication and signup flows
|
||||||
|
|
||||||
|
- add persisted email action tokens for email login and password reset with one-time consumption and expiry cleanup
|
||||||
|
- store registration sessions in the database so signup state, email validation, and SMS verification survive restarts
|
||||||
|
- enforce password changes through either a valid reset token or the current password
|
||||||
|
- add housekeeping jobs and tests for token/session expiry and state persistence
|
||||||
|
|
||||||
## 2026-04-20 - 1.17.1 - fix(docs)
|
## 2026-04-20 - 1.17.1 - fix(docs)
|
||||||
refresh module readmes and add repository license file
|
refresh module readmes and add repository license file
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@idp.global/idp.global",
|
"name": "@idp.global/idp.global",
|
||||||
"version": "1.17.1",
|
"version": "1.21.0",
|
||||||
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
"description": "An identity provider software managing user authentications, registrations, and sessions.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
"typings": "dist_ts/index.d.ts",
|
"typings": "dist_ts/index.d.ts",
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AbuseProtectionManager,
|
||||||
|
type IAbuseProtectionConfig,
|
||||||
|
} from '../ts/reception/classes.abuseprotectionmanager.js';
|
||||||
|
import { AbuseWindow } from '../ts/reception/classes.abusewindow.js';
|
||||||
|
|
||||||
|
const createTestAbuseProtectionManager = () => {
|
||||||
|
const manager = new AbuseProtectionManager({
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const store = new Map<string, AbuseWindow>();
|
||||||
|
const originalSave = AbuseWindow.prototype.save;
|
||||||
|
const originalDelete = AbuseWindow.prototype.delete;
|
||||||
|
|
||||||
|
(AbuseWindow.prototype as AbuseWindow & { save: () => Promise<void> }).save = async function () {
|
||||||
|
store.set(this.id, this);
|
||||||
|
};
|
||||||
|
(AbuseWindow.prototype as AbuseWindow & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
store.delete(this.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CAbuseWindow = {
|
||||||
|
getInstance: async (queryArg) => store.get(queryArg.id) ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = () => {
|
||||||
|
AbuseWindow.prototype.save = originalSave;
|
||||||
|
AbuseWindow.prototype.delete = originalDelete;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager,
|
||||||
|
store,
|
||||||
|
restore,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const testConfig: IAbuseProtectionConfig = {
|
||||||
|
maxAttempts: 2,
|
||||||
|
windowMillis: 1_000,
|
||||||
|
blockDurationMillis: 2_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('blocks after too many attempts within the active window', async () => {
|
||||||
|
const { manager, restore } = createTestAbuseProtectionManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
|
||||||
|
await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow();
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('resets attempts after the block and window have elapsed', async () => {
|
||||||
|
const { manager, store, restore } = createTestAbuseProtectionManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
await expect(manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig)).rejects.toThrow();
|
||||||
|
|
||||||
|
const abuseWindow = Array.from(store.values())[0];
|
||||||
|
abuseWindow.data.blockedUntil = Date.now() - 10;
|
||||||
|
abuseWindow.data.windowStartedAt = Date.now() - testConfig.windowMillis - 10;
|
||||||
|
abuseWindow.data.validUntil = Date.now() + 1_000;
|
||||||
|
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
expect(abuseWindow.data.attemptCount).toEqual(1);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('clears stored attempts after a successful action', async () => {
|
||||||
|
const { manager, store, restore } = createTestAbuseProtectionManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await manager.consumeAttempt('passwordLogin', 'phil@example.com', testConfig);
|
||||||
|
expect(store.size).toEqual(1);
|
||||||
|
|
||||||
|
await manager.clearAttempts('passwordLogin', 'phil@example.com');
|
||||||
|
expect(store.size).toEqual(0);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { Alert } from '../ts/reception/classes.alert.js';
|
||||||
|
import { AlertManager } from '../ts/reception/classes.alertmanager.js';
|
||||||
|
import { AlertRule } from '../ts/reception/classes.alertrule.js';
|
||||||
|
import { PassportDevice } from '../ts/reception/classes.passportdevice.js';
|
||||||
|
import { Role } from '../ts/reception/classes.role.js';
|
||||||
|
import { User } from '../ts/reception/classes.user.js';
|
||||||
|
|
||||||
|
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||||||
|
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||||||
|
return Object.entries(queryArg).every(([keyArg, valueArg]) => getNestedValue(targetArg, keyArg) === valueArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestAlertManager = () => {
|
||||||
|
const alerts = new Map<string, Alert>();
|
||||||
|
const alertRules = new Map<string, AlertRule>();
|
||||||
|
const users = new Map<string, User>();
|
||||||
|
const roles = new Map<string, Role>();
|
||||||
|
const passportDevices = new Map<string, PassportDevice>();
|
||||||
|
const deliveredHints: string[] = [];
|
||||||
|
|
||||||
|
const manager = new AlertManager({
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
typedrouter: { addTypedRouter: () => undefined },
|
||||||
|
jwtManager: {
|
||||||
|
verifyJWTAndGetData: async (jwtArg: string) => ({
|
||||||
|
data: {
|
||||||
|
userId: jwtArg,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
userManager: {
|
||||||
|
CUser: {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(users.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async () => Array.from(users.values()),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
roleManager: {
|
||||||
|
CRole: {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(roles.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
getAllRolesForOrg: async (organizationIdArg: string) =>
|
||||||
|
Array.from(roles.values()).filter((roleArg) => roleArg.data.organizationId === organizationIdArg),
|
||||||
|
},
|
||||||
|
passportManager: {
|
||||||
|
authenticatePassportDeviceRequest: async (requestArg: { deviceId: string }) => {
|
||||||
|
return passportDevices.get(requestArg.deviceId)!;
|
||||||
|
},
|
||||||
|
getPassportDevicesForUser: async (userIdArg: string) =>
|
||||||
|
Array.from(passportDevices.values()).filter(
|
||||||
|
(deviceArg) => deviceArg.data.userId === userIdArg && deviceArg.data.status === 'active'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
passportPushManager: {
|
||||||
|
deliverAlertHint: async (_passportDeviceArg: PassportDevice, alertArg: Alert) => {
|
||||||
|
deliveredHints.push(alertArg.data.notification.hintId);
|
||||||
|
alertArg.data.notification = {
|
||||||
|
...alertArg.data.notification,
|
||||||
|
status: 'sent',
|
||||||
|
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||||
|
deliveredAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
await alertArg.save();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const originalAlertSave = Alert.prototype.save;
|
||||||
|
const originalAlertDelete = Alert.prototype.delete;
|
||||||
|
const originalAlertRuleSave = AlertRule.prototype.save;
|
||||||
|
const originalAlertRuleDelete = AlertRule.prototype.delete;
|
||||||
|
|
||||||
|
(Alert.prototype as Alert & { save: () => Promise<void> }).save = async function () {
|
||||||
|
alerts.set(this.id, this);
|
||||||
|
};
|
||||||
|
(Alert.prototype as Alert & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
alerts.delete(this.id);
|
||||||
|
};
|
||||||
|
(AlertRule.prototype as AlertRule & { save: () => Promise<void> }).save = async function () {
|
||||||
|
alertRules.set(this.id, this);
|
||||||
|
};
|
||||||
|
(AlertRule.prototype as AlertRule & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
alertRules.delete(this.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CAlert = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(alerts.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(alerts.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
(manager as any).CAlertRule = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(alertRules.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async () => Array.from(alertRules.values()),
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager,
|
||||||
|
alerts,
|
||||||
|
alertRules,
|
||||||
|
users,
|
||||||
|
roles,
|
||||||
|
passportDevices,
|
||||||
|
deliveredHints,
|
||||||
|
restore: () => {
|
||||||
|
Alert.prototype.save = originalAlertSave;
|
||||||
|
Alert.prototype.delete = originalAlertDelete;
|
||||||
|
AlertRule.prototype.save = originalAlertRuleSave;
|
||||||
|
AlertRule.prototype.delete = originalAlertRuleDelete;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const addUser = (
|
||||||
|
usersArg: Map<string, User>,
|
||||||
|
optionsArg: { id: string; email: string; isGlobalAdmin?: boolean }
|
||||||
|
) => {
|
||||||
|
const user = new User();
|
||||||
|
user.id = optionsArg.id;
|
||||||
|
user.data = {
|
||||||
|
name: optionsArg.email,
|
||||||
|
username: optionsArg.email,
|
||||||
|
email: optionsArg.email,
|
||||||
|
status: 'active',
|
||||||
|
connectedOrgs: [],
|
||||||
|
isGlobalAdmin: optionsArg.isGlobalAdmin,
|
||||||
|
};
|
||||||
|
usersArg.set(user.id, user);
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
|
||||||
|
const addPassportDevice = (
|
||||||
|
passportDevicesArg: Map<string, PassportDevice>,
|
||||||
|
optionsArg: { id: string; userId: string; label: string }
|
||||||
|
) => {
|
||||||
|
const device = new PassportDevice();
|
||||||
|
device.id = optionsArg.id;
|
||||||
|
device.data = {
|
||||||
|
userId: optionsArg.userId,
|
||||||
|
label: optionsArg.label,
|
||||||
|
platform: 'ios',
|
||||||
|
status: 'active',
|
||||||
|
publicKeyAlgorithm: 'p256',
|
||||||
|
publicKeyX963Base64: 'public-key',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: true,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
pushRegistration: {
|
||||||
|
provider: 'apns',
|
||||||
|
token: `${optionsArg.id}-token`,
|
||||||
|
topic: 'global.idp.swiftapp',
|
||||||
|
environment: 'development',
|
||||||
|
registeredAt: Date.now(),
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
};
|
||||||
|
passportDevicesArg.set(device.id, device);
|
||||||
|
return device;
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('creates global admin access alerts with the built-in fallback rule', async () => {
|
||||||
|
const { manager, users, passportDevices, alerts, deliveredHints, restore } = createTestAlertManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
addUser(users, { id: 'admin-1', email: 'admin-1@example.com', isGlobalAdmin: true });
|
||||||
|
addPassportDevice(passportDevices, { id: 'device-1', userId: 'admin-1', label: 'Admin Phone' });
|
||||||
|
|
||||||
|
const createdAlerts = await manager.createAlertsForEvent({
|
||||||
|
category: 'admin',
|
||||||
|
eventType: 'global_admin_access',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Global admin console accessed',
|
||||||
|
body: 'A global admin accessed the console.',
|
||||||
|
actorUserId: 'admin-1',
|
||||||
|
relatedEntityType: 'global-admin-console',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createdAlerts).toHaveLength(1);
|
||||||
|
expect(alerts.size).toEqual(1);
|
||||||
|
expect(createdAlerts[0].data.notification.status).toEqual('sent');
|
||||||
|
expect(deliveredHints).toHaveLength(1);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('routes organization-scoped alerts to org admins by rule', async () => {
|
||||||
|
const { manager, users, roles, passportDevices, restore } = createTestAlertManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
addUser(users, { id: 'owner-1', email: 'owner@example.com' });
|
||||||
|
addUser(users, { id: 'viewer-1', email: 'viewer@example.com' });
|
||||||
|
addPassportDevice(passportDevices, { id: 'owner-device', userId: 'owner-1', label: 'Owner Phone' });
|
||||||
|
|
||||||
|
const ownerRole = new Role();
|
||||||
|
ownerRole.id = 'role-owner';
|
||||||
|
ownerRole.data = {
|
||||||
|
userId: 'owner-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
roles: ['owner'],
|
||||||
|
};
|
||||||
|
roles.set(ownerRole.id, ownerRole);
|
||||||
|
|
||||||
|
const viewerRole = new Role();
|
||||||
|
viewerRole.id = 'role-viewer';
|
||||||
|
viewerRole.data = {
|
||||||
|
userId: 'viewer-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
roles: ['viewer'],
|
||||||
|
};
|
||||||
|
roles.set(viewerRole.id, viewerRole);
|
||||||
|
|
||||||
|
const rule = new AlertRule();
|
||||||
|
rule.id = 'org-admin-rule';
|
||||||
|
rule.data = {
|
||||||
|
scope: 'organization',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
eventType: 'org_security_notice',
|
||||||
|
minimumSeverity: 'medium',
|
||||||
|
recipientMode: 'org_admins',
|
||||||
|
recipientUserIds: [],
|
||||||
|
push: true,
|
||||||
|
enabled: true,
|
||||||
|
createdByUserId: 'owner-1',
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await rule.save();
|
||||||
|
|
||||||
|
const createdAlerts = await manager.createAlertsForEvent({
|
||||||
|
category: 'security',
|
||||||
|
eventType: 'org_security_notice',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Organization security event',
|
||||||
|
body: 'A sensitive organization event occurred.',
|
||||||
|
actorUserId: 'viewer-1',
|
||||||
|
organizationId: 'org-1',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createdAlerts).toHaveLength(1);
|
||||||
|
expect(createdAlerts[0].data.recipientUserId).toEqual('owner-1');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('lists alerts, resolves hint lookups, and marks alerts seen', async () => {
|
||||||
|
const { manager, alerts, restore } = createTestAlertManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const alert = new Alert();
|
||||||
|
alert.id = 'alert-1';
|
||||||
|
alert.data = {
|
||||||
|
recipientUserId: 'user-1',
|
||||||
|
category: 'security',
|
||||||
|
eventType: 'global_admin_access',
|
||||||
|
severity: 'high',
|
||||||
|
title: 'Important alert',
|
||||||
|
body: 'Please inspect this alert.',
|
||||||
|
notification: {
|
||||||
|
hintId: 'hint-1',
|
||||||
|
status: 'sent',
|
||||||
|
attemptCount: 1,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
deliveredAt: Date.now(),
|
||||||
|
seenAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
seenAt: null,
|
||||||
|
dismissedAt: null,
|
||||||
|
};
|
||||||
|
await alert.save();
|
||||||
|
|
||||||
|
const listedAlerts = await manager.listAlertsForUser('user-1');
|
||||||
|
expect(listedAlerts).toHaveLength(1);
|
||||||
|
|
||||||
|
const hintAlert = await manager.getAlertByHint('user-1', 'hint-1');
|
||||||
|
expect(hintAlert?.id).toEqual('alert-1');
|
||||||
|
|
||||||
|
const seenAlert = await manager.markAlertSeen('user-1', 'hint-1');
|
||||||
|
expect(seenAlert.data.notification.status).toEqual('seen');
|
||||||
|
expect(seenAlert.data.seenAt).toBeGreaterThan(0);
|
||||||
|
expect(alerts.get('alert-1')?.data.notification.status).toEqual('seen');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { EmailActionToken } from '../ts/reception/classes.emailactiontoken.js';
|
||||||
import { LoginSession } from '../ts/reception/classes.loginsession.js';
|
import { LoginSession } from '../ts/reception/classes.loginsession.js';
|
||||||
|
import { RegistrationSession } from '../ts/reception/classes.registrationsession.js';
|
||||||
import { User } from '../ts/reception/classes.user.js';
|
import { User } from '../ts/reception/classes.user.js';
|
||||||
import * as plugins from '../ts/plugins.js';
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
|
||||||
@@ -12,6 +14,42 @@ const createTestLoginSession = () => {
|
|||||||
return loginSession;
|
return loginSession;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const createTestEmailActionToken = () => {
|
||||||
|
const emailActionToken = new EmailActionToken();
|
||||||
|
emailActionToken.id = 'email-action-token';
|
||||||
|
emailActionToken.data.email = 'user@example.com';
|
||||||
|
emailActionToken.data.action = 'emailLogin';
|
||||||
|
emailActionToken.data.validUntil = Date.now() + 60_000;
|
||||||
|
|
||||||
|
let deleted = false;
|
||||||
|
(emailActionToken as EmailActionToken & { delete: () => Promise<void> }).delete = async () => {
|
||||||
|
deleted = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
emailActionToken,
|
||||||
|
wasDeleted: () => deleted,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestRegistrationSession = () => {
|
||||||
|
const registrationSession = new RegistrationSession();
|
||||||
|
registrationSession.id = 'registration-session';
|
||||||
|
registrationSession.data.emailAddress = 'user@example.com';
|
||||||
|
registrationSession.data.validUntil = Date.now() + 60_000;
|
||||||
|
|
||||||
|
let deleted = false;
|
||||||
|
(registrationSession as RegistrationSession & { save: () => Promise<void> }).save = async () => undefined;
|
||||||
|
(registrationSession as RegistrationSession & { delete: () => Promise<void> }).delete = async () => {
|
||||||
|
deleted = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
registrationSession,
|
||||||
|
wasDeleted: () => deleted,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
tap.test('hashes passwords with argon2 and verifies them', async () => {
|
tap.test('hashes passwords with argon2 and verifies them', async () => {
|
||||||
const passwordHash = await User.hashPassword('correct horse battery staple');
|
const passwordHash = await User.hashPassword('correct horse battery staple');
|
||||||
|
|
||||||
@@ -58,4 +96,45 @@ tap.test('persists transfer tokens as one-time hashes', async () => {
|
|||||||
expect(await loginSession.validateTransferToken(transferToken)).toBeFalse();
|
expect(await loginSession.validateTransferToken(transferToken)).toBeFalse();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tap.test('consumes email action tokens exactly once', async () => {
|
||||||
|
const { emailActionToken, wasDeleted } = createTestEmailActionToken();
|
||||||
|
const plainToken = EmailActionToken.createOpaqueToken('emailLogin');
|
||||||
|
emailActionToken.data.tokenHash = EmailActionToken.hashToken(plainToken);
|
||||||
|
|
||||||
|
expect(await emailActionToken.consume(plainToken)).toBeTrue();
|
||||||
|
expect(wasDeleted()).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('invalidates expired email action tokens', async () => {
|
||||||
|
const { emailActionToken, wasDeleted } = createTestEmailActionToken();
|
||||||
|
emailActionToken.data.tokenHash = EmailActionToken.hashToken('expired-token');
|
||||||
|
emailActionToken.data.validUntil = Date.now() - 1;
|
||||||
|
|
||||||
|
expect(await emailActionToken.consume('expired-token')).toBeFalse();
|
||||||
|
expect(wasDeleted()).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('persists registration token validation and sms verification state', async () => {
|
||||||
|
const { registrationSession } = createTestRegistrationSession();
|
||||||
|
const emailToken = 'registration-token';
|
||||||
|
registrationSession.data.hashedEmailToken = RegistrationSession.hashToken(emailToken);
|
||||||
|
|
||||||
|
expect(await registrationSession.validateEmailToken(emailToken)).toBeTrue();
|
||||||
|
expect(registrationSession.data.status).toEqual('emailValidated');
|
||||||
|
expect(registrationSession.data.collectedData.userData.email).toEqual('user@example.com');
|
||||||
|
|
||||||
|
registrationSession.data.smsCodeHash = RegistrationSession.hashToken('123456');
|
||||||
|
expect(await registrationSession.validateSmsCode('123456')).toBeTrue();
|
||||||
|
expect(registrationSession.data.status).toEqual('mobileVerified');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('removes expired registration sessions on token validation', async () => {
|
||||||
|
const { registrationSession, wasDeleted } = createTestRegistrationSession();
|
||||||
|
registrationSession.data.hashedEmailToken = RegistrationSession.hashToken('expired-registration');
|
||||||
|
registrationSession.data.validUntil = Date.now() - 1;
|
||||||
|
|
||||||
|
expect(await registrationSession.validateEmailToken('expired-registration')).toBeFalse();
|
||||||
|
expect(wasDeleted()).toBeTrue();
|
||||||
|
});
|
||||||
|
|
||||||
export default tap.start();
|
export default tap.start();
|
||||||
|
|||||||
@@ -0,0 +1,208 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import { OidcAccessToken } from '../ts/reception/classes.oidcaccesstoken.js';
|
||||||
|
import { OidcAuthorizationCode } from '../ts/reception/classes.oidcauthorizationcode.js';
|
||||||
|
import { OidcManager } from '../ts/reception/classes.oidcmanager.js';
|
||||||
|
import { OidcRefreshToken } from '../ts/reception/classes.oidcrefreshtoken.js';
|
||||||
|
import { OidcUserConsent } from '../ts/reception/classes.oidcuserconsent.js';
|
||||||
|
|
||||||
|
const createTestOidcManager = () => {
|
||||||
|
const oidcManager = new OidcManager({
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
typedrouter: { addTypedRouter: () => undefined },
|
||||||
|
options: { baseUrl: 'https://idp.example' },
|
||||||
|
} as any);
|
||||||
|
void oidcManager.stop();
|
||||||
|
return oidcManager;
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('stores authorization codes as hashes and marks them used', async () => {
|
||||||
|
const authCode = new OidcAuthorizationCode();
|
||||||
|
authCode.id = 'oidc-auth-code';
|
||||||
|
authCode.data.codeHash = OidcAuthorizationCode.hashCode('plain-auth-code');
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(authCode as OidcAuthorizationCode & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(authCode.matchesCode('plain-auth-code')).toBeTrue();
|
||||||
|
expect(authCode.matchesCode('wrong-code')).toBeFalse();
|
||||||
|
|
||||||
|
await authCode.markUsed();
|
||||||
|
expect(authCode.data.used).toBeTrue();
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('stores access tokens without plaintext persistence', async () => {
|
||||||
|
const accessToken = new OidcAccessToken();
|
||||||
|
accessToken.id = 'oidc-access-token';
|
||||||
|
accessToken.data.tokenHash = OidcAccessToken.hashToken('plain-access-token');
|
||||||
|
accessToken.data.expiresAt = Date.now() + 60_000;
|
||||||
|
|
||||||
|
expect(accessToken.matchesToken('plain-access-token')).toBeTrue();
|
||||||
|
expect(accessToken.matchesToken('different-access-token')).toBeFalse();
|
||||||
|
expect(accessToken.isExpired()).toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('revokes persisted refresh tokens', async () => {
|
||||||
|
const refreshToken = new OidcRefreshToken();
|
||||||
|
refreshToken.id = 'oidc-refresh-token';
|
||||||
|
refreshToken.data.tokenHash = OidcRefreshToken.hashToken('plain-refresh-token');
|
||||||
|
refreshToken.data.expiresAt = Date.now() + 60_000;
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(refreshToken as OidcRefreshToken & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(refreshToken.matchesToken('plain-refresh-token')).toBeTrue();
|
||||||
|
expect(refreshToken.data.revoked).toBeFalse();
|
||||||
|
|
||||||
|
await refreshToken.revoke();
|
||||||
|
expect(refreshToken.data.revoked).toBeTrue();
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('merges user consent scopes without duplicates', async () => {
|
||||||
|
const consent = new OidcUserConsent();
|
||||||
|
consent.id = 'oidc-consent';
|
||||||
|
consent.data.userId = 'user-1';
|
||||||
|
consent.data.clientId = 'client-1';
|
||||||
|
consent.data.scopes = ['openid'];
|
||||||
|
|
||||||
|
let saveCount = 0;
|
||||||
|
(consent as OidcUserConsent & { save: () => Promise<void> }).save = async () => {
|
||||||
|
saveCount++;
|
||||||
|
};
|
||||||
|
|
||||||
|
await consent.grantScopes(['openid', 'email', 'profile']);
|
||||||
|
|
||||||
|
expect(consent.data.scopes.sort()).toEqual(['email', 'openid', 'profile']);
|
||||||
|
expect(consent.data.grantedAt).toBeGreaterThan(0);
|
||||||
|
expect(consent.data.updatedAt).toBeGreaterThan(0);
|
||||||
|
expect(saveCount).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('builds an OAuth redirect URL after successful authorization completion', async () => {
|
||||||
|
const oidcManager = createTestOidcManager();
|
||||||
|
|
||||||
|
(oidcManager as any).findAppByClientId = async () => ({
|
||||||
|
data: {
|
||||||
|
name: 'Example App',
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
logoUrl: 'https://app.example/logo.png',
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUris: ['https://app.example/callback'],
|
||||||
|
allowedScopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(oidcManager as any).generateAuthorizationCode = async () => 'generated-auth-code';
|
||||||
|
(oidcManager as any).getUserConsent = async () => ({
|
||||||
|
data: {
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
(oidcManager as any).upsertUserConsent = async () => undefined;
|
||||||
|
|
||||||
|
const result = await oidcManager.completeAuthorizationForUser('user-1', {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUri: 'https://app.example/callback',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state: 'xyz-state',
|
||||||
|
codeChallenge: 'challenge',
|
||||||
|
codeChallengeMethod: 'S256',
|
||||||
|
nonce: 'nonce-1',
|
||||||
|
consentApproved: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.code).toEqual('generated-auth-code');
|
||||||
|
expect(result.redirectUrl).toEqual(
|
||||||
|
'https://app.example/callback?code=generated-auth-code&state=xyz-state'
|
||||||
|
);
|
||||||
|
|
||||||
|
await oidcManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('prepares OAuth consent when scopes are not yet granted', async () => {
|
||||||
|
const oidcManager = createTestOidcManager();
|
||||||
|
|
||||||
|
(oidcManager as any).findAppByClientId = async () => ({
|
||||||
|
data: {
|
||||||
|
name: 'Example App',
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
logoUrl: 'https://app.example/logo.png',
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUris: ['https://app.example/callback'],
|
||||||
|
allowedScopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(oidcManager as any).getUserConsent = async () => ({
|
||||||
|
data: {
|
||||||
|
scopes: ['openid'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await oidcManager.prepareAuthorizationForUser('user-1', {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUri: 'https://app.example/callback',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state: 'xyz-state',
|
||||||
|
prompt: undefined,
|
||||||
|
codeChallenge: undefined,
|
||||||
|
codeChallengeMethod: undefined,
|
||||||
|
nonce: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toEqual('consent_required');
|
||||||
|
expect(result.requestedScopes.sort()).toEqual(['email', 'openid', 'profile']);
|
||||||
|
expect(result.grantedScopes).toEqual(['openid']);
|
||||||
|
|
||||||
|
await oidcManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('prepares OAuth authorization as ready when consent already exists', async () => {
|
||||||
|
const oidcManager = createTestOidcManager();
|
||||||
|
|
||||||
|
(oidcManager as any).findAppByClientId = async () => ({
|
||||||
|
data: {
|
||||||
|
name: 'Example App',
|
||||||
|
appUrl: 'https://app.example',
|
||||||
|
logoUrl: 'https://app.example/logo.png',
|
||||||
|
oauthCredentials: {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUris: ['https://app.example/callback'],
|
||||||
|
allowedScopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
(oidcManager as any).getUserConsent = async () => ({
|
||||||
|
data: {
|
||||||
|
scopes: ['openid', 'profile', 'email'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await oidcManager.prepareAuthorizationForUser('user-1', {
|
||||||
|
clientId: 'client-1',
|
||||||
|
redirectUri: 'https://app.example/callback',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
state: 'xyz-state',
|
||||||
|
prompt: undefined,
|
||||||
|
codeChallenge: undefined,
|
||||||
|
codeChallengeMethod: undefined,
|
||||||
|
nonce: undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toEqual('ready');
|
||||||
|
|
||||||
|
await oidcManager.stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
import { tap, expect } from '@git.zone/tstest/tapbundle';
|
||||||
|
|
||||||
|
import * as plugins from '../ts/plugins.js';
|
||||||
|
import { PassportChallenge } from '../ts/reception/classes.passportchallenge.js';
|
||||||
|
import { PassportDevice } from '../ts/reception/classes.passportdevice.js';
|
||||||
|
import { PassportManager } from '../ts/reception/classes.passportmanager.js';
|
||||||
|
import { PassportNonce } from '../ts/reception/classes.passportnonce.js';
|
||||||
|
|
||||||
|
const getNestedValue = (targetArg: any, pathArg: string) => {
|
||||||
|
return pathArg.split('.').reduce((currentArg, keyArg) => currentArg?.[keyArg], targetArg);
|
||||||
|
};
|
||||||
|
|
||||||
|
const matchesQuery = (targetArg: any, queryArg: Record<string, any>) => {
|
||||||
|
return Object.entries(queryArg).every(([keyArg, valueArg]) => {
|
||||||
|
return getNestedValue(targetArg, keyArg) === valueArg;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestPassportManager = () => {
|
||||||
|
const passportDevices = new Map<string, PassportDevice>();
|
||||||
|
const passportChallenges = new Map<string, PassportChallenge>();
|
||||||
|
const passportNonces = new Map<string, PassportNonce>();
|
||||||
|
const activityLogCalls: Array<{
|
||||||
|
userId: string;
|
||||||
|
action: string;
|
||||||
|
description: string;
|
||||||
|
}> = [];
|
||||||
|
const deliveredHintIds: string[] = [];
|
||||||
|
|
||||||
|
const manager = new PassportManager({
|
||||||
|
db: { smartdataDb: {} },
|
||||||
|
typedrouter: { addTypedRouter: () => undefined },
|
||||||
|
options: { baseUrl: 'https://idp.global' },
|
||||||
|
jwtManager: { verifyJWTAndGetData: async () => null },
|
||||||
|
activityLogManager: {
|
||||||
|
logActivity: async (userIdArg: string, actionArg: string, descriptionArg: string) => {
|
||||||
|
activityLogCalls.push({
|
||||||
|
userId: userIdArg,
|
||||||
|
action: actionArg,
|
||||||
|
description: descriptionArg,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
passportPushManager: {
|
||||||
|
deliverChallengeHint: async (_passportDeviceArg: PassportDevice, passportChallengeArg: PassportChallenge) => {
|
||||||
|
deliveredHintIds.push(passportChallengeArg.data.notification!.hintId);
|
||||||
|
passportChallengeArg.data.notification = {
|
||||||
|
...passportChallengeArg.data.notification!,
|
||||||
|
status: 'sent',
|
||||||
|
attemptCount: passportChallengeArg.data.notification!.attemptCount + 1,
|
||||||
|
deliveredAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
await passportChallengeArg.save();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
|
||||||
|
const originalPassportDeviceSave = PassportDevice.prototype.save;
|
||||||
|
const originalPassportDeviceDelete = PassportDevice.prototype.delete;
|
||||||
|
const originalPassportChallengeSave = PassportChallenge.prototype.save;
|
||||||
|
const originalPassportChallengeDelete = PassportChallenge.prototype.delete;
|
||||||
|
const originalPassportNonceSave = PassportNonce.prototype.save;
|
||||||
|
const originalPassportNonceDelete = PassportNonce.prototype.delete;
|
||||||
|
|
||||||
|
(PassportDevice.prototype as PassportDevice & { save: () => Promise<void> }).save = async function () {
|
||||||
|
passportDevices.set(this.id, this);
|
||||||
|
};
|
||||||
|
(PassportDevice.prototype as PassportDevice & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
passportDevices.delete(this.id);
|
||||||
|
};
|
||||||
|
(PassportChallenge.prototype as PassportChallenge & { save: () => Promise<void> }).save = async function () {
|
||||||
|
passportChallenges.set(this.id, this);
|
||||||
|
};
|
||||||
|
(PassportChallenge.prototype as PassportChallenge & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
passportChallenges.delete(this.id);
|
||||||
|
};
|
||||||
|
(PassportNonce.prototype as PassportNonce & { save: () => Promise<void> }).save = async function () {
|
||||||
|
passportNonces.set(this.id, this);
|
||||||
|
};
|
||||||
|
(PassportNonce.prototype as PassportNonce & { delete: () => Promise<void> }).delete = async function () {
|
||||||
|
passportNonces.delete(this.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CPassportDevice = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(passportDevices.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(passportDevices.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CPassportChallenge = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return (
|
||||||
|
Array.from(passportChallenges.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null
|
||||||
|
);
|
||||||
|
},
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(passportChallenges.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
(manager as any).CPassportNonce = {
|
||||||
|
getInstance: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(passportNonces.values()).find((docArg) => matchesQuery(docArg, queryArg)) || null;
|
||||||
|
},
|
||||||
|
getInstances: async (queryArg: Record<string, any>) => {
|
||||||
|
return Array.from(passportNonces.values()).filter((docArg) => matchesQuery(docArg, queryArg));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
manager,
|
||||||
|
passportDevices,
|
||||||
|
passportChallenges,
|
||||||
|
passportNonces,
|
||||||
|
activityLogCalls,
|
||||||
|
deliveredHintIds,
|
||||||
|
restore: () => {
|
||||||
|
PassportDevice.prototype.save = originalPassportDeviceSave;
|
||||||
|
PassportDevice.prototype.delete = originalPassportDeviceDelete;
|
||||||
|
PassportChallenge.prototype.save = originalPassportChallengeSave;
|
||||||
|
PassportChallenge.prototype.delete = originalPassportChallengeDelete;
|
||||||
|
PassportNonce.prototype.save = originalPassportNonceSave;
|
||||||
|
PassportNonce.prototype.delete = originalPassportNonceDelete;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createRawPassportSigner = async () => {
|
||||||
|
const subtle = plugins.crypto.webcrypto.subtle;
|
||||||
|
const keyPair = await subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, [
|
||||||
|
'sign',
|
||||||
|
'verify',
|
||||||
|
]);
|
||||||
|
const publicKeyRaw = Buffer.from(await subtle.exportKey('raw', keyPair.publicKey)).toString('base64');
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKeyX963Base64: publicKeyRaw,
|
||||||
|
sign: async (payloadArg: string) => {
|
||||||
|
const signature = await subtle.sign(
|
||||||
|
{ name: 'ECDSA', hash: 'SHA-256' },
|
||||||
|
keyPair.privateKey,
|
||||||
|
Buffer.from(payloadArg, 'utf8')
|
||||||
|
);
|
||||||
|
return Buffer.from(signature).toString('base64');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDerPassportSigner = () => {
|
||||||
|
const keyPair = plugins.crypto.generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
|
||||||
|
const publicJwk = keyPair.publicKey.export({ format: 'jwk' }) as JsonWebKey;
|
||||||
|
const publicKeyX963Base64 = Buffer.concat([
|
||||||
|
Buffer.from([4]),
|
||||||
|
Buffer.from(publicJwk.x!, 'base64url'),
|
||||||
|
Buffer.from(publicJwk.y!, 'base64url'),
|
||||||
|
]).toString('base64');
|
||||||
|
|
||||||
|
return {
|
||||||
|
publicKeyX963Base64,
|
||||||
|
sign: (payloadArg: string) => {
|
||||||
|
return plugins.crypto.sign('sha256', Buffer.from(payloadArg, 'utf8'), keyPair.privateKey).toString('base64');
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createSignedDeviceRequest = async (
|
||||||
|
managerArg: PassportManager,
|
||||||
|
signerArg: { sign: (payloadArg: string) => Promise<string> | string },
|
||||||
|
requestArg: {
|
||||||
|
deviceId: string;
|
||||||
|
action: string;
|
||||||
|
signedFields?: string[];
|
||||||
|
}
|
||||||
|
) => {
|
||||||
|
const baseRequest = {
|
||||||
|
deviceId: requestArg.deviceId,
|
||||||
|
timestamp: Date.now(),
|
||||||
|
nonce: plugins.crypto.randomUUID(),
|
||||||
|
};
|
||||||
|
const payload = (managerArg as any).buildDeviceRequestSigningPayload(
|
||||||
|
baseRequest,
|
||||||
|
requestArg.action,
|
||||||
|
requestArg.signedFields || []
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...baseRequest,
|
||||||
|
signatureBase64: await signerArg.sign(payload),
|
||||||
|
signatureFormat: 'raw' as const,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
tap.test('enrolls a passport device from a pairing challenge', async () => {
|
||||||
|
const { manager, passportDevices, passportChallenges, activityLogCalls, restore } =
|
||||||
|
createTestPassportManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enrollment = await manager.createEnrollmentChallengeForUser('user-1', {
|
||||||
|
deviceLabel: 'Phil iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: true,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signer = await createRawPassportSigner();
|
||||||
|
const signatureBase64 = await signer.sign(enrollment.signingPayload);
|
||||||
|
|
||||||
|
const passportDevice = await manager.completeEnrollment({
|
||||||
|
pairingToken: enrollment.pairingToken,
|
||||||
|
deviceLabel: 'Phil iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||||
|
signatureBase64,
|
||||||
|
signatureFormat: 'raw',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: true,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
appVersion: '1.0.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(passportDevice.data.userId).toEqual('user-1');
|
||||||
|
expect(passportDevice.data.label).toEqual('Phil iPhone');
|
||||||
|
expect(passportDevices.size).toEqual(1);
|
||||||
|
expect(passportChallenges.size).toEqual(1);
|
||||||
|
expect(Array.from(passportChallenges.values())[0].data.status).toEqual('approved');
|
||||||
|
expect(activityLogCalls[0].action).toEqual('passport_device_enrolled');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('creates and approves a passport challenge with DER signatures and evidence', async () => {
|
||||||
|
const { manager, activityLogCalls, deliveredHintIds, restore } = createTestPassportManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enrollment = await manager.createEnrollmentChallengeForUser('user-2', {
|
||||||
|
deviceLabel: 'Office iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: true,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signer = createDerPassportSigner();
|
||||||
|
const passportDevice = await manager.completeEnrollment({
|
||||||
|
pairingToken: enrollment.pairingToken,
|
||||||
|
deviceLabel: 'Office iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||||
|
signatureBase64: signer.sign(enrollment.signingPayload),
|
||||||
|
signatureFormat: 'der',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: true,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const challengeResult = await manager.createPassportChallengeForUser('user-2', {
|
||||||
|
type: 'physical_access',
|
||||||
|
preferredDeviceId: passportDevice.id,
|
||||||
|
audience: 'hq-door-a',
|
||||||
|
notificationTitle: 'Office entry request',
|
||||||
|
requireLocation: true,
|
||||||
|
requireNfc: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deliveredHintIds).toHaveLength(1);
|
||||||
|
expect(challengeResult.challenge.data.notification?.status).toEqual('sent');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
manager.approvePassportChallenge({
|
||||||
|
challengeId: challengeResult.challenge.id,
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
signatureBase64: signer.sign(challengeResult.signingPayload),
|
||||||
|
signatureFormat: 'der',
|
||||||
|
})
|
||||||
|
).rejects.toThrow();
|
||||||
|
|
||||||
|
const approvedChallenge = await manager.approvePassportChallenge({
|
||||||
|
challengeId: challengeResult.challenge.id,
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
signatureBase64: signer.sign(challengeResult.signingPayload),
|
||||||
|
signatureFormat: 'der',
|
||||||
|
location: {
|
||||||
|
latitude: 53.0793,
|
||||||
|
longitude: 8.8017,
|
||||||
|
accuracyMeters: 12,
|
||||||
|
capturedAt: Date.now(),
|
||||||
|
},
|
||||||
|
nfc: {
|
||||||
|
readerId: 'door-reader-a',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(approvedChallenge.data.status).toEqual('approved');
|
||||||
|
expect(approvedChallenge.data.evidence?.signatureFormat).toEqual('der');
|
||||||
|
expect(approvedChallenge.data.evidence?.location?.accuracyMeters).toEqual(12);
|
||||||
|
expect(approvedChallenge.data.evidence?.nfc?.readerId).toEqual('door-reader-a');
|
||||||
|
expect(activityLogCalls.at(-1)?.action).toEqual('passport_challenge_approved');
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('registers push tokens and loads pending challenges through signed device requests', async () => {
|
||||||
|
const { manager, passportNonces, restore } = createTestPassportManager();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const enrollment = await manager.createEnrollmentChallengeForUser('user-3', {
|
||||||
|
deviceLabel: 'Work iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: false,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const signer = await createRawPassportSigner();
|
||||||
|
const passportDevice = await manager.completeEnrollment({
|
||||||
|
pairingToken: enrollment.pairingToken,
|
||||||
|
deviceLabel: 'Work iPhone',
|
||||||
|
platform: 'ios',
|
||||||
|
publicKeyX963Base64: signer.publicKeyX963Base64,
|
||||||
|
signatureBase64: await signer.sign(enrollment.signingPayload),
|
||||||
|
signatureFormat: 'raw',
|
||||||
|
capabilities: {
|
||||||
|
gps: true,
|
||||||
|
nfc: false,
|
||||||
|
push: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const pushRequest = await createSignedDeviceRequest(manager, signer, {
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
action: 'registerPassportPushToken',
|
||||||
|
signedFields: [
|
||||||
|
'provider=apns',
|
||||||
|
'token=device-token-1',
|
||||||
|
'topic=global.idp.swiftapp',
|
||||||
|
'environment=development',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const registeredPassportDevice = await (manager as any).authenticatePassportDeviceRequest(
|
||||||
|
{
|
||||||
|
...pushRequest,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: 'registerPassportPushToken',
|
||||||
|
signedFields: [
|
||||||
|
'provider=apns',
|
||||||
|
'token=device-token-1',
|
||||||
|
'topic=global.idp.swiftapp',
|
||||||
|
'environment=development',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
registeredPassportDevice.data.pushRegistration = {
|
||||||
|
provider: 'apns',
|
||||||
|
token: 'device-token-1',
|
||||||
|
topic: 'global.idp.swiftapp',
|
||||||
|
environment: 'development',
|
||||||
|
registeredAt: Date.now(),
|
||||||
|
};
|
||||||
|
await registeredPassportDevice.save();
|
||||||
|
|
||||||
|
const challengeResult = await manager.createPassportChallengeForUser('user-3', {
|
||||||
|
type: 'authentication',
|
||||||
|
preferredDeviceId: passportDevice.id,
|
||||||
|
audience: 'office-saas',
|
||||||
|
notificationTitle: 'Office sign-in verification',
|
||||||
|
});
|
||||||
|
|
||||||
|
const listRequest = await createSignedDeviceRequest(manager, signer, {
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
action: 'listPendingPassportChallenges',
|
||||||
|
});
|
||||||
|
|
||||||
|
const authenticatedDevice = await (manager as any).authenticatePassportDeviceRequest(listRequest, {
|
||||||
|
action: 'listPendingPassportChallenges',
|
||||||
|
});
|
||||||
|
const pendingChallenges = await manager.listPendingChallengesForDevice(authenticatedDevice.id);
|
||||||
|
expect(pendingChallenges).toHaveLength(1);
|
||||||
|
expect(pendingChallenges[0].id).toEqual(challengeResult.challenge.id);
|
||||||
|
|
||||||
|
const hintId = challengeResult.challenge.data.notification!.hintId;
|
||||||
|
const getRequest = await createSignedDeviceRequest(manager, signer, {
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
action: 'getPassportChallengeByHint',
|
||||||
|
signedFields: [`hint_id=${hintId}`],
|
||||||
|
});
|
||||||
|
const hintChallenge = await manager.getPassportChallengeByHint(
|
||||||
|
(
|
||||||
|
await (manager as any).authenticatePassportDeviceRequest(getRequest, {
|
||||||
|
action: 'getPassportChallengeByHint',
|
||||||
|
signedFields: [`hint_id=${hintId}`],
|
||||||
|
})
|
||||||
|
).id,
|
||||||
|
hintId
|
||||||
|
);
|
||||||
|
expect(hintChallenge?.id).toEqual(challengeResult.challenge.id);
|
||||||
|
|
||||||
|
const seenRequest = await createSignedDeviceRequest(manager, signer, {
|
||||||
|
deviceId: passportDevice.id,
|
||||||
|
action: 'markPassportChallengeSeen',
|
||||||
|
signedFields: [`hint_id=${hintId}`],
|
||||||
|
});
|
||||||
|
await (manager as any).authenticatePassportDeviceRequest(seenRequest, {
|
||||||
|
action: 'markPassportChallengeSeen',
|
||||||
|
signedFields: [`hint_id=${hintId}`],
|
||||||
|
});
|
||||||
|
const seenChallenge = await manager.markPassportChallengeSeen(passportDevice.id, hintId);
|
||||||
|
expect(seenChallenge.data.notification?.status).toEqual('seen');
|
||||||
|
expect(passportNonces.size).toEqual(4);
|
||||||
|
} finally {
|
||||||
|
restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.17.1',
|
version: '1.21.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import { Reception } from './classes.reception.js';
|
||||||
|
import { AbuseWindow } from './classes.abusewindow.js';
|
||||||
|
|
||||||
|
export interface IAbuseProtectionConfig {
|
||||||
|
maxAttempts: number;
|
||||||
|
windowMillis: number;
|
||||||
|
blockDurationMillis: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AbuseProtectionManager {
|
||||||
|
public receptionRef: Reception;
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CAbuseWindow = plugins.smartdata.setDefaultManagerForDoc(this, AbuseWindow);
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeIdentifier(identifierArg: string) {
|
||||||
|
return identifierArg.trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private hashIdentifier(identifierArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(this.normalizeIdentifier(identifierArg));
|
||||||
|
}
|
||||||
|
|
||||||
|
private createWindowId(actionArg: string, identifierArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(
|
||||||
|
`${actionArg}:${this.hashIdentifier(identifierArg)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getWindow(actionArg: string, identifierArg: string) {
|
||||||
|
return this.CAbuseWindow.getInstance({
|
||||||
|
id: this.createWindowId(actionArg, identifierArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consumeAttempt(
|
||||||
|
actionArg: string,
|
||||||
|
identifierArg: string,
|
||||||
|
configArg: IAbuseProtectionConfig,
|
||||||
|
errorTextArg = 'Too many attempts. Please wait before trying again.'
|
||||||
|
) {
|
||||||
|
const now = Date.now();
|
||||||
|
let abuseWindow = await this.getWindow(actionArg, identifierArg);
|
||||||
|
|
||||||
|
if (!abuseWindow) {
|
||||||
|
abuseWindow = new AbuseWindow();
|
||||||
|
abuseWindow.id = this.createWindowId(actionArg, identifierArg);
|
||||||
|
abuseWindow.data.action = actionArg;
|
||||||
|
abuseWindow.data.identifierHash = this.hashIdentifier(identifierArg);
|
||||||
|
abuseWindow.data.createdAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abuseWindow.isBlocked(now)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(errorTextArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (abuseWindow.data.blockedUntil && abuseWindow.data.blockedUntil <= now) {
|
||||||
|
abuseWindow.data.attemptCount = 0;
|
||||||
|
abuseWindow.data.windowStartedAt = now;
|
||||||
|
abuseWindow.data.blockedUntil = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!abuseWindow.data.windowStartedAt ||
|
||||||
|
abuseWindow.data.windowStartedAt + configArg.windowMillis <= now
|
||||||
|
) {
|
||||||
|
abuseWindow.data.attemptCount = 0;
|
||||||
|
abuseWindow.data.windowStartedAt = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
abuseWindow.data.attemptCount += 1;
|
||||||
|
abuseWindow.data.updatedAt = now;
|
||||||
|
abuseWindow.data.validUntil = now + configArg.windowMillis;
|
||||||
|
|
||||||
|
if (abuseWindow.data.attemptCount > configArg.maxAttempts) {
|
||||||
|
abuseWindow.data.blockedUntil = now + configArg.blockDurationMillis;
|
||||||
|
abuseWindow.data.validUntil = abuseWindow.data.blockedUntil;
|
||||||
|
await abuseWindow.save();
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(errorTextArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
await abuseWindow.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearAttempts(actionArg: string, identifierArg: string) {
|
||||||
|
const abuseWindow = await this.getWindow(actionArg, identifierArg);
|
||||||
|
if (!abuseWindow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await abuseWindow.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { AbuseProtectionManager } from './classes.abuseprotectionmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class AbuseWindow extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
AbuseWindow,
|
||||||
|
plugins.idpInterfaces.data.IAbuseWindow,
|
||||||
|
AbuseProtectionManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAbuseWindow['data'] = {
|
||||||
|
action: '',
|
||||||
|
identifierHash: '',
|
||||||
|
attemptCount: 0,
|
||||||
|
windowStartedAt: 0,
|
||||||
|
blockedUntil: 0,
|
||||||
|
validUntil: 0,
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isBlocked(nowArg = Date.now()) {
|
||||||
|
return this.data.blockedUntil > nowArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isExpired(nowArg = Date.now()) {
|
||||||
|
return this.data.validUntil < nowArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { AlertManager } from './classes.alertmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class Alert extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
Alert,
|
||||||
|
plugins.idpInterfaces.data.IAlert,
|
||||||
|
AlertManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAlert['data'] = {
|
||||||
|
recipientUserId: '',
|
||||||
|
organizationId: undefined,
|
||||||
|
category: 'security',
|
||||||
|
eventType: '',
|
||||||
|
severity: 'medium',
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
actorUserId: undefined,
|
||||||
|
relatedEntityId: undefined,
|
||||||
|
relatedEntityType: undefined,
|
||||||
|
notification: {
|
||||||
|
hintId: '',
|
||||||
|
status: 'pending',
|
||||||
|
attemptCount: 0,
|
||||||
|
createdAt: 0,
|
||||||
|
deliveredAt: null,
|
||||||
|
seenAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
createdAt: 0,
|
||||||
|
seenAt: null,
|
||||||
|
dismissedAt: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,425 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import { Alert } from './classes.alert.js';
|
||||||
|
import { AlertRule } from './classes.alertrule.js';
|
||||||
|
import type { Reception } from './classes.reception.js';
|
||||||
|
|
||||||
|
const severityOrder: Record<plugins.idpInterfaces.data.TAlertSeverity, number> = {
|
||||||
|
low: 1,
|
||||||
|
medium: 2,
|
||||||
|
high: 3,
|
||||||
|
critical: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class AlertManager {
|
||||||
|
public receptionRef: Reception;
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
public CAlert = plugins.smartdata.setDefaultManagerForDoc(this, Alert);
|
||||||
|
public CAlertRule = plugins.smartdata.setDefaultManagerForDoc(this, AlertRule);
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ListPassportAlerts>(
|
||||||
|
'listPassportAlerts',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||||
|
requestArg,
|
||||||
|
{
|
||||||
|
action: 'listPassportAlerts',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const alerts = await this.listAlertsForUser(passportDevice.data.userId);
|
||||||
|
return {
|
||||||
|
alerts: alerts.map((alertArg) => ({ id: alertArg.id, data: alertArg.data })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportAlertByHint>(
|
||||||
|
'getPassportAlertByHint',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||||
|
requestArg,
|
||||||
|
{
|
||||||
|
action: 'getPassportAlertByHint',
|
||||||
|
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
const alert = await this.getAlertByHint(passportDevice.data.userId, requestArg.hintId);
|
||||||
|
return {
|
||||||
|
alert: alert ? { id: alert.id, data: alert.data } : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MarkPassportAlertSeen>(
|
||||||
|
'markPassportAlertSeen',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.receptionRef.passportManager.authenticatePassportDeviceRequest(
|
||||||
|
requestArg,
|
||||||
|
{
|
||||||
|
action: 'markPassportAlertSeen',
|
||||||
|
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await this.markAlertSeen(passportDevice.data.userId, requestArg.hintId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_UpsertAlertRule>(
|
||||||
|
'upsertAlertRule',
|
||||||
|
async (requestArg) => {
|
||||||
|
const actorUserId = await this.verifyAlertRuleAccess(
|
||||||
|
requestArg.jwt,
|
||||||
|
requestArg.scope,
|
||||||
|
requestArg.organizationId
|
||||||
|
);
|
||||||
|
const rule = requestArg.ruleId
|
||||||
|
? await this.CAlertRule.getInstance({ id: requestArg.ruleId })
|
||||||
|
: new AlertRule();
|
||||||
|
if (!rule) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Alert rule not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
rule.id = rule.id || plugins.smartunique.shortId();
|
||||||
|
rule.data = {
|
||||||
|
scope: requestArg.scope,
|
||||||
|
organizationId: requestArg.organizationId,
|
||||||
|
eventType: requestArg.eventType,
|
||||||
|
minimumSeverity: requestArg.minimumSeverity,
|
||||||
|
recipientMode: requestArg.recipientMode,
|
||||||
|
recipientUserIds: requestArg.recipientUserIds || [],
|
||||||
|
push: requestArg.push,
|
||||||
|
enabled: requestArg.enabled,
|
||||||
|
createdByUserId: rule.data?.createdByUserId || actorUserId,
|
||||||
|
createdAt: rule.data?.createdAt || Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
await rule.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
rule: {
|
||||||
|
id: rule.id,
|
||||||
|
data: rule.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetAlertRules>(
|
||||||
|
'getAlertRules',
|
||||||
|
async (requestArg) => {
|
||||||
|
await this.verifyAlertRuleAccess(requestArg.jwt, requestArg.scope || 'global', requestArg.organizationId);
|
||||||
|
const rules = await this.CAlertRule.getInstances({});
|
||||||
|
return {
|
||||||
|
rules: rules
|
||||||
|
.filter((ruleArg) => {
|
||||||
|
if (requestArg.scope && ruleArg.data.scope !== requestArg.scope) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (requestArg.organizationId && ruleArg.data.organizationId !== requestArg.organizationId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((ruleArg) => ({ id: ruleArg.id, data: ruleArg.data })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_DeleteAlertRule>(
|
||||||
|
'deleteAlertRule',
|
||||||
|
async (requestArg) => {
|
||||||
|
const rule = await this.CAlertRule.getInstance({ id: requestArg.ruleId });
|
||||||
|
if (!rule) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Alert rule not found');
|
||||||
|
}
|
||||||
|
await this.verifyAlertRuleAccess(requestArg.jwt, rule.data.scope, rule.data.organizationId);
|
||||||
|
await rule.delete();
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verifyAlertRuleAccess(
|
||||||
|
jwtArg: string,
|
||||||
|
scopeArg: plugins.idpInterfaces.data.TAlertRuleScope,
|
||||||
|
organizationIdArg?: string
|
||||||
|
) {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scopeArg === 'global') {
|
||||||
|
const user = await this.receptionRef.userManager.CUser.getInstance({ id: jwt.data.userId });
|
||||||
|
if (!user?.data?.isGlobalAdmin) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Global admin privileges required');
|
||||||
|
}
|
||||||
|
return jwt.data.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!organizationIdArg) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('organizationId is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = await this.receptionRef.roleManager.CRole.getInstance({
|
||||||
|
data: {
|
||||||
|
userId: jwt.data.userId,
|
||||||
|
organizationId: organizationIdArg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!role || !role.data.roles.some((roleArg) => ['owner', 'admin'].includes(roleArg))) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Organization admin privileges required');
|
||||||
|
}
|
||||||
|
return jwt.data.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveGlobalAdminRecipients() {
|
||||||
|
const users = await this.receptionRef.userManager.CUser.getInstances({});
|
||||||
|
return users.filter((userArg) => !!userArg.data.isGlobalAdmin);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveOrganizationAdminRecipients(organizationIdArg: string) {
|
||||||
|
const roles = await this.receptionRef.roleManager.getAllRolesForOrg(organizationIdArg);
|
||||||
|
const adminUserIds = [...new Set(
|
||||||
|
roles
|
||||||
|
.filter((roleArg) => roleArg.data.roles.some((roleNameArg) => ['owner', 'admin'].includes(roleNameArg)))
|
||||||
|
.map((roleArg) => roleArg.data.userId)
|
||||||
|
)];
|
||||||
|
const users = await Promise.all(
|
||||||
|
adminUserIds.map((userIdArg) => this.receptionRef.userManager.CUser.getInstance({ id: userIdArg }))
|
||||||
|
);
|
||||||
|
return users.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveRuleRecipients(ruleArg: AlertRule) {
|
||||||
|
switch (ruleArg.data.recipientMode) {
|
||||||
|
case 'global_admins':
|
||||||
|
return this.resolveGlobalAdminRecipients();
|
||||||
|
case 'org_admins':
|
||||||
|
if (!ruleArg.data.organizationId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return this.resolveOrganizationAdminRecipients(ruleArg.data.organizationId);
|
||||||
|
case 'specific_users':
|
||||||
|
if (!ruleArg.data.recipientUserIds?.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const users = await Promise.all(
|
||||||
|
ruleArg.data.recipientUserIds.map((userIdArg) =>
|
||||||
|
this.receptionRef.userManager.CUser.getInstance({ id: userIdArg })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return users.filter(Boolean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getMatchingRules(optionsArg: {
|
||||||
|
eventType: string;
|
||||||
|
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||||
|
organizationId?: string;
|
||||||
|
}) {
|
||||||
|
const rules = await this.CAlertRule.getInstances({});
|
||||||
|
const matchingRules = rules.filter((ruleArg) => {
|
||||||
|
if (!ruleArg.data.enabled) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ruleArg.data.eventType !== optionsArg.eventType) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (ruleArg.data.scope === 'organization' && ruleArg.data.organizationId !== optionsArg.organizationId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return severityOrder[optionsArg.severity] >= severityOrder[ruleArg.data.minimumSeverity];
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchingRules.length > 0) {
|
||||||
|
return matchingRules;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionsArg.eventType === 'global_admin_access') {
|
||||||
|
const fallbackRule = new AlertRule();
|
||||||
|
fallbackRule.id = 'builtin-global-admin-access';
|
||||||
|
fallbackRule.data = {
|
||||||
|
scope: 'global',
|
||||||
|
organizationId: undefined,
|
||||||
|
eventType: 'global_admin_access',
|
||||||
|
minimumSeverity: 'high',
|
||||||
|
recipientMode: 'global_admins',
|
||||||
|
recipientUserIds: [],
|
||||||
|
push: true,
|
||||||
|
enabled: true,
|
||||||
|
createdByUserId: 'system',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
return [fallbackRule];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionsArg.eventType === 'global_app_credentials_regenerated') {
|
||||||
|
const fallbackRule = new AlertRule();
|
||||||
|
fallbackRule.id = 'builtin-global-app-credentials-regenerated';
|
||||||
|
fallbackRule.data = {
|
||||||
|
scope: 'global',
|
||||||
|
organizationId: undefined,
|
||||||
|
eventType: 'global_app_credentials_regenerated',
|
||||||
|
minimumSeverity: 'critical',
|
||||||
|
recipientMode: 'global_admins',
|
||||||
|
recipientUserIds: [],
|
||||||
|
push: true,
|
||||||
|
enabled: true,
|
||||||
|
createdByUserId: 'system',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
return [fallbackRule];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createAlertsForEvent(optionsArg: {
|
||||||
|
category: plugins.idpInterfaces.data.TAlertCategory;
|
||||||
|
eventType: string;
|
||||||
|
severity: plugins.idpInterfaces.data.TAlertSeverity;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
actorUserId?: string;
|
||||||
|
organizationId?: string;
|
||||||
|
relatedEntityId?: string;
|
||||||
|
relatedEntityType?: string;
|
||||||
|
}) {
|
||||||
|
const matchingRules = await this.getMatchingRules(optionsArg);
|
||||||
|
if (matchingRules.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientIds = new Set<string>();
|
||||||
|
for (const rule of matchingRules) {
|
||||||
|
const recipients = await this.resolveRuleRecipients(rule);
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
recipientIds.add(recipient.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAlerts: Alert[] = [];
|
||||||
|
for (const recipientUserId of recipientIds) {
|
||||||
|
const alert = new Alert();
|
||||||
|
alert.id = plugins.smartunique.shortId();
|
||||||
|
alert.data = {
|
||||||
|
recipientUserId,
|
||||||
|
organizationId: optionsArg.organizationId,
|
||||||
|
category: optionsArg.category,
|
||||||
|
eventType: optionsArg.eventType,
|
||||||
|
severity: optionsArg.severity,
|
||||||
|
title: optionsArg.title,
|
||||||
|
body: optionsArg.body,
|
||||||
|
actorUserId: optionsArg.actorUserId,
|
||||||
|
relatedEntityId: optionsArg.relatedEntityId,
|
||||||
|
relatedEntityType: optionsArg.relatedEntityType,
|
||||||
|
notification: {
|
||||||
|
hintId: plugins.crypto.randomUUID(),
|
||||||
|
status: 'pending',
|
||||||
|
attemptCount: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
deliveredAt: null,
|
||||||
|
seenAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
seenAt: null,
|
||||||
|
dismissedAt: null,
|
||||||
|
};
|
||||||
|
await alert.save();
|
||||||
|
createdAlerts.push(alert);
|
||||||
|
|
||||||
|
const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(recipientUserId);
|
||||||
|
let delivered = false;
|
||||||
|
for (const device of devices) {
|
||||||
|
const result = await this.receptionRef.passportPushManager.deliverAlertHint(device, alert);
|
||||||
|
delivered = delivered || result;
|
||||||
|
}
|
||||||
|
if (!delivered && devices.length === 0) {
|
||||||
|
alert.data.notification = {
|
||||||
|
...alert.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: alert.data.notification.attemptCount + 1,
|
||||||
|
lastError: 'Recipient has no active passport device',
|
||||||
|
};
|
||||||
|
await alert.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return createdAlerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listAlertsForUser(userIdArg: string) {
|
||||||
|
const alerts = await this.CAlert.getInstances({
|
||||||
|
'data.recipientUserId': userIdArg,
|
||||||
|
});
|
||||||
|
return alerts.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAlertByHint(userIdArg: string, hintIdArg: string) {
|
||||||
|
return this.CAlert.getInstance({
|
||||||
|
'data.recipientUserId': userIdArg,
|
||||||
|
'data.notification.hintId': hintIdArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markAlertSeen(userIdArg: string, hintIdArg: string) {
|
||||||
|
const alert = await this.getAlertByHint(userIdArg, hintIdArg);
|
||||||
|
if (!alert) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Alert not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert.data.seenAt = Date.now();
|
||||||
|
alert.data.notification = {
|
||||||
|
...alert.data.notification,
|
||||||
|
status: 'seen',
|
||||||
|
seenAt: Date.now(),
|
||||||
|
};
|
||||||
|
await alert.save();
|
||||||
|
return alert;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reDeliverPendingAlerts() {
|
||||||
|
const alerts = await this.CAlert.getInstances({});
|
||||||
|
for (const alert of alerts) {
|
||||||
|
if (alert.data.notification.status === 'sent' || alert.data.notification.status === 'seen') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const devices = await this.receptionRef.passportManager.getPassportDevicesForUser(
|
||||||
|
alert.data.recipientUserId
|
||||||
|
);
|
||||||
|
for (const device of devices) {
|
||||||
|
await this.receptionRef.passportPushManager.deliverAlertHint(device, alert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { AlertManager } from './classes.alertmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class AlertRule extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
AlertRule,
|
||||||
|
plugins.idpInterfaces.data.IAlertRule,
|
||||||
|
AlertManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAlertRule['data'] = {
|
||||||
|
scope: 'global',
|
||||||
|
organizationId: undefined,
|
||||||
|
eventType: '',
|
||||||
|
minimumSeverity: 'medium',
|
||||||
|
recipientMode: 'global_admins',
|
||||||
|
recipientUserIds: [],
|
||||||
|
push: true,
|
||||||
|
enabled: true,
|
||||||
|
createdByUserId: '',
|
||||||
|
createdAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -59,7 +59,20 @@ export class AppManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetGlobalAppStats>(
|
||||||
'getGlobalAppStats',
|
'getGlobalAppStats',
|
||||||
async (requestArg) => {
|
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)
|
// Get all global apps (including inactive)
|
||||||
const globalApps = await this.CApp.getInstances({
|
const globalApps = await this.CApp.getInstances({
|
||||||
@@ -198,7 +211,7 @@ export class AppManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegenerateAppCredentials>(
|
||||||
'regenerateAppCredentials',
|
'regenerateAppCredentials',
|
||||||
async (requestArg) => {
|
async (requestArg) => {
|
||||||
await this.verifyGlobalAdmin(requestArg.jwt);
|
const jwtData = await this.verifyGlobalAdmin(requestArg.jwt);
|
||||||
|
|
||||||
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
const app = await this.CApp.getInstance({ id: requestArg.appId });
|
||||||
if (!app) {
|
if (!app) {
|
||||||
@@ -214,6 +227,17 @@ export class AppManager {
|
|||||||
app.data.oauthCredentials.clientSecretHash = clientSecretHash;
|
app.data.oauthCredentials.clientSecretHash = clientSecretHash;
|
||||||
await app.save();
|
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 {
|
return {
|
||||||
clientId,
|
clientId,
|
||||||
clientSecret, // Only shown once
|
clientSecret, // Only shown once
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { LoginSessionManager } from './classes.loginsessionmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class EmailActionToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
EmailActionToken,
|
||||||
|
plugins.idpInterfaces.data.IEmailActionToken,
|
||||||
|
LoginSessionManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static createOpaqueToken(actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction) {
|
||||||
|
return `${actionArg}_${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IEmailActionToken['data'] = {
|
||||||
|
email: '',
|
||||||
|
action: 'emailLogin',
|
||||||
|
tokenHash: '',
|
||||||
|
validUntil: 0,
|
||||||
|
createdAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.validUntil < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === EmailActionToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consume(tokenArg: string) {
|
||||||
|
if (this.isExpired() || !this.matchesToken(tokenArg)) {
|
||||||
|
if (this.isExpired()) {
|
||||||
|
await this.delete();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.delete();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -34,6 +34,96 @@ export class ReceptionHousekeeping {
|
|||||||
'2 * * * * *'
|
'2 * * * * *'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredEmailActionTokens',
|
||||||
|
taskFunction: async () => {
|
||||||
|
const expiredEmailActionTokens =
|
||||||
|
await this.receptionRef.loginSessionManager.CEmailActionToken.getInstances({
|
||||||
|
data: {
|
||||||
|
validUntil: {
|
||||||
|
$lt: Date.now(),
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const emailActionToken of expiredEmailActionTokens) {
|
||||||
|
await emailActionToken.delete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'2 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredRegistrationSessions',
|
||||||
|
taskFunction: async () => {
|
||||||
|
const expiredRegistrationSessions =
|
||||||
|
await this.receptionRef.registrationSessionManager.CRegistrationSession.getInstances({
|
||||||
|
data: {
|
||||||
|
validUntil: {
|
||||||
|
$lt: Date.now(),
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const registrationSession of expiredRegistrationSessions) {
|
||||||
|
await registrationSession.delete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'2 * * * * *'
|
||||||
|
);
|
||||||
|
|
||||||
|
this.taskmanager.addAndScheduleTask(
|
||||||
|
new plugins.taskbuffer.Task({
|
||||||
|
name: 'expiredAbuseWindows',
|
||||||
|
taskFunction: async () => {
|
||||||
|
const expiredAbuseWindows =
|
||||||
|
await this.receptionRef.abuseProtectionManager.CAbuseWindow.getInstances({
|
||||||
|
data: {
|
||||||
|
validUntil: {
|
||||||
|
$lt: Date.now(),
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const abuseWindow of expiredAbuseWindows) {
|
||||||
|
await abuseWindow.delete();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
'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();
|
this.taskmanager.start();
|
||||||
logger.log('info', 'housekeeping started');
|
logger.log('info', 'housekeeping started');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,49 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
import { EmailActionToken } from './classes.emailactiontoken.js';
|
||||||
import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js';
|
import { LoginSession, type TRefreshTokenValidationResult } from './classes.loginsession.js';
|
||||||
import { Reception } from './classes.reception.js';
|
import { Reception } from './classes.reception.js';
|
||||||
import { logger } from './logging.js';
|
import { logger } from './logging.js';
|
||||||
|
|
||||||
export class LoginSessionManager {
|
export class LoginSessionManager {
|
||||||
|
private readonly abuseProtectionConfigs = {
|
||||||
|
passwordLogin: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||||
|
},
|
||||||
|
emailLoginRequest: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
},
|
||||||
|
emailLoginToken: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||||
|
},
|
||||||
|
passwordResetRequest: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
},
|
||||||
|
passwordResetCompletion: {
|
||||||
|
maxAttempts: 5,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 30 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// refs
|
// refs
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
public get db() {
|
public get db() {
|
||||||
return this.receptionRef.db.smartdataDb;
|
return this.receptionRef.db.smartdataDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public CEmailActionToken = plugins.smartdata.setDefaultManagerForDoc(this, EmailActionToken);
|
||||||
public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession);
|
public CLoginSession = plugins.smartdata.setDefaultManagerForDoc(this, LoginSession);
|
||||||
|
|
||||||
public loginSessions = new plugins.lik.ObjectMap<LoginSession>();
|
|
||||||
|
|
||||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
public emailTokenMap = new plugins.lik.ObjectMap<{
|
|
||||||
email: string;
|
|
||||||
token: string;
|
|
||||||
action: 'emailLogin' | 'passwordReset';
|
|
||||||
}>();
|
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
@@ -29,6 +51,14 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||||
'loginWithEmailOrUsernameAndPassword',
|
'loginWithEmailOrUsernameAndPassword',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
|
const loginIdentifier = requestData.username;
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'passwordLogin',
|
||||||
|
loginIdentifier,
|
||||||
|
this.abuseProtectionConfigs.passwordLogin,
|
||||||
|
'Too many login attempts. Please wait before trying again.'
|
||||||
|
);
|
||||||
|
|
||||||
let user = await this.receptionRef.userManager.CUser.getInstance({
|
let user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
username: requestData.username,
|
username: requestData.username,
|
||||||
@@ -55,12 +85,16 @@ export class LoginSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
||||||
this.loginSessions.add(loginSession);
|
|
||||||
const refreshToken = await loginSession.getRefreshToken();
|
const refreshToken = await loginSession.getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||||
|
'passwordLogin',
|
||||||
|
loginIdentifier
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
refreshToken,
|
refreshToken,
|
||||||
twoFaNeeded: false,
|
twoFaNeeded: false,
|
||||||
@@ -76,6 +110,12 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmail>(
|
||||||
'loginWithEmail',
|
'loginWithEmail',
|
||||||
async (requestDataArg) => {
|
async (requestDataArg) => {
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'emailLoginRequest',
|
||||||
|
requestDataArg.email,
|
||||||
|
this.abuseProtectionConfigs.emailLoginRequest,
|
||||||
|
'Too many magic link requests. Please wait before trying again.'
|
||||||
|
);
|
||||||
logger.log('info', `loginWithEmail requested for: ${requestDataArg.email}`);
|
logger.log('info', `loginWithEmail requested for: ${requestDataArg.email}`);
|
||||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
@@ -84,33 +124,21 @@ export class LoginSessionManager {
|
|||||||
});
|
});
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`);
|
logger.log('info', `loginWithEmail found user: ${existingUser.data.email}`);
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
const loginEmailToken = await this.createEmailActionToken(
|
||||||
(itemArg) => itemArg.email === existingUser.data.email
|
existingUser.data.email,
|
||||||
|
'emailLogin'
|
||||||
);
|
);
|
||||||
const loginEmailToken = plugins.smartunique.uuid4();
|
|
||||||
this.emailTokenMap.add({
|
|
||||||
email: existingUser.data.email,
|
|
||||||
token: loginEmailToken,
|
|
||||||
action: 'emailLogin',
|
|
||||||
});
|
|
||||||
// lets make sure its only valid for 10 minutes
|
|
||||||
plugins.smartdelay.delayFor(600000, null, true).then(() => {
|
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
|
||||||
(itemArg) => itemArg.token === loginEmailToken
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.receptionRef.receptionMailer.sendLoginWithEMailMail(existingUser, loginEmailToken);
|
this.receptionRef.receptionMailer.sendLoginWithEMailMail(existingUser, loginEmailToken);
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
testOnlyToken: process.env.TEST_MODE ? loginEmailToken : undefined,
|
||||||
|
};
|
||||||
} else {
|
} else {
|
||||||
logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`);
|
logger.log('info', `loginWithEmail did not find user: ${requestDataArg.email}`);
|
||||||
}
|
}
|
||||||
const testOnlyToken =
|
|
||||||
process.env.TEST_MODE && existingUser
|
|
||||||
? this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
|
|
||||||
?.token
|
|
||||||
: undefined;
|
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
testOnlyToken,
|
testOnlyToken: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -120,9 +148,17 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_LoginWithEmailAfterEmailTokenAquired>(
|
||||||
'loginWithEmailAfterEmailTokenAquired',
|
'loginWithEmailAfterEmailTokenAquired',
|
||||||
async (requestArg) => {
|
async (requestArg) => {
|
||||||
const tokenObject = this.emailTokenMap.findSync((itemArg) => {
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
return itemArg.email === requestArg.email && itemArg.token === requestArg.token;
|
'emailLoginToken',
|
||||||
});
|
requestArg.email,
|
||||||
|
this.abuseProtectionConfigs.emailLoginToken,
|
||||||
|
'Too many magic link attempts. Please wait before trying again.'
|
||||||
|
);
|
||||||
|
const tokenObject = await this.consumeEmailActionToken(
|
||||||
|
requestArg.email,
|
||||||
|
requestArg.token,
|
||||||
|
'emailLogin'
|
||||||
|
);
|
||||||
if (tokenObject) {
|
if (tokenObject) {
|
||||||
const user = await this.receptionRef.userManager.CUser.getInstance({
|
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
@@ -133,11 +169,14 @@ export class LoginSessionManager {
|
|||||||
throw new plugins.typedrequest.TypedResponseError('User not found');
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
}
|
}
|
||||||
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
const loginSession = await LoginSession.createLoginSessionForUser(user);
|
||||||
this.loginSessions.add(loginSession);
|
|
||||||
const refreshToken = await loginSession.getRefreshToken();
|
const refreshToken = await loginSession.getRefreshToken();
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
throw new plugins.typedrequest.TypedResponseError('Could not create login session');
|
||||||
}
|
}
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||||
|
'emailLoginToken',
|
||||||
|
requestArg.email
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
refreshToken,
|
refreshToken,
|
||||||
};
|
};
|
||||||
@@ -206,6 +245,12 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
||||||
'resetPassword',
|
'resetPassword',
|
||||||
async (requestDataArg) => {
|
async (requestDataArg) => {
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'passwordResetRequest',
|
||||||
|
requestDataArg.email,
|
||||||
|
this.abuseProtectionConfigs.passwordResetRequest,
|
||||||
|
'Too many password reset requests. Please wait before trying again.'
|
||||||
|
);
|
||||||
const emailOfPasswordToReset = requestDataArg.email;
|
const emailOfPasswordToReset = requestDataArg.email;
|
||||||
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
const existingUser = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
data: {
|
data: {
|
||||||
@@ -213,23 +258,13 @@ export class LoginSessionManager {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
const resetToken = await this.createEmailActionToken(
|
||||||
(itemArg) => itemArg.email === existingUser.data.email
|
existingUser.data.email,
|
||||||
|
'passwordReset'
|
||||||
);
|
);
|
||||||
this.emailTokenMap.add({
|
|
||||||
email: existingUser.data.email,
|
|
||||||
token: plugins.smartunique.shortId(),
|
|
||||||
action: 'passwordReset',
|
|
||||||
});
|
|
||||||
plugins.smartdelay.delayFor(600000, null, true).then(() => {
|
|
||||||
this.emailTokenMap.findOneAndRemoveSync(
|
|
||||||
(itemArg) => itemArg.email === existingUser.data.email
|
|
||||||
);
|
|
||||||
});
|
|
||||||
this.receptionRef.receptionMailer.sendPasswordResetMail(
|
this.receptionRef.receptionMailer.sendPasswordResetMail(
|
||||||
existingUser,
|
existingUser,
|
||||||
this.emailTokenMap.findSync((itemArg) => itemArg.email === existingUser.data.email)
|
resetToken
|
||||||
.token
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// note: we always return ok here, since we don't want to give any indication as to wether a user is already registered with us.
|
// note: we always return ok here, since we don't want to give any indication as to wether a user is already registered with us.
|
||||||
@@ -244,6 +279,53 @@ export class LoginSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetNewPassword>(
|
||||||
'setNewPassword',
|
'setNewPassword',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'passwordResetCompletion',
|
||||||
|
requestData.email,
|
||||||
|
this.abuseProtectionConfigs.passwordResetCompletion,
|
||||||
|
'Too many password change attempts. Please wait before trying again.'
|
||||||
|
);
|
||||||
|
const user = await this.receptionRef.userManager.CUser.getInstance({
|
||||||
|
data: {
|
||||||
|
email: requestData.email,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestData.tokenArg) {
|
||||||
|
const tokenObject = await this.consumeEmailActionToken(
|
||||||
|
requestData.email,
|
||||||
|
requestData.tokenArg,
|
||||||
|
'passwordReset'
|
||||||
|
);
|
||||||
|
if (!tokenObject) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Password reset token invalid');
|
||||||
|
}
|
||||||
|
} else if (requestData.oldPassword) {
|
||||||
|
const passwordOk = await this.receptionRef.userManager.CUser.verifyPassword(
|
||||||
|
requestData.oldPassword,
|
||||||
|
user.data.passwordHash
|
||||||
|
);
|
||||||
|
if (!passwordOk) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Old password invalid');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Either a reset token or the old password is required'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.data.passwordHash = await this.receptionRef.userManager.CUser.hashPassword(
|
||||||
|
requestData.newPassword
|
||||||
|
);
|
||||||
|
await user.save();
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts(
|
||||||
|
'passwordResetCompletion',
|
||||||
|
requestData.email
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
status: 'ok',
|
status: 'ok',
|
||||||
};
|
};
|
||||||
@@ -393,4 +475,50 @@ export class LoginSessionManager {
|
|||||||
const isValid = await loginSession.validateTransferToken(transferTokenArg);
|
const isValid = await loginSession.validateTransferToken(transferTokenArg);
|
||||||
return isValid ? loginSession : null;
|
return isValid ? loginSession : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async createEmailActionToken(
|
||||||
|
emailArg: string,
|
||||||
|
actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction
|
||||||
|
) {
|
||||||
|
const existingTokens = await this.CEmailActionToken.getInstances({
|
||||||
|
'data.email': emailArg,
|
||||||
|
'data.action': actionArg,
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const existingToken of existingTokens) {
|
||||||
|
await existingToken.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const plainToken = EmailActionToken.createOpaqueToken(actionArg);
|
||||||
|
const emailActionToken = new EmailActionToken();
|
||||||
|
emailActionToken.id = plugins.smartunique.shortId();
|
||||||
|
emailActionToken.data = {
|
||||||
|
email: emailArg,
|
||||||
|
action: actionArg,
|
||||||
|
tokenHash: EmailActionToken.hashToken(plainToken),
|
||||||
|
validUntil: Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
};
|
||||||
|
await emailActionToken.save();
|
||||||
|
return plainToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async consumeEmailActionToken(
|
||||||
|
emailArg: string,
|
||||||
|
tokenArg: string,
|
||||||
|
actionArg: plugins.idpInterfaces.data.TEmailActionTokenAction
|
||||||
|
) {
|
||||||
|
const emailActionToken = await this.CEmailActionToken.getInstance({
|
||||||
|
'data.email': emailArg,
|
||||||
|
'data.action': actionArg,
|
||||||
|
'data.tokenHash': EmailActionToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!emailActionToken) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const consumed = await emailActionToken.consume(tokenArg);
|
||||||
|
return consumed ? emailActionToken : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcAccessToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcAccessToken,
|
||||||
|
plugins.idpInterfaces.data.IOidcAccessToken,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IOidcAccessToken['data'] = {
|
||||||
|
tokenHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === OidcAccessToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcAuthorizationCode extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcAuthorizationCode,
|
||||||
|
plugins.idpInterfaces.data.IAuthorizationCode,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashCode(codeArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(codeArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IAuthorizationCode['data'] = {
|
||||||
|
codeHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
redirectUri: '',
|
||||||
|
codeChallenge: undefined,
|
||||||
|
codeChallengeMethod: undefined,
|
||||||
|
nonce: undefined,
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
used: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesCode(codeArg: string) {
|
||||||
|
return this.data.codeHash === OidcAuthorizationCode.hashCode(codeArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markUsed() {
|
||||||
|
this.data.used = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,85 @@
|
|||||||
import * as plugins from '../plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import type { Reception } from './classes.reception.js';
|
import type { Reception } from './classes.reception.js';
|
||||||
import type { App } from './classes.app.js';
|
import type { App } from './classes.app.js';
|
||||||
|
import { OidcAccessToken } from './classes.oidcaccesstoken.js';
|
||||||
|
import { OidcAuthorizationCode } from './classes.oidcauthorizationcode.js';
|
||||||
|
import { OidcRefreshToken } from './classes.oidcrefreshtoken.js';
|
||||||
|
import { OidcUserConsent } from './classes.oidcuserconsent.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OidcManager handles OpenID Connect (OIDC) server functionality
|
* OidcManager handles OpenID Connect (OIDC) server functionality
|
||||||
* for third-party client authentication.
|
* for third-party client authentication.
|
||||||
*/
|
*/
|
||||||
export class OidcManager {
|
export class OidcManager {
|
||||||
|
private readonly abuseProtectionConfig = {
|
||||||
|
oidcTokenExchange: {
|
||||||
|
maxAttempts: 10,
|
||||||
|
windowMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 }),
|
||||||
|
blockDurationMillis: plugins.smarttime.getMilliSecondsFromUnits({ minutes: 15 }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
public get db() {
|
public get db() {
|
||||||
return this.receptionRef.db.smartdataDb;
|
return this.receptionRef.db.smartdataDb;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In-memory store for authorization codes (short-lived, 10 min TTL)
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
private authorizationCodes = new Map<string, plugins.idpInterfaces.data.IAuthorizationCode>();
|
|
||||||
|
|
||||||
// In-memory store for access tokens (for validation)
|
public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc(
|
||||||
private accessTokens = new Map<string, plugins.idpInterfaces.data.IOidcAccessToken>();
|
this,
|
||||||
|
OidcAuthorizationCode
|
||||||
|
);
|
||||||
|
|
||||||
// In-memory store for refresh tokens
|
public COidcAccessToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcAccessToken);
|
||||||
private refreshTokens = new Map<string, plugins.idpInterfaces.data.IOidcRefreshToken>();
|
|
||||||
|
|
||||||
// In-memory store for user consents (should be persisted later)
|
public COidcRefreshToken = plugins.smartdata.setDefaultManagerForDoc(this, OidcRefreshToken);
|
||||||
private userConsents = new Map<string, plugins.idpInterfaces.data.IUserConsent>();
|
|
||||||
|
public COidcUserConsent = plugins.smartdata.setDefaultManagerForDoc(this, OidcUserConsent);
|
||||||
|
|
||||||
|
private cleanupInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
|
|
||||||
// Start cleanup task for expired codes/tokens
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization>(
|
||||||
|
'prepareOidcAuthorization',
|
||||||
|
async (requestArg) => {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prepareAuthorizationForUser(jwt.data.userId, requestArg);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization>(
|
||||||
|
'completeOidcAuthorization',
|
||||||
|
async (requestArg) => {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(requestArg.jwt);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.completeAuthorizationForUser(jwt.data.userId, requestArg);
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
this.startCleanupTask();
|
this.startCleanupTask();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async stop() {
|
||||||
|
if (this.cleanupInterval) {
|
||||||
|
clearInterval(this.cleanupInterval);
|
||||||
|
this.cleanupInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the OIDC Discovery Document
|
* Get the OIDC Discovery Document
|
||||||
*/
|
*/
|
||||||
@@ -118,6 +167,10 @@ export class OidcManager {
|
|||||||
return this.errorResponse('unsupported_response_type', 'Only code response type is supported');
|
return this.errorResponse('unsupported_response_type', 'Only code response type is supported');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prompt && !this.isSupportedPrompt(prompt)) {
|
||||||
|
return this.errorResponse('invalid_request', 'Unsupported prompt value');
|
||||||
|
}
|
||||||
|
|
||||||
// Validate code challenge method if present
|
// Validate code challenge method if present
|
||||||
if (codeChallenge && codeChallengeMethod !== 'S256') {
|
if (codeChallenge && codeChallengeMethod !== 'S256') {
|
||||||
return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported');
|
return this.errorResponse('invalid_request', 'Only S256 code challenge method is supported');
|
||||||
@@ -159,6 +212,9 @@ export class OidcManager {
|
|||||||
if (nonce) {
|
if (nonce) {
|
||||||
loginUrl.searchParams.set('nonce', nonce);
|
loginUrl.searchParams.set('nonce', nonce);
|
||||||
}
|
}
|
||||||
|
if (prompt) {
|
||||||
|
loginUrl.searchParams.set('prompt', prompt);
|
||||||
|
}
|
||||||
|
|
||||||
return Response.redirect(loginUrl.toString(), 302);
|
return Response.redirect(loginUrl.toString(), 302);
|
||||||
}
|
}
|
||||||
@@ -174,9 +230,11 @@ export class OidcManager {
|
|||||||
codeChallenge?: string,
|
codeChallenge?: string,
|
||||||
nonce?: string
|
nonce?: string
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const code = plugins.smartunique.shortId(32);
|
const code = this.createOpaqueToken();
|
||||||
const authCode: plugins.idpInterfaces.data.IAuthorizationCode = {
|
const authCode = new OidcAuthorizationCode();
|
||||||
code,
|
authCode.id = plugins.smartunique.shortId(12);
|
||||||
|
authCode.data = {
|
||||||
|
codeHash: OidcAuthorizationCode.hashCode(code),
|
||||||
clientId,
|
clientId,
|
||||||
userId,
|
userId,
|
||||||
scopes,
|
scopes,
|
||||||
@@ -184,14 +242,77 @@ export class OidcManager {
|
|||||||
codeChallenge,
|
codeChallenge,
|
||||||
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
codeChallengeMethod: codeChallenge ? 'S256' : undefined,
|
||||||
nonce,
|
nonce,
|
||||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 minutes
|
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||||
|
issuedAt: Date.now(),
|
||||||
used: false,
|
used: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
this.authorizationCodes.set(code, authCode);
|
await authCode.save();
|
||||||
return code;
|
return code;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async prepareAuthorizationForUser(
|
||||||
|
userIdArg: string,
|
||||||
|
requestArg: Omit<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['request'], 'jwt'>
|
||||||
|
): Promise<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response']> {
|
||||||
|
const resolvedRequest = await this.resolveAuthorizationRequest(requestArg);
|
||||||
|
const consentState = await this.evaluateConsentRequirement(
|
||||||
|
userIdArg,
|
||||||
|
resolvedRequest.clientId,
|
||||||
|
resolvedRequest.validScopes,
|
||||||
|
resolvedRequest.prompt
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: consentState.consentRequired ? ('consent_required' as const) : ('ready' as const),
|
||||||
|
clientId: resolvedRequest.clientId,
|
||||||
|
appName: resolvedRequest.app.data.name,
|
||||||
|
appUrl: resolvedRequest.app.data.appUrl,
|
||||||
|
logoUrl: resolvedRequest.app.data.logoUrl,
|
||||||
|
requestedScopes: resolvedRequest.validScopes,
|
||||||
|
grantedScopes: consentState.grantedScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async completeAuthorizationForUser(
|
||||||
|
userIdArg: string,
|
||||||
|
requestArg: Omit<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'], 'jwt'>
|
||||||
|
) {
|
||||||
|
const resolvedRequest = await this.resolveAuthorizationRequest(requestArg);
|
||||||
|
const consentState = await this.evaluateConsentRequirement(
|
||||||
|
userIdArg,
|
||||||
|
resolvedRequest.clientId,
|
||||||
|
resolvedRequest.validScopes,
|
||||||
|
resolvedRequest.prompt
|
||||||
|
);
|
||||||
|
|
||||||
|
if (consentState.consentRequired && !requestArg.consentApproved) {
|
||||||
|
throw new Error('Consent required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.consentApproved) {
|
||||||
|
await this.upsertUserConsent(userIdArg, resolvedRequest.clientId, resolvedRequest.validScopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
const code = await this.generateAuthorizationCode(
|
||||||
|
resolvedRequest.clientId,
|
||||||
|
userIdArg,
|
||||||
|
resolvedRequest.validScopes,
|
||||||
|
resolvedRequest.redirectUri,
|
||||||
|
resolvedRequest.codeChallenge,
|
||||||
|
resolvedRequest.nonce
|
||||||
|
);
|
||||||
|
|
||||||
|
const redirectUrl = new URL(resolvedRequest.redirectUri);
|
||||||
|
redirectUrl.searchParams.set('code', code);
|
||||||
|
redirectUrl.searchParams.set('state', resolvedRequest.state);
|
||||||
|
|
||||||
|
return {
|
||||||
|
code,
|
||||||
|
redirectUrl: redirectUrl.toString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the token endpoint request
|
* Handle the token endpoint request
|
||||||
*/
|
*/
|
||||||
@@ -222,6 +343,13 @@ export class OidcManager {
|
|||||||
return this.tokenErrorResponse('invalid_client', 'Missing client_id');
|
return this.tokenErrorResponse('invalid_client', 'Missing client_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.receptionRef.abuseProtectionManager.consumeAttempt(
|
||||||
|
'oidcTokenExchange',
|
||||||
|
clientId,
|
||||||
|
this.abuseProtectionConfig.oidcTokenExchange,
|
||||||
|
'Too many token endpoint attempts. Please wait before retrying.'
|
||||||
|
);
|
||||||
|
|
||||||
// Find and validate app
|
// Find and validate app
|
||||||
const app = await this.findAppByClientId(clientId);
|
const app = await this.findAppByClientId(clientId);
|
||||||
if (!app) {
|
if (!app) {
|
||||||
@@ -236,13 +364,20 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let response: Response;
|
||||||
if (grantType === 'authorization_code') {
|
if (grantType === 'authorization_code') {
|
||||||
return this.handleAuthorizationCodeGrant(formData, app);
|
response = await this.handleAuthorizationCodeGrant(formData, app);
|
||||||
} else if (grantType === 'refresh_token') {
|
} else if (grantType === 'refresh_token') {
|
||||||
return this.handleRefreshTokenGrant(formData, app);
|
response = await this.handleRefreshTokenGrant(formData, app);
|
||||||
} else {
|
} else {
|
||||||
return this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
|
response = this.tokenErrorResponse('unsupported_grant_type', 'Unsupported grant type');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (response.status === 200) {
|
||||||
|
await this.receptionRef.abuseProtectionManager.clearAttempts('oidcTokenExchange', clientId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,50 +396,48 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find and validate authorization code
|
// Find and validate authorization code
|
||||||
const authCode = this.authorizationCodes.get(code);
|
const authCode = await this.getAuthorizationCodeByCode(code);
|
||||||
if (!authCode) {
|
if (!authCode) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
return this.tokenErrorResponse('invalid_grant', 'Invalid authorization code');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.used) {
|
if (authCode.data.used) {
|
||||||
// Code reuse attack - revoke all tokens for this code
|
|
||||||
this.authorizationCodes.delete(code);
|
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
return this.tokenErrorResponse('invalid_grant', 'Authorization code already used');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.expiresAt < Date.now()) {
|
if (authCode.isExpired()) {
|
||||||
this.authorizationCodes.delete(code);
|
await authCode.delete();
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Authorization code expired');
|
return this.tokenErrorResponse('invalid_grant', 'Authorization code expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.clientId !== app.data.oauthCredentials.clientId) {
|
if (authCode.data.clientId !== app.data.oauthCredentials.clientId) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authCode.redirectUri !== redirectUri) {
|
if (authCode.data.redirectUri !== redirectUri) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
return this.tokenErrorResponse('invalid_grant', 'Redirect URI mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify PKCE if code challenge was used
|
// Verify PKCE if code challenge was used
|
||||||
if (authCode.codeChallenge) {
|
if (authCode.data.codeChallenge) {
|
||||||
if (!codeVerifier) {
|
if (!codeVerifier) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
return this.tokenErrorResponse('invalid_grant', 'Code verifier required');
|
||||||
}
|
}
|
||||||
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
const expectedChallenge = this.generateS256Challenge(codeVerifier);
|
||||||
if (expectedChallenge !== authCode.codeChallenge) {
|
if (expectedChallenge !== authCode.data.codeChallenge) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
return this.tokenErrorResponse('invalid_grant', 'Invalid code verifier');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark code as used
|
// Mark code as used
|
||||||
authCode.used = true;
|
await authCode.markUsed();
|
||||||
|
|
||||||
// Generate tokens
|
// Generate tokens
|
||||||
const tokens = await this.generateTokens(
|
const tokens = await this.generateTokens(
|
||||||
authCode.userId,
|
authCode.data.userId,
|
||||||
app.data.oauthCredentials.clientId,
|
app.data.oauthCredentials.clientId,
|
||||||
authCode.scopes,
|
authCode.data.scopes,
|
||||||
authCode.nonce
|
authCode.data.nonce
|
||||||
);
|
);
|
||||||
|
|
||||||
return new Response(JSON.stringify(tokens), {
|
return new Response(JSON.stringify(tokens), {
|
||||||
@@ -330,31 +463,30 @@ export class OidcManager {
|
|||||||
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
return this.tokenErrorResponse('invalid_request', 'Missing refresh_token');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
const storedToken = await this.getRefreshTokenByToken(refreshToken);
|
||||||
const storedToken = this.refreshTokens.get(tokenHash);
|
|
||||||
|
|
||||||
if (!storedToken) {
|
if (!storedToken) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token');
|
return this.tokenErrorResponse('invalid_grant', 'Invalid refresh token');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.revoked) {
|
if (storedToken.data.revoked) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked');
|
return this.tokenErrorResponse('invalid_grant', 'Refresh token has been revoked');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.expiresAt < Date.now()) {
|
if (storedToken.isExpired()) {
|
||||||
this.refreshTokens.delete(tokenHash);
|
await storedToken.delete();
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Refresh token expired');
|
return this.tokenErrorResponse('invalid_grant', 'Refresh token expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (storedToken.clientId !== app.data.oauthCredentials.clientId) {
|
if (storedToken.data.clientId !== app.data.oauthCredentials.clientId) {
|
||||||
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
return this.tokenErrorResponse('invalid_grant', 'Client ID mismatch');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new tokens (without new refresh token by default)
|
// Generate new tokens (without new refresh token by default)
|
||||||
const tokens = await this.generateTokens(
|
const tokens = await this.generateTokens(
|
||||||
storedToken.userId,
|
storedToken.data.userId,
|
||||||
storedToken.clientId,
|
storedToken.data.clientId,
|
||||||
storedToken.scopes,
|
storedToken.data.scopes,
|
||||||
undefined,
|
undefined,
|
||||||
false // Don't generate new refresh token
|
false // Don't generate new refresh token
|
||||||
);
|
);
|
||||||
@@ -384,18 +516,18 @@ export class OidcManager {
|
|||||||
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
const refreshTokenLifetime = 30 * 24 * 3600; // 30 days
|
||||||
|
|
||||||
// Generate access token
|
// Generate access token
|
||||||
const accessToken = plugins.smartunique.shortId(32);
|
const accessToken = this.createOpaqueToken();
|
||||||
const accessTokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
const accessTokenData = new OidcAccessToken();
|
||||||
const accessTokenData: plugins.idpInterfaces.data.IOidcAccessToken = {
|
accessTokenData.id = plugins.smartunique.shortId(12);
|
||||||
id: plugins.smartunique.shortId(8),
|
accessTokenData.data = {
|
||||||
tokenHash: accessTokenHash,
|
tokenHash: OidcAccessToken.hashToken(accessToken),
|
||||||
clientId,
|
clientId,
|
||||||
userId,
|
userId,
|
||||||
scopes,
|
scopes,
|
||||||
expiresAt: now + accessTokenLifetime * 1000,
|
expiresAt: now + accessTokenLifetime * 1000,
|
||||||
issuedAt: now,
|
issuedAt: now,
|
||||||
};
|
};
|
||||||
this.accessTokens.set(accessTokenHash, accessTokenData);
|
await accessTokenData.save();
|
||||||
|
|
||||||
// Generate ID token (JWT)
|
// Generate ID token (JWT)
|
||||||
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
const idToken = await this.generateIdToken(userId, clientId, scopes, nonce);
|
||||||
@@ -410,11 +542,11 @@ export class OidcManager {
|
|||||||
|
|
||||||
// Generate refresh token if requested
|
// Generate refresh token if requested
|
||||||
if (includeRefreshToken) {
|
if (includeRefreshToken) {
|
||||||
const refreshToken = plugins.smartunique.shortId(48);
|
const refreshToken = this.createOpaqueToken(48);
|
||||||
const refreshTokenHash = await plugins.smarthash.sha256FromString(refreshToken);
|
const refreshTokenData = new OidcRefreshToken();
|
||||||
const refreshTokenData: plugins.idpInterfaces.data.IOidcRefreshToken = {
|
refreshTokenData.id = plugins.smartunique.shortId(12);
|
||||||
id: plugins.smartunique.shortId(8),
|
refreshTokenData.data = {
|
||||||
tokenHash: refreshTokenHash,
|
tokenHash: OidcRefreshToken.hashToken(refreshToken),
|
||||||
clientId,
|
clientId,
|
||||||
userId,
|
userId,
|
||||||
scopes,
|
scopes,
|
||||||
@@ -422,7 +554,7 @@ export class OidcManager {
|
|||||||
issuedAt: now,
|
issuedAt: now,
|
||||||
revoked: false,
|
revoked: false,
|
||||||
};
|
};
|
||||||
this.refreshTokens.set(refreshTokenHash, refreshTokenData);
|
await refreshTokenData.save();
|
||||||
response.refresh_token = refreshToken;
|
response.refresh_token = refreshToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,8 +614,7 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const accessToken = authHeader.substring(7);
|
const accessToken = authHeader.substring(7);
|
||||||
const tokenHash = await plugins.smarthash.sha256FromString(accessToken);
|
const tokenData = await this.getAccessTokenByToken(accessToken);
|
||||||
const tokenData = this.accessTokens.get(tokenHash);
|
|
||||||
|
|
||||||
if (!tokenData) {
|
if (!tokenData) {
|
||||||
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
return new Response(JSON.stringify({ error: 'invalid_token' }), {
|
||||||
@@ -495,8 +626,8 @@ export class OidcManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tokenData.expiresAt < Date.now()) {
|
if (tokenData.isExpired()) {
|
||||||
this.accessTokens.delete(tokenHash);
|
await tokenData.delete();
|
||||||
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
return new Response(JSON.stringify({ error: 'invalid_token', error_description: 'Token expired' }), {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: {
|
headers: {
|
||||||
@@ -507,7 +638,7 @@ export class OidcManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get user claims based on token scopes
|
// Get user claims based on token scopes
|
||||||
const userInfo = await this.getUserClaims(tokenData.userId, tokenData.scopes);
|
const userInfo = await this.getUserClaims(tokenData.data.userId, tokenData.data.scopes);
|
||||||
|
|
||||||
return new Response(JSON.stringify(userInfo), {
|
return new Response(JSON.stringify(userInfo), {
|
||||||
status: 200,
|
status: 200,
|
||||||
@@ -583,21 +714,20 @@ export class OidcManager {
|
|||||||
return new Response(null, { status: 200 }); // Spec says always return 200
|
return new Response(null, { status: 200 }); // Spec says always return 200
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenHash = await plugins.smarthash.sha256FromString(token);
|
|
||||||
|
|
||||||
// Try to revoke as refresh token
|
// Try to revoke as refresh token
|
||||||
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
if (!tokenTypeHint || tokenTypeHint === 'refresh_token') {
|
||||||
const refreshToken = this.refreshTokens.get(tokenHash);
|
const refreshToken = await this.getRefreshTokenByToken(token);
|
||||||
if (refreshToken) {
|
if (refreshToken) {
|
||||||
refreshToken.revoked = true;
|
await refreshToken.revoke();
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to revoke as access token
|
// Try to revoke as access token
|
||||||
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
if (!tokenTypeHint || tokenTypeHint === 'access_token') {
|
||||||
if (this.accessTokens.has(tokenHash)) {
|
const accessToken = await this.getAccessTokenByToken(token);
|
||||||
this.accessTokens.delete(tokenHash);
|
if (accessToken) {
|
||||||
|
await accessToken.delete();
|
||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -616,6 +746,125 @@ export class OidcManager {
|
|||||||
return apps[0] || null;
|
return apps[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private isSupportedPrompt(promptArg: string): promptArg is 'none' | 'login' | 'consent' {
|
||||||
|
return ['none', 'login', 'consent'].includes(promptArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAuthorizationRequest(
|
||||||
|
requestArg: Pick<
|
||||||
|
plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'],
|
||||||
|
'clientId' | 'redirectUri' | 'scope' | 'state' | 'prompt' | 'codeChallenge' | 'codeChallengeMethod' | 'nonce'
|
||||||
|
>
|
||||||
|
) {
|
||||||
|
if (!requestArg.clientId || !requestArg.redirectUri || !requestArg.scope || !requestArg.state) {
|
||||||
|
throw new Error('Missing required OAuth authorization parameters');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.prompt && !this.isSupportedPrompt(requestArg.prompt)) {
|
||||||
|
throw new Error('Unsupported prompt value');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestArg.codeChallenge && requestArg.codeChallengeMethod !== 'S256') {
|
||||||
|
throw new Error('Only S256 code challenge method is supported');
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = await this.findAppByClientId(requestArg.clientId);
|
||||||
|
if (!app) {
|
||||||
|
throw new Error('Unknown client_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.data.oauthCredentials.redirectUris.includes(requestArg.redirectUri)) {
|
||||||
|
throw new Error('Invalid redirect_uri');
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestedScopes = requestArg.scope
|
||||||
|
.split(' ')
|
||||||
|
.filter(Boolean) as plugins.idpInterfaces.data.TOidcScope[];
|
||||||
|
const allowedScopes =
|
||||||
|
app.data.oauthCredentials.allowedScopes as plugins.idpInterfaces.data.TOidcScope[];
|
||||||
|
const validScopes = requestedScopes.filter((scopeArg) => allowedScopes.includes(scopeArg));
|
||||||
|
|
||||||
|
if (!validScopes.includes('openid')) {
|
||||||
|
throw new Error('openid scope is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
app,
|
||||||
|
clientId: requestArg.clientId,
|
||||||
|
redirectUri: requestArg.redirectUri,
|
||||||
|
state: requestArg.state,
|
||||||
|
prompt: requestArg.prompt,
|
||||||
|
codeChallenge: requestArg.codeChallenge,
|
||||||
|
codeChallengeMethod: requestArg.codeChallengeMethod,
|
||||||
|
nonce: requestArg.nonce,
|
||||||
|
validScopes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async evaluateConsentRequirement(
|
||||||
|
userIdArg: string,
|
||||||
|
clientIdArg: string,
|
||||||
|
scopesArg: plugins.idpInterfaces.data.TOidcScope[],
|
||||||
|
promptArg?: 'none' | 'login' | 'consent'
|
||||||
|
) {
|
||||||
|
const existingConsent = await this.getUserConsent(userIdArg, clientIdArg);
|
||||||
|
const grantedScopes = existingConsent?.data.scopes || [];
|
||||||
|
const missingScopes = scopesArg.filter((scopeArg) => !grantedScopes.includes(scopeArg));
|
||||||
|
|
||||||
|
return {
|
||||||
|
grantedScopes,
|
||||||
|
missingScopes,
|
||||||
|
consentRequired: promptArg === 'consent' || missingScopes.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private createOpaqueToken(byteLength = 32): string {
|
||||||
|
return plugins.crypto.randomBytes(byteLength).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthorizationCodeByCode(codeArg: string) {
|
||||||
|
return this.COidcAuthorizationCode.getInstance({
|
||||||
|
'data.codeHash': OidcAuthorizationCode.hashCode(codeArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAccessTokenByToken(tokenArg: string) {
|
||||||
|
return this.COidcAccessToken.getInstance({
|
||||||
|
'data.tokenHash': OidcAccessToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getRefreshTokenByToken(tokenArg: string) {
|
||||||
|
return this.COidcRefreshToken.getInstance({
|
||||||
|
'data.tokenHash': OidcRefreshToken.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getUserConsent(userIdArg: string, clientIdArg: string) {
|
||||||
|
return this.COidcUserConsent.getInstance({
|
||||||
|
'data.userId': userIdArg,
|
||||||
|
'data.clientId': clientIdArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async upsertUserConsent(
|
||||||
|
userIdArg: string,
|
||||||
|
clientIdArg: string,
|
||||||
|
scopesArg: plugins.idpInterfaces.data.TOidcScope[]
|
||||||
|
) {
|
||||||
|
let userConsent = await this.getUserConsent(userIdArg, clientIdArg);
|
||||||
|
|
||||||
|
if (!userConsent) {
|
||||||
|
userConsent = new OidcUserConsent();
|
||||||
|
userConsent.id = plugins.smartunique.shortId(12);
|
||||||
|
userConsent.data.userId = userIdArg;
|
||||||
|
userConsent.data.clientId = clientIdArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
await userConsent.grantScopes(scopesArg);
|
||||||
|
return userConsent;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate S256 PKCE challenge from verifier
|
* Generate S256 PKCE challenge from verifier
|
||||||
*/
|
*/
|
||||||
@@ -655,29 +904,45 @@ export class OidcManager {
|
|||||||
* Start cleanup task for expired tokens/codes
|
* Start cleanup task for expired tokens/codes
|
||||||
*/
|
*/
|
||||||
private startCleanupTask(): void {
|
private startCleanupTask(): void {
|
||||||
setInterval(() => {
|
this.cleanupInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
void this.cleanupExpiredOidcState();
|
||||||
|
}, 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up expired authorization codes
|
private async cleanupExpiredOidcState() {
|
||||||
for (const [code, data] of this.authorizationCodes) {
|
const now = Date.now();
|
||||||
if (data.expiresAt < now) {
|
|
||||||
this.authorizationCodes.delete(code);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up expired access tokens
|
const expiredAuthorizationCodes = await this.COidcAuthorizationCode.getInstances({
|
||||||
for (const [hash, data] of this.accessTokens) {
|
data: {
|
||||||
if (data.expiresAt < now) {
|
expiresAt: {
|
||||||
this.accessTokens.delete(hash);
|
$lt: now,
|
||||||
}
|
} as any,
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
for (const authCode of expiredAuthorizationCodes) {
|
||||||
|
await authCode.delete();
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up expired refresh tokens
|
const expiredAccessTokens = await this.COidcAccessToken.getInstances({
|
||||||
for (const [hash, data] of this.refreshTokens) {
|
data: {
|
||||||
if (data.expiresAt < now) {
|
expiresAt: {
|
||||||
this.refreshTokens.delete(hash);
|
$lt: now,
|
||||||
}
|
} as any,
|
||||||
}
|
},
|
||||||
}, 60 * 1000); // Run every minute
|
});
|
||||||
|
for (const accessToken of expiredAccessTokens) {
|
||||||
|
await accessToken.delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiredRefreshTokens = await this.COidcRefreshToken.getInstances({
|
||||||
|
data: {
|
||||||
|
expiresAt: {
|
||||||
|
$lt: now,
|
||||||
|
} as any,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
for (const refreshToken of expiredRefreshTokens) {
|
||||||
|
await refreshToken.delete();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcRefreshToken extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcRefreshToken,
|
||||||
|
plugins.idpInterfaces.data.IOidcRefreshToken,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IOidcRefreshToken['data'] = {
|
||||||
|
tokenHash: '',
|
||||||
|
clientId: '',
|
||||||
|
userId: '',
|
||||||
|
scopes: [],
|
||||||
|
expiresAt: 0,
|
||||||
|
issuedAt: 0,
|
||||||
|
revoked: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.expiresAt < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public matchesToken(tokenArg: string) {
|
||||||
|
return this.data.tokenHash === OidcRefreshToken.hashToken(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async revoke() {
|
||||||
|
this.data.revoked = true;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import type { OidcManager } from './classes.oidcmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class OidcUserConsent extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
OidcUserConsent,
|
||||||
|
plugins.idpInterfaces.data.IUserConsent,
|
||||||
|
OidcManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IUserConsent['data'] = {
|
||||||
|
userId: '',
|
||||||
|
clientId: '',
|
||||||
|
scopes: [],
|
||||||
|
grantedAt: 0,
|
||||||
|
updatedAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public async grantScopes(scopesArg: plugins.idpInterfaces.data.TOidcScope[]) {
|
||||||
|
this.data.scopes = [...new Set([...this.data.scopes, ...scopesArg])];
|
||||||
|
if (!this.data.grantedAt) {
|
||||||
|
this.data.grantedAt = Date.now();
|
||||||
|
}
|
||||||
|
this.data.updatedAt = Date.now();
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { PassportManager } from './classes.passportmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class PassportChallenge extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
PassportChallenge,
|
||||||
|
plugins.idpInterfaces.data.IPassportChallenge,
|
||||||
|
PassportManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IPassportChallenge['data'] = {
|
||||||
|
userId: '',
|
||||||
|
deviceId: null,
|
||||||
|
type: 'device_enrollment',
|
||||||
|
status: 'pending',
|
||||||
|
tokenHash: null,
|
||||||
|
challenge: '',
|
||||||
|
metadata: {
|
||||||
|
originHost: undefined,
|
||||||
|
audience: undefined,
|
||||||
|
notificationTitle: undefined,
|
||||||
|
deviceLabel: undefined,
|
||||||
|
requireLocation: false,
|
||||||
|
requireNfc: false,
|
||||||
|
requestedCapabilities: undefined,
|
||||||
|
},
|
||||||
|
evidence: undefined,
|
||||||
|
notification: undefined,
|
||||||
|
createdAt: 0,
|
||||||
|
expiresAt: 0,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired(nowArg = Date.now()) {
|
||||||
|
return this.data.expiresAt < nowArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markApproved(
|
||||||
|
evidenceArg?: plugins.idpInterfaces.data.IPassportChallenge['data']['evidence']
|
||||||
|
) {
|
||||||
|
this.data.status = 'approved';
|
||||||
|
this.data.completedAt = Date.now();
|
||||||
|
this.data.evidence = evidenceArg;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markExpired() {
|
||||||
|
this.data.status = 'expired';
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { PassportManager } from './classes.passportmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class PassportDevice extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
PassportDevice,
|
||||||
|
plugins.idpInterfaces.data.IPassportDevice,
|
||||||
|
PassportManager
|
||||||
|
> {
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IPassportDevice['data'] = {
|
||||||
|
userId: '',
|
||||||
|
label: '',
|
||||||
|
platform: 'unknown',
|
||||||
|
status: 'active',
|
||||||
|
publicKeyAlgorithm: 'p256',
|
||||||
|
publicKeyX963Base64: '',
|
||||||
|
capabilities: {
|
||||||
|
gps: false,
|
||||||
|
nfc: false,
|
||||||
|
push: false,
|
||||||
|
},
|
||||||
|
pushRegistration: undefined,
|
||||||
|
appVersion: undefined,
|
||||||
|
createdAt: 0,
|
||||||
|
lastSeenAt: undefined,
|
||||||
|
lastChallengeAt: undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isActive() {
|
||||||
|
return this.data.status === 'active';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,816 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import { PassportChallenge } from './classes.passportchallenge.js';
|
||||||
|
import { PassportDevice } from './classes.passportdevice.js';
|
||||||
|
import { PassportNonce } from './classes.passportnonce.js';
|
||||||
|
import { logger } from './logging.js';
|
||||||
|
import { Reception } from './classes.reception.js';
|
||||||
|
|
||||||
|
export class PassportManager {
|
||||||
|
private readonly enrollmentChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({
|
||||||
|
minutes: 10,
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly assertionChallengeMillis = plugins.smarttime.getMilliSecondsFromUnits({
|
||||||
|
minutes: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly deviceRequestWindowMillis = plugins.smarttime.getMilliSecondsFromUnits({
|
||||||
|
minutes: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
public receptionRef: Reception;
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
public CPassportDevice = plugins.smartdata.setDefaultManagerForDoc(this, PassportDevice);
|
||||||
|
public CPassportChallenge = plugins.smartdata.setDefaultManagerForDoc(this, PassportChallenge);
|
||||||
|
public CPassportNonce = plugins.smartdata.setDefaultManagerForDoc(this, PassportNonce);
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreatePassportEnrollmentChallenge>(
|
||||||
|
'createPassportEnrollmentChallenge',
|
||||||
|
async (requestArg) => {
|
||||||
|
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||||
|
const enrollmentChallenge = await this.createEnrollmentChallengeForUser(userId, {
|
||||||
|
deviceLabel: requestArg.deviceLabel,
|
||||||
|
platform: requestArg.platform,
|
||||||
|
appVersion: requestArg.appVersion,
|
||||||
|
capabilities: requestArg.capabilities,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
challengeId: enrollmentChallenge.challenge.id,
|
||||||
|
pairingToken: enrollmentChallenge.pairingToken,
|
||||||
|
pairingPayload: enrollmentChallenge.pairingPayload,
|
||||||
|
signingPayload: enrollmentChallenge.signingPayload,
|
||||||
|
expiresAt: enrollmentChallenge.challenge.data.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CompletePassportEnrollment>(
|
||||||
|
'completePassportEnrollment',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.completeEnrollment({
|
||||||
|
pairingToken: requestArg.pairingToken,
|
||||||
|
deviceLabel: requestArg.deviceLabel,
|
||||||
|
platform: requestArg.platform,
|
||||||
|
publicKeyX963Base64: requestArg.publicKeyX963Base64,
|
||||||
|
signatureBase64: requestArg.signatureBase64,
|
||||||
|
signatureFormat: requestArg.signatureFormat,
|
||||||
|
appVersion: requestArg.appVersion,
|
||||||
|
capabilities: requestArg.capabilities,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
device: {
|
||||||
|
id: passportDevice.id,
|
||||||
|
data: passportDevice.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportDevices>(
|
||||||
|
'getPassportDevices',
|
||||||
|
async (requestArg) => {
|
||||||
|
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||||
|
const devices = await this.getPassportDevicesForUser(userId);
|
||||||
|
return {
|
||||||
|
devices: devices.map((deviceArg) => ({
|
||||||
|
id: deviceArg.id,
|
||||||
|
data: deviceArg.data,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RevokePassportDevice>(
|
||||||
|
'revokePassportDevice',
|
||||||
|
async (requestArg) => {
|
||||||
|
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||||
|
await this.revokePassportDeviceForUser(userId, requestArg.deviceId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_CreatePassportChallenge>(
|
||||||
|
'createPassportChallenge',
|
||||||
|
async (requestArg) => {
|
||||||
|
const userId = await this.getAuthenticatedUserId(requestArg.jwt);
|
||||||
|
const challengeResult = await this.createPassportChallengeForUser(userId, {
|
||||||
|
type: requestArg.type,
|
||||||
|
preferredDeviceId: requestArg.preferredDeviceId,
|
||||||
|
audience: requestArg.audience,
|
||||||
|
notificationTitle: requestArg.notificationTitle,
|
||||||
|
requireLocation: requestArg.requireLocation,
|
||||||
|
requireNfc: requestArg.requireNfc,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
challengeId: challengeResult.challenge.id,
|
||||||
|
challenge: challengeResult.challenge.data.challenge,
|
||||||
|
signingPayload: challengeResult.signingPayload,
|
||||||
|
deviceId: challengeResult.challenge.data.deviceId!,
|
||||||
|
expiresAt: challengeResult.challenge.data.expiresAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ApprovePassportChallenge>(
|
||||||
|
'approvePassportChallenge',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportChallenge = await this.approvePassportChallenge({
|
||||||
|
challengeId: requestArg.challengeId,
|
||||||
|
deviceId: requestArg.deviceId,
|
||||||
|
signatureBase64: requestArg.signatureBase64,
|
||||||
|
signatureFormat: requestArg.signatureFormat,
|
||||||
|
location: requestArg.location,
|
||||||
|
nfc: requestArg.nfc,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
challenge: {
|
||||||
|
id: passportChallenge.id,
|
||||||
|
data: passportChallenge.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_RegisterPassportPushToken>(
|
||||||
|
'registerPassportPushToken',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||||
|
action: 'registerPassportPushToken',
|
||||||
|
signedFields: [
|
||||||
|
`provider=${requestArg.provider}`,
|
||||||
|
`token=${requestArg.token}`,
|
||||||
|
`topic=${requestArg.topic}`,
|
||||||
|
`environment=${requestArg.environment}`,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
passportDevice.data.pushRegistration = {
|
||||||
|
provider: requestArg.provider,
|
||||||
|
token: requestArg.token,
|
||||||
|
topic: requestArg.topic,
|
||||||
|
environment: requestArg.environment,
|
||||||
|
registeredAt: Date.now(),
|
||||||
|
lastDeliveredAt: passportDevice.data.pushRegistration?.lastDeliveredAt,
|
||||||
|
lastError: undefined,
|
||||||
|
};
|
||||||
|
passportDevice.data.lastSeenAt = Date.now();
|
||||||
|
await passportDevice.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_ListPendingPassportChallenges>(
|
||||||
|
'listPendingPassportChallenges',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||||
|
action: 'listPendingPassportChallenges',
|
||||||
|
});
|
||||||
|
const challenges = await this.listPendingChallengesForDevice(passportDevice.id);
|
||||||
|
return {
|
||||||
|
challenges: challenges.map((challengeArg) => ({
|
||||||
|
id: challengeArg.id,
|
||||||
|
data: challengeArg.data,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_GetPassportChallengeByHint>(
|
||||||
|
'getPassportChallengeByHint',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||||
|
action: 'getPassportChallengeByHint',
|
||||||
|
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||||
|
});
|
||||||
|
const passportChallenge = await this.getPassportChallengeByHint(passportDevice.id, requestArg.hintId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
challenge: passportChallenge
|
||||||
|
? {
|
||||||
|
id: passportChallenge.id,
|
||||||
|
data: passportChallenge.data,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
this.typedRouter.addTypedHandler(
|
||||||
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MarkPassportChallengeSeen>(
|
||||||
|
'markPassportChallengeSeen',
|
||||||
|
async (requestArg) => {
|
||||||
|
const passportDevice = await this.authenticatePassportDeviceRequest(requestArg, {
|
||||||
|
action: 'markPassportChallengeSeen',
|
||||||
|
signedFields: [`hint_id=${requestArg.hintId}`],
|
||||||
|
});
|
||||||
|
await this.markPassportChallengeSeen(passportDevice.id, requestArg.hintId);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getAuthenticatedUserId(jwtArg: string) {
|
||||||
|
const jwt = await this.receptionRef.jwtManager.verifyJWTAndGetData(jwtArg);
|
||||||
|
if (!jwt) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid JWT');
|
||||||
|
}
|
||||||
|
|
||||||
|
return jwt.data.userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOriginHost() {
|
||||||
|
return new URL(this.receptionRef.options.baseUrl).host;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createOpaqueToken(prefixArg: string) {
|
||||||
|
return `${prefixArg}${plugins.crypto.randomBytes(32).toString('base64url')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildDeviceRequestSigningPayload(
|
||||||
|
requestArg: plugins.idpInterfaces.request.IPassportDeviceSignedRequest,
|
||||||
|
actionArg: string,
|
||||||
|
signedFieldsArg: string[] = []
|
||||||
|
) {
|
||||||
|
return [
|
||||||
|
'purpose=passport-device-request',
|
||||||
|
`origin=${this.getOriginHost()}`,
|
||||||
|
`action=${actionArg}`,
|
||||||
|
`device_id=${requestArg.deviceId}`,
|
||||||
|
`timestamp=${requestArg.timestamp}`,
|
||||||
|
`nonce=${requestArg.nonce}`,
|
||||||
|
...signedFieldsArg,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async consumePassportNonce(deviceIdArg: string, nonceArg: string, timestampArg: number) {
|
||||||
|
const now = Date.now();
|
||||||
|
if (Math.abs(now - timestampArg) > this.deviceRequestWindowMillis) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device request timestamp expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingNonce = await this.CPassportNonce.getInstance({
|
||||||
|
id: PassportNonce.hashNonce(`${deviceIdArg}:${nonceArg}`),
|
||||||
|
});
|
||||||
|
if (existingNonce && !existingNonce.isExpired(now)) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device request replay detected');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportNonce = existingNonce || new PassportNonce();
|
||||||
|
passportNonce.id = PassportNonce.hashNonce(`${deviceIdArg}:${nonceArg}`);
|
||||||
|
passportNonce.data = {
|
||||||
|
deviceId: deviceIdArg,
|
||||||
|
nonceHash: PassportNonce.hashNonce(nonceArg),
|
||||||
|
createdAt: now,
|
||||||
|
expiresAt: now + this.deviceRequestWindowMillis,
|
||||||
|
};
|
||||||
|
await passportNonce.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
public async authenticatePassportDeviceRequest(
|
||||||
|
requestArg: plugins.idpInterfaces.request.IPassportDeviceSignedRequest,
|
||||||
|
optionsArg: {
|
||||||
|
action: string;
|
||||||
|
signedFields?: string[];
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const passportDevice = await this.CPassportDevice.getInstance({
|
||||||
|
id: requestArg.deviceId,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
if (!passportDevice) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = this.verifyPassportSignature(
|
||||||
|
passportDevice.data.publicKeyX963Base64,
|
||||||
|
requestArg.signatureBase64,
|
||||||
|
requestArg.signatureFormat || 'raw',
|
||||||
|
this.buildDeviceRequestSigningPayload(
|
||||||
|
requestArg,
|
||||||
|
optionsArg.action,
|
||||||
|
optionsArg.signedFields || []
|
||||||
|
)
|
||||||
|
);
|
||||||
|
if (!verified) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device signature invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.consumePassportNonce(requestArg.deviceId, requestArg.nonce, requestArg.timestamp);
|
||||||
|
passportDevice.data.lastSeenAt = Date.now();
|
||||||
|
await passportDevice.save();
|
||||||
|
return passportDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
private normalizeCapabilities(
|
||||||
|
capabilitiesArg?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>
|
||||||
|
): plugins.idpInterfaces.data.IPassportCapabilities {
|
||||||
|
return {
|
||||||
|
gps: !!capabilitiesArg?.gps,
|
||||||
|
nfc: !!capabilitiesArg?.nfc,
|
||||||
|
push: !!capabilitiesArg?.push,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildEnrollmentSigningPayload(pairingTokenArg: string, challengeArg: PassportChallenge) {
|
||||||
|
return [
|
||||||
|
'purpose=passport-enrollment',
|
||||||
|
`origin=${this.getOriginHost()}`,
|
||||||
|
`token=${pairingTokenArg}`,
|
||||||
|
`challenge=${challengeArg.data.challenge}`,
|
||||||
|
`challenge_id=${challengeArg.id}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildChallengeSigningPayload(challengeArg: PassportChallenge) {
|
||||||
|
return [
|
||||||
|
'purpose=passport-challenge',
|
||||||
|
`origin=${this.getOriginHost()}`,
|
||||||
|
`challenge=${challengeArg.data.challenge}`,
|
||||||
|
`challenge_id=${challengeArg.id}`,
|
||||||
|
`type=${challengeArg.data.type}`,
|
||||||
|
`device_id=${challengeArg.data.deviceId || ''}`,
|
||||||
|
`audience=${challengeArg.data.metadata.audience || ''}`,
|
||||||
|
`require_location=${challengeArg.data.metadata.requireLocation}`,
|
||||||
|
`require_nfc=${challengeArg.data.metadata.requireNfc}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private createPairingPayload(
|
||||||
|
pairingTokenArg: string,
|
||||||
|
challengeArg: PassportChallenge,
|
||||||
|
deviceLabelArg: string
|
||||||
|
) {
|
||||||
|
const searchParams = new URLSearchParams({
|
||||||
|
token: pairingTokenArg,
|
||||||
|
challenge: challengeArg.data.challenge,
|
||||||
|
challenge_id: challengeArg.id,
|
||||||
|
origin: this.getOriginHost(),
|
||||||
|
device: deviceLabelArg,
|
||||||
|
});
|
||||||
|
return `idp.global://pair?${searchParams.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private createP256JwkFromX963(publicKeyX963Base64Arg: string) {
|
||||||
|
const rawPublicKey = Buffer.from(publicKeyX963Base64Arg, 'base64');
|
||||||
|
if (rawPublicKey.length !== 65 || rawPublicKey[0] !== 4) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Invalid passport public key');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
kty: 'EC',
|
||||||
|
crv: 'P-256',
|
||||||
|
x: rawPublicKey.subarray(1, 33).toString('base64url'),
|
||||||
|
y: rawPublicKey.subarray(33, 65).toString('base64url'),
|
||||||
|
ext: true,
|
||||||
|
} as JsonWebKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
private verifyPassportSignature(
|
||||||
|
publicKeyX963Base64Arg: string,
|
||||||
|
signatureBase64Arg: string,
|
||||||
|
signatureFormatArg: plugins.idpInterfaces.data.TPassportSignatureFormat,
|
||||||
|
payloadArg: string
|
||||||
|
) {
|
||||||
|
const publicKey = plugins.crypto.createPublicKey({
|
||||||
|
key: this.createP256JwkFromX963(publicKeyX963Base64Arg),
|
||||||
|
format: 'jwk',
|
||||||
|
});
|
||||||
|
|
||||||
|
const signature = Buffer.from(signatureBase64Arg, 'base64');
|
||||||
|
const payload = Buffer.from(payloadArg, 'utf8');
|
||||||
|
|
||||||
|
return signatureFormatArg === 'raw'
|
||||||
|
? plugins.crypto.verify('sha256', payload, { key: publicKey, dsaEncoding: 'ieee-p1363' }, signature)
|
||||||
|
: plugins.crypto.verify('sha256', payload, publicKey, signature);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createEnrollmentChallengeForUser(
|
||||||
|
userIdArg: string,
|
||||||
|
optionsArg: {
|
||||||
|
deviceLabel: string;
|
||||||
|
platform: plugins.idpInterfaces.data.TPassportDevicePlatform;
|
||||||
|
appVersion?: string;
|
||||||
|
capabilities?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const pairingToken = this.createOpaqueToken('passport_pair_');
|
||||||
|
const passportChallenge = new PassportChallenge();
|
||||||
|
passportChallenge.id = plugins.smartunique.shortId();
|
||||||
|
passportChallenge.data = {
|
||||||
|
userId: userIdArg,
|
||||||
|
deviceId: null,
|
||||||
|
type: 'device_enrollment',
|
||||||
|
status: 'pending',
|
||||||
|
tokenHash: PassportChallenge.hashToken(pairingToken),
|
||||||
|
challenge: this.createOpaqueToken('challenge_'),
|
||||||
|
metadata: {
|
||||||
|
originHost: this.getOriginHost(),
|
||||||
|
deviceLabel: optionsArg.deviceLabel,
|
||||||
|
requireLocation: false,
|
||||||
|
requireNfc: false,
|
||||||
|
requestedCapabilities: this.normalizeCapabilities(optionsArg.capabilities),
|
||||||
|
},
|
||||||
|
evidence: undefined,
|
||||||
|
notification: undefined,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + this.enrollmentChallengeMillis,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
await passportChallenge.save();
|
||||||
|
|
||||||
|
return {
|
||||||
|
challenge: passportChallenge,
|
||||||
|
pairingToken,
|
||||||
|
pairingPayload: this.createPairingPayload(
|
||||||
|
pairingToken,
|
||||||
|
passportChallenge,
|
||||||
|
optionsArg.deviceLabel
|
||||||
|
),
|
||||||
|
signingPayload: this.buildEnrollmentSigningPayload(pairingToken, passportChallenge),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async completeEnrollment(optionsArg: {
|
||||||
|
pairingToken: string;
|
||||||
|
deviceLabel: string;
|
||||||
|
platform: plugins.idpInterfaces.data.TPassportDevicePlatform;
|
||||||
|
publicKeyX963Base64: string;
|
||||||
|
signatureBase64: string;
|
||||||
|
signatureFormat?: plugins.idpInterfaces.data.TPassportSignatureFormat;
|
||||||
|
appVersion?: string;
|
||||||
|
capabilities?: Partial<plugins.idpInterfaces.data.IPassportCapabilities>;
|
||||||
|
}) {
|
||||||
|
const passportChallenge = await this.CPassportChallenge.getInstance({
|
||||||
|
'data.tokenHash': PassportChallenge.hashToken(optionsArg.pairingToken),
|
||||||
|
'data.type': 'device_enrollment',
|
||||||
|
'data.status': 'pending',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!passportChallenge) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Pairing token not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.isExpired()) {
|
||||||
|
await passportChallenge.markExpired();
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Pairing token expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPassportDevice = await this.CPassportDevice.getInstance({
|
||||||
|
'data.publicKeyX963Base64': optionsArg.publicKeyX963Base64,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
if (existingPassportDevice) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device already enrolled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = this.verifyPassportSignature(
|
||||||
|
optionsArg.publicKeyX963Base64,
|
||||||
|
optionsArg.signatureBase64,
|
||||||
|
optionsArg.signatureFormat || 'raw',
|
||||||
|
this.buildEnrollmentSigningPayload(optionsArg.pairingToken, passportChallenge)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!verified) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportDevice = new PassportDevice();
|
||||||
|
passportDevice.id = plugins.smartunique.shortId();
|
||||||
|
passportDevice.data = {
|
||||||
|
userId: passportChallenge.data.userId,
|
||||||
|
label: optionsArg.deviceLabel,
|
||||||
|
platform: optionsArg.platform,
|
||||||
|
status: 'active',
|
||||||
|
publicKeyAlgorithm: 'p256',
|
||||||
|
publicKeyX963Base64: optionsArg.publicKeyX963Base64,
|
||||||
|
capabilities: this.normalizeCapabilities(
|
||||||
|
optionsArg.capabilities || passportChallenge.data.metadata.requestedCapabilities
|
||||||
|
),
|
||||||
|
pushRegistration: undefined,
|
||||||
|
appVersion: optionsArg.appVersion,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
lastSeenAt: Date.now(),
|
||||||
|
lastChallengeAt: undefined,
|
||||||
|
};
|
||||||
|
await passportDevice.save();
|
||||||
|
|
||||||
|
passportChallenge.data.deviceId = passportDevice.id;
|
||||||
|
passportChallenge.data.tokenHash = null;
|
||||||
|
await passportChallenge.markApproved({
|
||||||
|
signatureFormat: optionsArg.signatureFormat || 'raw',
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
passportChallenge.data.userId,
|
||||||
|
'passport_device_enrolled',
|
||||||
|
`Enrolled passport device ${passportDevice.data.label}`,
|
||||||
|
{
|
||||||
|
targetId: passportDevice.id,
|
||||||
|
targetType: 'passport-device',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return passportDevice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPassportDevicesForUser(userIdArg: string) {
|
||||||
|
const devices = await this.CPassportDevice.getInstances({
|
||||||
|
'data.userId': userIdArg,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
return devices.sort(
|
||||||
|
(leftArg, rightArg) =>
|
||||||
|
(rightArg.data.lastSeenAt || rightArg.data.createdAt) -
|
||||||
|
(leftArg.data.lastSeenAt || leftArg.data.createdAt)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async revokePassportDeviceForUser(userIdArg: string, deviceIdArg: string) {
|
||||||
|
const passportDevice = await this.CPassportDevice.getInstance({
|
||||||
|
id: deviceIdArg,
|
||||||
|
'data.userId': userIdArg,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!passportDevice) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
passportDevice.data.status = 'revoked';
|
||||||
|
await passportDevice.save();
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
userIdArg,
|
||||||
|
'passport_device_revoked',
|
||||||
|
`Revoked passport device ${passportDevice.data.label}`,
|
||||||
|
{
|
||||||
|
targetId: passportDevice.id,
|
||||||
|
targetType: 'passport-device',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async createPassportChallengeForUser(
|
||||||
|
userIdArg: string,
|
||||||
|
optionsArg: {
|
||||||
|
type?: Exclude<plugins.idpInterfaces.data.TPassportChallengeType, 'device_enrollment'>;
|
||||||
|
preferredDeviceId?: string;
|
||||||
|
audience?: string;
|
||||||
|
notificationTitle?: string;
|
||||||
|
requireLocation?: boolean;
|
||||||
|
requireNfc?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const passportDevices = await this.getPassportDevicesForUser(userIdArg);
|
||||||
|
if (passportDevices.length === 0) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('No passport device enrolled');
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDevice = optionsArg.preferredDeviceId
|
||||||
|
? passportDevices.find((deviceArg) => deviceArg.id === optionsArg.preferredDeviceId)
|
||||||
|
: passportDevices[0];
|
||||||
|
|
||||||
|
if (!targetDevice) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Target passport device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportChallenge = new PassportChallenge();
|
||||||
|
passportChallenge.id = plugins.smartunique.shortId();
|
||||||
|
passportChallenge.data = {
|
||||||
|
userId: userIdArg,
|
||||||
|
deviceId: targetDevice.id,
|
||||||
|
type: optionsArg.type || 'step_up',
|
||||||
|
status: 'pending',
|
||||||
|
tokenHash: null,
|
||||||
|
challenge: this.createOpaqueToken('passport_challenge_'),
|
||||||
|
metadata: {
|
||||||
|
originHost: this.getOriginHost(),
|
||||||
|
audience: optionsArg.audience,
|
||||||
|
notificationTitle: optionsArg.notificationTitle,
|
||||||
|
deviceLabel: targetDevice.data.label,
|
||||||
|
requireLocation: !!optionsArg.requireLocation,
|
||||||
|
requireNfc: !!optionsArg.requireNfc,
|
||||||
|
},
|
||||||
|
evidence: undefined,
|
||||||
|
notification: {
|
||||||
|
hintId: plugins.crypto.randomUUID(),
|
||||||
|
status: 'pending',
|
||||||
|
attemptCount: 0,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
deliveredAt: null,
|
||||||
|
seenAt: null,
|
||||||
|
lastError: null,
|
||||||
|
},
|
||||||
|
createdAt: Date.now(),
|
||||||
|
expiresAt: Date.now() + this.assertionChallengeMillis,
|
||||||
|
completedAt: null,
|
||||||
|
};
|
||||||
|
await passportChallenge.save();
|
||||||
|
|
||||||
|
targetDevice.data.lastChallengeAt = Date.now();
|
||||||
|
await targetDevice.save();
|
||||||
|
|
||||||
|
await this.receptionRef.passportPushManager.deliverChallengeHint(targetDevice, passportChallenge);
|
||||||
|
|
||||||
|
return {
|
||||||
|
challenge: passportChallenge,
|
||||||
|
signingPayload: this.buildChallengeSigningPayload(passportChallenge),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async approvePassportChallenge(optionsArg: {
|
||||||
|
challengeId: string;
|
||||||
|
deviceId: string;
|
||||||
|
signatureBase64: string;
|
||||||
|
signatureFormat?: plugins.idpInterfaces.data.TPassportSignatureFormat;
|
||||||
|
location?: plugins.idpInterfaces.data.IPassportLocationEvidence;
|
||||||
|
nfc?: plugins.idpInterfaces.data.IPassportNfcEvidence;
|
||||||
|
}) {
|
||||||
|
const passportChallenge = await this.CPassportChallenge.getInstance({
|
||||||
|
id: optionsArg.challengeId,
|
||||||
|
'data.status': 'pending',
|
||||||
|
});
|
||||||
|
if (!passportChallenge) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.isExpired()) {
|
||||||
|
await passportChallenge.markExpired();
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport challenge expired');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.data.deviceId && passportChallenge.data.deviceId !== optionsArg.deviceId) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport challenge not assigned to this device');
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportDevice = await this.CPassportDevice.getInstance({
|
||||||
|
id: optionsArg.deviceId,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
if (!passportDevice) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportDevice.data.userId !== passportChallenge.data.userId) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport device user mismatch');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.data.metadata.requireLocation && !optionsArg.location) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Location evidence required');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passportChallenge.data.metadata.requireNfc && !optionsArg.nfc) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('NFC evidence required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const verified = this.verifyPassportSignature(
|
||||||
|
passportDevice.data.publicKeyX963Base64,
|
||||||
|
optionsArg.signatureBase64,
|
||||||
|
optionsArg.signatureFormat || 'raw',
|
||||||
|
this.buildChallengeSigningPayload(passportChallenge)
|
||||||
|
);
|
||||||
|
if (!verified) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport signature invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
await passportChallenge.markApproved({
|
||||||
|
signatureFormat: optionsArg.signatureFormat || 'raw',
|
||||||
|
location: optionsArg.location,
|
||||||
|
nfc: optionsArg.nfc,
|
||||||
|
});
|
||||||
|
|
||||||
|
passportDevice.data.lastSeenAt = Date.now();
|
||||||
|
await passportDevice.save();
|
||||||
|
|
||||||
|
await this.receptionRef.activityLogManager.logActivity(
|
||||||
|
passportChallenge.data.userId,
|
||||||
|
'passport_challenge_approved',
|
||||||
|
`Approved passport challenge ${passportChallenge.data.type}`,
|
||||||
|
{
|
||||||
|
targetId: passportChallenge.id,
|
||||||
|
targetType: 'passport-challenge',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return passportChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async listPendingChallengesForDevice(deviceIdArg: string) {
|
||||||
|
const passportChallenges = await this.CPassportChallenge.getInstances({
|
||||||
|
'data.deviceId': deviceIdArg,
|
||||||
|
'data.status': 'pending',
|
||||||
|
});
|
||||||
|
return passportChallenges.sort((leftArg, rightArg) => rightArg.data.createdAt - leftArg.data.createdAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getPassportChallengeByHint(deviceIdArg: string, hintIdArg: string) {
|
||||||
|
return this.CPassportChallenge.getInstance({
|
||||||
|
'data.deviceId': deviceIdArg,
|
||||||
|
'data.status': 'pending',
|
||||||
|
'data.notification.hintId': hintIdArg,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async markPassportChallengeSeen(deviceIdArg: string, hintIdArg: string) {
|
||||||
|
const passportChallenge = await this.getPassportChallengeByHint(deviceIdArg, hintIdArg);
|
||||||
|
if (!passportChallenge) {
|
||||||
|
throw new plugins.typedrequest.TypedResponseError('Passport challenge not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
passportChallenge.data.notification = {
|
||||||
|
...passportChallenge.data.notification!,
|
||||||
|
status: 'seen',
|
||||||
|
seenAt: Date.now(),
|
||||||
|
};
|
||||||
|
await passportChallenge.save();
|
||||||
|
return passportChallenge;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async cleanupExpiredChallenges() {
|
||||||
|
const passportChallenges = await this.CPassportChallenge.getInstances({});
|
||||||
|
for (const passportChallenge of passportChallenges) {
|
||||||
|
if (passportChallenge.data.status === 'pending' && passportChallenge.isExpired()) {
|
||||||
|
await passportChallenge.markExpired();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportNonces = await this.CPassportNonce.getInstances({});
|
||||||
|
for (const passportNonce of passportNonces) {
|
||||||
|
if (passportNonce.isExpired()) {
|
||||||
|
await passportNonce.delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async reDeliverPendingChallengeHints() {
|
||||||
|
const passportChallenges = await this.CPassportChallenge.getInstances({
|
||||||
|
'data.status': 'pending',
|
||||||
|
});
|
||||||
|
for (const passportChallenge of passportChallenges) {
|
||||||
|
if (!passportChallenge.data.notification || passportChallenge.data.notification.status === 'sent') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!passportChallenge.data.deviceId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const passportDevice = await this.CPassportDevice.getInstance({
|
||||||
|
id: passportChallenge.data.deviceId,
|
||||||
|
'data.status': 'active',
|
||||||
|
});
|
||||||
|
if (!passportDevice) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.receptionRef.passportPushManager.deliverChallengeHint(passportDevice, passportChallenge);
|
||||||
|
} catch (errorArg) {
|
||||||
|
logger.log('warn', `passport hint redelivery failed: ${(errorArg as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import type { PassportManager } from './classes.passportmanager.js';
|
||||||
|
|
||||||
|
@plugins.smartdata.Manager()
|
||||||
|
export class PassportNonce extends plugins.smartdata.SmartDataDbDoc<
|
||||||
|
PassportNonce,
|
||||||
|
plugins.idpInterfaces.data.IPassportNonce,
|
||||||
|
PassportManager
|
||||||
|
> {
|
||||||
|
public static hashNonce(nonceArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(nonceArg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@plugins.smartdata.unI()
|
||||||
|
public id: string;
|
||||||
|
|
||||||
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IPassportNonce['data'] = {
|
||||||
|
deviceId: '',
|
||||||
|
nonceHash: '',
|
||||||
|
createdAt: 0,
|
||||||
|
expiresAt: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
public isExpired(nowArg = Date.now()) {
|
||||||
|
return this.data.expiresAt < nowArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
|
import { Alert } from './classes.alert.js';
|
||||||
|
import { logger } from './logging.js';
|
||||||
|
import { PassportChallenge } from './classes.passportchallenge.js';
|
||||||
|
import { PassportDevice } from './classes.passportdevice.js';
|
||||||
|
import type { Reception } from './classes.reception.js';
|
||||||
|
|
||||||
|
interface IApnsConfig {
|
||||||
|
keyId: string;
|
||||||
|
teamId: string;
|
||||||
|
privateKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PassportPushManager {
|
||||||
|
public receptionRef: Reception;
|
||||||
|
|
||||||
|
constructor(receptionRefArg: Reception) {
|
||||||
|
this.receptionRef = receptionRefArg;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getApnsConfig(): Promise<IApnsConfig | null> {
|
||||||
|
try {
|
||||||
|
return {
|
||||||
|
keyId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_KEY_ID'),
|
||||||
|
teamId: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_TEAM_ID'),
|
||||||
|
privateKey: await this.receptionRef.serviceQenv.getEnvVarOnDemand('PASSPORT_APNS_PRIVATE_KEY'),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private base64UrlEncode(valueArg: string | Buffer) {
|
||||||
|
return Buffer.from(valueArg).toString('base64url');
|
||||||
|
}
|
||||||
|
|
||||||
|
private createApnsJwt(configArg: IApnsConfig) {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const header = this.base64UrlEncode(
|
||||||
|
JSON.stringify({ alg: 'ES256', kid: configArg.keyId, typ: 'JWT' })
|
||||||
|
);
|
||||||
|
const payload = this.base64UrlEncode(JSON.stringify({ iss: configArg.teamId, iat: nowSeconds }));
|
||||||
|
const unsignedToken = `${header}.${payload}`;
|
||||||
|
const signature = plugins.crypto.sign('sha256', Buffer.from(unsignedToken, 'utf8'), {
|
||||||
|
key: configArg.privateKey.replace(/\\n/g, '\n'),
|
||||||
|
dsaEncoding: 'ieee-p1363',
|
||||||
|
});
|
||||||
|
return `${unsignedToken}.${this.base64UrlEncode(signature)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async deliverApnsPayload(
|
||||||
|
passportDeviceArg: PassportDevice,
|
||||||
|
payloadArg: Record<string, any>
|
||||||
|
) {
|
||||||
|
if (!passportDeviceArg.data.pushRegistration) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
text: async () => 'Passport device has no push registration',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const apnsConfig = await this.getApnsConfig();
|
||||||
|
if (!apnsConfig) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
text: async () => 'APNs push transport is not configured',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const pushRegistration = passportDeviceArg.data.pushRegistration;
|
||||||
|
const apnsHost =
|
||||||
|
pushRegistration.environment === 'production'
|
||||||
|
? 'https://api.push.apple.com'
|
||||||
|
: 'https://api.sandbox.push.apple.com';
|
||||||
|
const authorizationToken = this.createApnsJwt(apnsConfig);
|
||||||
|
return fetch(`${apnsHost}/3/device/${pushRegistration.token}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
authorization: `bearer ${authorizationToken}`,
|
||||||
|
'apns-topic': pushRegistration.topic,
|
||||||
|
'apns-push-type': 'alert',
|
||||||
|
'content-type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payloadArg),
|
||||||
|
}).catch((errorArg: Error) => {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 0,
|
||||||
|
text: async () => errorArg.message,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deliverChallengeHint(passportDeviceArg: PassportDevice, passportChallengeArg: PassportChallenge) {
|
||||||
|
if (!passportDeviceArg.data.pushRegistration) {
|
||||||
|
passportChallengeArg.data.notification = {
|
||||||
|
...passportChallengeArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||||
|
lastError: 'Passport device has no push registration',
|
||||||
|
};
|
||||||
|
await passportChallengeArg.save();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await this.getApnsConfig())) {
|
||||||
|
passportChallengeArg.data.notification = {
|
||||||
|
...passportChallengeArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||||
|
lastError: 'APNs push transport is not configured',
|
||||||
|
};
|
||||||
|
await passportChallengeArg.save();
|
||||||
|
logger.log('warn', 'passport push delivery skipped because APNs is not configured');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.deliverApnsPayload(passportDeviceArg, {
|
||||||
|
aps: {
|
||||||
|
alert: {
|
||||||
|
title: passportChallengeArg.data.metadata.notificationTitle || 'idp.global challenge',
|
||||||
|
body: `Open idp.global to review your ${passportChallengeArg.data.type} request.`,
|
||||||
|
},
|
||||||
|
sound: 'default',
|
||||||
|
},
|
||||||
|
kind: 'passport_challenge',
|
||||||
|
hintId: passportChallengeArg.data.notification?.hintId,
|
||||||
|
challengeId: passportChallengeArg.id,
|
||||||
|
severity:
|
||||||
|
passportChallengeArg.data.type === 'physical_access' ? 'high' : passportChallengeArg.data.type,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
if (response.ok) {
|
||||||
|
passportDeviceArg.data.pushRegistration.lastDeliveredAt = Date.now();
|
||||||
|
passportDeviceArg.data.pushRegistration.lastError = undefined;
|
||||||
|
passportChallengeArg.data.notification = {
|
||||||
|
...passportChallengeArg.data.notification,
|
||||||
|
status: 'sent',
|
||||||
|
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||||
|
deliveredAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
await passportDeviceArg.save();
|
||||||
|
await passportChallengeArg.save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
passportDeviceArg.data.pushRegistration.lastError = responseText || `APNs error ${response.status}`;
|
||||||
|
passportChallengeArg.data.notification = {
|
||||||
|
...passportChallengeArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: (passportChallengeArg.data.notification?.attemptCount || 0) + 1,
|
||||||
|
lastError: responseText || `APNs error ${response.status}`,
|
||||||
|
};
|
||||||
|
await passportDeviceArg.save();
|
||||||
|
await passportChallengeArg.save();
|
||||||
|
logger.log('warn', `passport push delivery failed: ${responseText || response.status}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async deliverAlertHint(passportDeviceArg: PassportDevice, alertArg: Alert) {
|
||||||
|
if (!passportDeviceArg.data.pushRegistration) {
|
||||||
|
alertArg.data.notification = {
|
||||||
|
...alertArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||||
|
lastError: 'Passport device has no push registration',
|
||||||
|
};
|
||||||
|
await alertArg.save();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(await this.getApnsConfig())) {
|
||||||
|
alertArg.data.notification = {
|
||||||
|
...alertArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||||
|
lastError: 'APNs push transport is not configured',
|
||||||
|
};
|
||||||
|
await alertArg.save();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await this.deliverApnsPayload(passportDeviceArg, {
|
||||||
|
aps: {
|
||||||
|
alert: {
|
||||||
|
title: alertArg.data.title,
|
||||||
|
body: alertArg.data.body,
|
||||||
|
},
|
||||||
|
sound: 'default',
|
||||||
|
},
|
||||||
|
kind: 'passport_alert',
|
||||||
|
hintId: alertArg.data.notification.hintId,
|
||||||
|
alertId: alertArg.id,
|
||||||
|
severity: alertArg.data.severity,
|
||||||
|
eventType: alertArg.data.eventType,
|
||||||
|
});
|
||||||
|
|
||||||
|
const responseText = await response.text();
|
||||||
|
if (response.ok) {
|
||||||
|
passportDeviceArg.data.pushRegistration.lastDeliveredAt = Date.now();
|
||||||
|
passportDeviceArg.data.pushRegistration.lastError = undefined;
|
||||||
|
alertArg.data.notification = {
|
||||||
|
...alertArg.data.notification,
|
||||||
|
status: 'sent',
|
||||||
|
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||||
|
deliveredAt: Date.now(),
|
||||||
|
lastError: null,
|
||||||
|
};
|
||||||
|
await passportDeviceArg.save();
|
||||||
|
await alertArg.save();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
passportDeviceArg.data.pushRegistration.lastError = responseText || `APNs error ${response.status}`;
|
||||||
|
alertArg.data.notification = {
|
||||||
|
...alertArg.data.notification,
|
||||||
|
status: 'failed',
|
||||||
|
attemptCount: alertArg.data.notification.attemptCount + 1,
|
||||||
|
lastError: responseText || `APNs error ${response.status}`,
|
||||||
|
};
|
||||||
|
await passportDeviceArg.save();
|
||||||
|
await alertArg.save();
|
||||||
|
logger.log('warn', `passport alert push delivery failed: ${responseText || response.status}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,10 @@ import { AppConnectionManager } from './classes.appconnectionmanager.js';
|
|||||||
import { ActivityLogManager } from './classes.activitylogmanager.js';
|
import { ActivityLogManager } from './classes.activitylogmanager.js';
|
||||||
import { UserInvitationManager } from './classes.userinvitationmanager.js';
|
import { UserInvitationManager } from './classes.userinvitationmanager.js';
|
||||||
import { OidcManager } from './classes.oidcmanager.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 {
|
export interface IReceptionOptions {
|
||||||
/**
|
/**
|
||||||
@@ -47,7 +51,11 @@ export class Reception {
|
|||||||
public appManager = new AppManager(this);
|
public appManager = new AppManager(this);
|
||||||
public appConnectionManager = new AppConnectionManager(this);
|
public appConnectionManager = new AppConnectionManager(this);
|
||||||
public activityLogManager = new ActivityLogManager(this);
|
public activityLogManager = new ActivityLogManager(this);
|
||||||
|
public alertManager = new AlertManager(this);
|
||||||
public userInvitationManager = new UserInvitationManager(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);
|
public oidcManager = new OidcManager(this);
|
||||||
housekeeping = new ReceptionHousekeeping(this);
|
housekeeping = new ReceptionHousekeeping(this);
|
||||||
|
|
||||||
@@ -78,6 +86,7 @@ export class Reception {
|
|||||||
*/
|
*/
|
||||||
public async stop() {
|
public async stop() {
|
||||||
await this.housekeeping.stop();
|
await this.housekeeping.stop();
|
||||||
|
await this.oidcManager.stop();
|
||||||
console.log('stopped serviceserver!');
|
console.log('stopped serviceserver!');
|
||||||
await this.db.stop();
|
await this.db.stop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,191 +5,187 @@ import { logger } from './logging.js';
|
|||||||
import { User } from './classes.user.js';
|
import { User } from './classes.user.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* a RegistrationSession is a in memory session for signing up
|
* a RegistrationSession persists a sign up flow across restarts
|
||||||
*/
|
*/
|
||||||
export class RegistrationSession {
|
@plugins.smartdata.Manager()
|
||||||
// ======
|
export class RegistrationSession extends plugins.smartdata.SmartDataDbDoc<
|
||||||
// STATIC
|
RegistrationSession,
|
||||||
// ======
|
plugins.idpInterfaces.data.IRegistrationSession,
|
||||||
|
RegistrationSessionManager
|
||||||
|
> {
|
||||||
|
public static hashToken(tokenArg: string) {
|
||||||
|
return plugins.smarthash.sha256FromStringSync(tokenArg);
|
||||||
|
}
|
||||||
|
|
||||||
public static async createRegistrationSessionForEmail(
|
public static async createRegistrationSessionForEmail(
|
||||||
registrationSessionManageremailArg: RegistrationSessionManager,
|
|
||||||
emailArg: string
|
emailArg: string
|
||||||
) {
|
) {
|
||||||
const newRegistrationSession = new RegistrationSession(
|
const newRegistrationSession = new RegistrationSession();
|
||||||
registrationSessionManageremailArg,
|
newRegistrationSession.id = plugins.smartunique.shortId();
|
||||||
emailArg
|
newRegistrationSession.data.emailAddress = emailArg;
|
||||||
);
|
newRegistrationSession.data.validUntil =
|
||||||
const emailValidationResult = await newRegistrationSession
|
Date.now() + plugins.smarttime.getMilliSecondsFromUnits({ minutes: 10 });
|
||||||
.validateEMailAddress()
|
newRegistrationSession.data.createdAt = Date.now();
|
||||||
.catch((error) => {
|
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
const emailValidationResult = await newRegistrationSession.validateEMailAddress().catch(() => {
|
||||||
'Error occured during email provider & dns validation'
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
);
|
'Error occured during email provider & dns validation'
|
||||||
});
|
);
|
||||||
|
});
|
||||||
|
|
||||||
if (!emailValidationResult?.valid) {
|
if (!emailValidationResult?.valid) {
|
||||||
newRegistrationSession.destroy();
|
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'Email Address is not valid. Please use a correctly formated email address'
|
'Email Address is not valid. Please use a correctly formated email address'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (emailValidationResult.disposable) {
|
if (emailValidationResult.disposable) {
|
||||||
newRegistrationSession.destroy();
|
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'Email is disposable. Please use a non disposable email address.'
|
'Email is disposable. Please use a non disposable email address.'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
console.log(
|
|
||||||
`${newRegistrationSession.emailAddress} is valid. Continuing registration process!`
|
const validationToken = await newRegistrationSession.sendTokenValidationEmail();
|
||||||
);
|
newRegistrationSession.unhashedEmailToken = validationToken;
|
||||||
await newRegistrationSession.sendTokenValidationEmail();
|
|
||||||
console.log(`Successfully sent email validation email`);
|
|
||||||
return newRegistrationSession;
|
return newRegistrationSession;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========
|
@plugins.smartdata.unI()
|
||||||
// INSTANCE
|
public id: string;
|
||||||
// ========
|
|
||||||
public registrationSessionManagerRef: RegistrationSessionManager;
|
|
||||||
|
|
||||||
public emailAddress: string;
|
@plugins.smartdata.svDb()
|
||||||
|
public data: plugins.idpInterfaces.data.IRegistrationSession['data'] = {
|
||||||
|
emailAddress: '',
|
||||||
|
hashedEmailToken: '',
|
||||||
|
smsCodeHash: null,
|
||||||
|
smsvalidationCounter: 0,
|
||||||
|
status: 'announced',
|
||||||
|
validUntil: 0,
|
||||||
|
createdAt: 0,
|
||||||
|
collectedData: {
|
||||||
|
userData: {
|
||||||
|
username: null,
|
||||||
|
connectedOrgs: [],
|
||||||
|
email: null,
|
||||||
|
name: null,
|
||||||
|
status: null,
|
||||||
|
mobileNumber: null,
|
||||||
|
password: null,
|
||||||
|
passwordHash: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* only used during testing
|
* only used during testing
|
||||||
*/
|
*/
|
||||||
public unhashedEmailToken?: string;
|
public unhashedEmailToken?: string;
|
||||||
public hashedEmailToken: string;
|
|
||||||
private smsvalidationCounter = 0;
|
|
||||||
public smsCode: string;
|
|
||||||
|
|
||||||
/**
|
public get emailAddress() {
|
||||||
* the status of the registration. should progress in a linear fashion.
|
return this.data.emailAddress;
|
||||||
*/
|
}
|
||||||
public status: 'announced' | 'emailValidated' | 'mobileVerified' | 'registered' | 'failed' =
|
|
||||||
'announced';
|
|
||||||
|
|
||||||
public collectedData: {
|
public get status() {
|
||||||
userData: plugins.idpInterfaces.data.IUser['data'];
|
return this.data.status;
|
||||||
} = {
|
}
|
||||||
userData: {
|
|
||||||
username: null,
|
|
||||||
connectedOrgs: [],
|
|
||||||
email: null,
|
|
||||||
name: null,
|
|
||||||
status: null,
|
|
||||||
mobileNumber: null,
|
|
||||||
password: null,
|
|
||||||
passwordHash: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
constructor(
|
public set status(statusArg: plugins.idpInterfaces.data.TRegistrationSessionStatus) {
|
||||||
registrationSessionManagerRefArg: RegistrationSessionManager,
|
this.data.status = statusArg;
|
||||||
emailAddressArg: string
|
}
|
||||||
) {
|
|
||||||
this.registrationSessionManagerRef = registrationSessionManagerRefArg;
|
|
||||||
this.emailAddress = emailAddressArg;
|
|
||||||
this.registrationSessionManagerRef.registrationSessions.addToMap(this.emailAddress, this);
|
|
||||||
|
|
||||||
// lets destroy this after 10 minutes,
|
public get collectedData() {
|
||||||
// works in unrefed mode so not blocking node exiting.
|
return this.data.collectedData;
|
||||||
plugins.smartdelay.delayFor(600000, null, true).then(() => this.destroy());
|
}
|
||||||
|
|
||||||
|
public isExpired() {
|
||||||
|
return this.data.validUntil < Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* validates a token by comparing its hash against the stored hashed token
|
* validates a token by comparing its hash against the stored hashed token
|
||||||
* @param tokenArg
|
|
||||||
*/
|
*/
|
||||||
public validateEmailToken(tokenArg: string): boolean {
|
public async validateEmailToken(tokenArg: string): Promise<boolean> {
|
||||||
const result = this.hashedEmailToken === plugins.smarthash.sha256FromStringSync(tokenArg);
|
if (this.isExpired()) {
|
||||||
if (result && this.status === 'announced') {
|
await this.destroy();
|
||||||
this.status = 'emailValidated';
|
return false;
|
||||||
this.collectedData.userData.email = this.emailAddress;
|
|
||||||
}
|
}
|
||||||
if (!result && this.status === 'announced') {
|
|
||||||
this.status = 'failed';
|
const result = this.data.hashedEmailToken === RegistrationSession.hashToken(tokenArg);
|
||||||
|
if (result && this.data.status === 'announced') {
|
||||||
|
this.data.status = 'emailValidated';
|
||||||
|
this.data.collectedData.userData.email = this.data.emailAddress;
|
||||||
|
await this.save();
|
||||||
|
}
|
||||||
|
if (!result && this.data.status === 'announced') {
|
||||||
|
this.data.status = 'failed';
|
||||||
|
await this.save();
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** validates the sms code */
|
/** validates the sms code */
|
||||||
public validateSmsCode(smsCodeArg: string) {
|
public async validateSmsCode(smsCodeArg: string) {
|
||||||
this.smsvalidationCounter++;
|
this.data.smsvalidationCounter++;
|
||||||
const result = this.smsCode === smsCodeArg;
|
const result = this.data.smsCodeHash === RegistrationSession.hashToken(smsCodeArg);
|
||||||
if (this.status === 'emailValidated' && result) {
|
if (this.data.status === 'emailValidated' && result) {
|
||||||
this.status = 'mobileVerified';
|
this.data.status = 'mobileVerified';
|
||||||
|
await this.save();
|
||||||
return result;
|
return result;
|
||||||
} else {
|
|
||||||
if (this.smsvalidationCounter === 5) {
|
|
||||||
this.destroy();
|
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
|
||||||
'Registration cancelled due to repeated wrong verification code submission'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.data.smsvalidationCounter >= 5) {
|
||||||
|
await this.destroy();
|
||||||
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
|
'Registration cancelled due to repeated wrong verification code submission'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.save();
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* validate the email address with provider and dns sanity checks
|
|
||||||
* @returns
|
|
||||||
*/
|
|
||||||
public async validateEMailAddress(): Promise<plugins.smartmail.IEmailValidationResult> {
|
public async validateEMailAddress(): Promise<plugins.smartmail.IEmailValidationResult> {
|
||||||
console.log(`validating email ${this.emailAddress}`);
|
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.data.emailAddress);
|
||||||
const result = await new plugins.smartmail.EmailAddressValidator().validate(this.emailAddress);
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* send the validation email
|
|
||||||
*/
|
|
||||||
public async sendTokenValidationEmail() {
|
public async sendTokenValidationEmail() {
|
||||||
const uuidToSend = plugins.smartunique.uuid4();
|
const uuidToSend = plugins.smartunique.uuid4();
|
||||||
this.unhashedEmailToken = uuidToSend;
|
this.data.hashedEmailToken = RegistrationSession.hashToken(uuidToSend);
|
||||||
this.hashedEmailToken = plugins.smarthash.sha256FromStringSync(uuidToSend);
|
await this.save();
|
||||||
this.registrationSessionManagerRef.receptionRef.receptionMailer.sendRegistrationEmail(
|
this.manager.receptionRef.receptionMailer.sendRegistrationEmail(this, uuidToSend);
|
||||||
this,
|
logger.log('info', `sent a validation email with a verification code to ${this.data.emailAddress}`);
|
||||||
uuidToSend
|
return uuidToSend;
|
||||||
);
|
|
||||||
logger.log('info', `sent a validation email with a verification code to ${this.emailAddress}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* validate the mobile number of someone
|
|
||||||
*/
|
|
||||||
public async sendValidationSms() {
|
public async sendValidationSms() {
|
||||||
this.smsCode =
|
const smsCode =
|
||||||
await this.registrationSessionManagerRef.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation(
|
await this.manager.receptionRef.szPlatformClient.smsConnector.sendSmsVerifcation({
|
||||||
{
|
fromName: this.manager.receptionRef.options.name,
|
||||||
fromName: this.registrationSessionManagerRef.receptionRef.options.name,
|
toNumber: parseInt(this.data.collectedData.userData.mobileNumber),
|
||||||
toNumber: parseInt(this.collectedData.userData.mobileNumber),
|
});
|
||||||
}
|
this.data.smsCodeHash = RegistrationSession.hashToken(smsCode);
|
||||||
);
|
await this.save();
|
||||||
|
return smsCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* this method can be called when this registrationsession is validated
|
|
||||||
* and all data has been set
|
|
||||||
*/
|
|
||||||
public async manifestUserWithAccountData(): Promise<User> {
|
public async manifestUserWithAccountData(): Promise<User> {
|
||||||
if (this.status !== 'mobileVerified') {
|
if (this.data.status !== 'mobileVerified') {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'You can only manifest user that have a validated email Address and Mobile Number'
|
'You can only manifest user that have a validated email Address and Mobile Number'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (!this.collectedData) {
|
if (!this.data.collectedData) {
|
||||||
throw new Error('You have to set the accountdata first');
|
throw new Error('You have to set the accountdata first');
|
||||||
}
|
}
|
||||||
const manifestedUser =
|
const manifestedUser = await this.manager.receptionRef.userManager.CUser.createNewUserForUserData(
|
||||||
await this.registrationSessionManagerRef.receptionRef.userManager.CUser.createNewUserForUserData(
|
this.data.collectedData.userData as plugins.idpInterfaces.data.IUser['data']
|
||||||
this.collectedData.userData
|
);
|
||||||
);
|
this.data.status = 'registered';
|
||||||
|
await this.save();
|
||||||
return manifestedUser;
|
return manifestedUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public async destroy() {
|
||||||
* destroys the registrationsession
|
await this.delete();
|
||||||
*/
|
|
||||||
public destroy() {
|
|
||||||
this.registrationSessionManagerRef.registrationSessions.removeFromMap(this.emailAddress);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,14 @@ import { logger } from './logging.js';
|
|||||||
|
|
||||||
export class RegistrationSessionManager {
|
export class RegistrationSessionManager {
|
||||||
public receptionRef: Reception;
|
public receptionRef: Reception;
|
||||||
|
|
||||||
public registrationSessions = new plugins.lik.FastMap<RegistrationSession>();
|
|
||||||
public typedRouter = new plugins.typedrequest.TypedRouter();
|
public typedRouter = new plugins.typedrequest.TypedRouter();
|
||||||
|
|
||||||
|
public get db() {
|
||||||
|
return this.receptionRef.db.smartdataDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
public CRegistrationSession = plugins.smartdata.setDefaultManagerForDoc(this, RegistrationSession);
|
||||||
|
|
||||||
constructor(receptionRefArg: Reception) {
|
constructor(receptionRefArg: Reception) {
|
||||||
this.receptionRef = receptionRefArg;
|
this.receptionRef = receptionRefArg;
|
||||||
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
|
||||||
@@ -29,17 +33,16 @@ export class RegistrationSessionManager {
|
|||||||
`We sent you an Email with more information.`
|
`We sent you an Email with more information.`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// check for exiting SignupSession
|
|
||||||
const existingSession = this.registrationSessions.getByKey(requestData.email);
|
const existingSessions = await this.CRegistrationSession.getInstances({
|
||||||
if (existingSession) {
|
'data.emailAddress': requestData.email,
|
||||||
|
});
|
||||||
|
for (const existingSession of existingSessions) {
|
||||||
logger.log('warn', `destroyed old signupSession for ${requestData.email}`);
|
logger.log('warn', `destroyed old signupSession for ${requestData.email}`);
|
||||||
existingSession.destroy();
|
await existingSession.destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
// lets check the email before we create a signup session
|
|
||||||
|
|
||||||
const newSignupSession = await RegistrationSession.createRegistrationSessionForEmail(
|
const newSignupSession = await RegistrationSession.createRegistrationSessionForEmail(
|
||||||
this,
|
|
||||||
requestData.email
|
requestData.email
|
||||||
).catch((e: plugins.typedrequest.TypedResponseError) => {
|
).catch((e: plugins.typedrequest.TypedResponseError) => {
|
||||||
console.log(e.errorText);
|
console.log(e.errorText);
|
||||||
@@ -63,10 +66,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_AfterRegistrationEmailClicked>(
|
||||||
'afterRegistrationEmailClicked',
|
'afterRegistrationEmailClicked',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
console.log(requestData);
|
const signupSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
const signupSession = await this.registrationSessions.find(async (itemArg) =>
|
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (signupSession) {
|
if (signupSession) {
|
||||||
return {
|
return {
|
||||||
email: signupSession.emailAddress,
|
email: signupSession.emailAddress,
|
||||||
@@ -86,9 +86,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_SetDataForRegistration>(
|
||||||
'setDataForRegistration',
|
'setDataForRegistration',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (!registrationSession) {
|
if (!registrationSession) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'could not find a matching signupsession'
|
'could not find a matching signupsession'
|
||||||
@@ -114,9 +112,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_MobileVerificationForRegistration>(
|
||||||
'mobileVerificationForRegistration',
|
'mobileVerificationForRegistration',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (!registrationSession) {
|
if (!registrationSession) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'could not find a matching signupsession'
|
'could not find a matching signupsession'
|
||||||
@@ -131,17 +127,16 @@ export class RegistrationSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (requestData.mobileNumber) {
|
if (requestData.mobileNumber) {
|
||||||
registrationSession.status = 'emailValidated';
|
|
||||||
registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber;
|
registrationSession.collectedData.userData.mobileNumber = requestData.mobileNumber;
|
||||||
await registrationSession.sendValidationSms();
|
const smsCode = await registrationSession.sendValidationSms();
|
||||||
return {
|
return {
|
||||||
messageSent: true,
|
messageSent: true,
|
||||||
testOnlySmsCode: process.env.TEST_MODE ? registrationSession.smsCode : null,
|
testOnlySmsCode: process.env.TEST_MODE ? smsCode : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (requestData.verificationCode) {
|
if (requestData.verificationCode) {
|
||||||
const validationResult = registrationSession.validateSmsCode(
|
const validationResult = await registrationSession.validateSmsCode(
|
||||||
requestData.verificationCode
|
requestData.verificationCode
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -160,9 +155,7 @@ export class RegistrationSessionManager {
|
|||||||
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
new plugins.typedrequest.TypedHandler<plugins.idpInterfaces.request.IReq_FinishRegistration>(
|
||||||
'finishRegistration',
|
'finishRegistration',
|
||||||
async (requestData) => {
|
async (requestData) => {
|
||||||
const registrationSession = await this.registrationSessions.find(async (itemArg) =>
|
const registrationSession = await this.findRegistrationSessionByToken(requestData.token);
|
||||||
itemArg.validateEmailToken(requestData.token)
|
|
||||||
);
|
|
||||||
if (!registrationSession) {
|
if (!registrationSession) {
|
||||||
throw new plugins.typedrequest.TypedResponseError(
|
throw new plugins.typedrequest.TypedResponseError(
|
||||||
'could not find a matching signupsession'
|
'could not find a matching signupsession'
|
||||||
@@ -170,7 +163,7 @@ export class RegistrationSessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const resultingUser = await registrationSession.manifestUserWithAccountData();
|
const resultingUser = await registrationSession.manifestUserWithAccountData();
|
||||||
registrationSession.destroy();
|
await registrationSession.destroy();
|
||||||
this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser);
|
this.receptionRef.receptionMailer.sendWelcomeEMail(resultingUser);
|
||||||
return {
|
return {
|
||||||
accountData: {
|
accountData: {
|
||||||
@@ -187,4 +180,17 @@ export class RegistrationSessionManager {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async findRegistrationSessionByToken(tokenArg: string) {
|
||||||
|
const registrationSession = await this.CRegistrationSession.getInstance({
|
||||||
|
'data.hashedEmailToken': RegistrationSession.hashToken(tokenArg),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!registrationSession) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValid = await registrationSession.validateEmailToken(tokenArg);
|
||||||
|
return isValid ? registrationSession : null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,18 @@ export class IdpRequests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public get completeOidcAuthorization() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization>(
|
||||||
|
'completeOidcAuthorization'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public get prepareOidcAuthorization() {
|
||||||
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization>(
|
||||||
|
'prepareOidcAuthorization'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public get resetPassword() {
|
public get resetPassword() {
|
||||||
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
return this.idpClientArg.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_ResetPassword>(
|
||||||
'resetPassword'
|
'resetPassword'
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export interface IAbuseWindow {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
action: string;
|
||||||
|
identifierHash: string;
|
||||||
|
attemptCount: number;
|
||||||
|
windowStartedAt: number;
|
||||||
|
blockedUntil: number;
|
||||||
|
validUntil: number;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -3,6 +3,9 @@ export type TActivityAction =
|
|||||||
| 'logout'
|
| 'logout'
|
||||||
| 'session_created'
|
| 'session_created'
|
||||||
| 'session_revoked'
|
| 'session_revoked'
|
||||||
|
| 'passport_device_enrolled'
|
||||||
|
| 'passport_device_revoked'
|
||||||
|
| 'passport_challenge_approved'
|
||||||
| 'org_created'
|
| 'org_created'
|
||||||
| 'org_joined'
|
| 'org_joined'
|
||||||
| 'org_left'
|
| 'org_left'
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
export type TAlertSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
|
||||||
|
export type TAlertStatus = 'pending' | 'seen' | 'dismissed';
|
||||||
|
|
||||||
|
export type TAlertCategory = 'security' | 'admin' | 'system';
|
||||||
|
|
||||||
|
export type TAlertNotificationStatus = 'pending' | 'sent' | 'failed' | 'seen';
|
||||||
|
|
||||||
|
export interface IAlert {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
recipientUserId: string;
|
||||||
|
organizationId?: string;
|
||||||
|
category: TAlertCategory;
|
||||||
|
eventType: string;
|
||||||
|
severity: TAlertSeverity;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
actorUserId?: string;
|
||||||
|
relatedEntityId?: string;
|
||||||
|
relatedEntityType?: string;
|
||||||
|
notification: {
|
||||||
|
hintId: string;
|
||||||
|
status: TAlertNotificationStatus;
|
||||||
|
attemptCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
deliveredAt?: number | null;
|
||||||
|
seenAt?: number | null;
|
||||||
|
lastError?: string | null;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
seenAt?: number | null;
|
||||||
|
dismissedAt?: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import type { TAlertSeverity } from './alert.js';
|
||||||
|
|
||||||
|
export type TAlertRuleScope = 'global' | 'organization';
|
||||||
|
|
||||||
|
export type TAlertRuleRecipientMode = 'global_admins' | 'org_admins' | 'specific_users';
|
||||||
|
|
||||||
|
export interface IAlertRule {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
scope: TAlertRuleScope;
|
||||||
|
organizationId?: string;
|
||||||
|
eventType: string;
|
||||||
|
minimumSeverity: TAlertSeverity;
|
||||||
|
recipientMode: TAlertRuleRecipientMode;
|
||||||
|
recipientUserIds?: string[];
|
||||||
|
push: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
createdByUserId: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { TAppType } from './loint-reception.app.js';
|
import type { TAppType } from './app.js';
|
||||||
|
|
||||||
export type TAppConnectionStatus = 'active' | 'disconnected';
|
export type TAppConnectionStatus = 'active' | 'disconnected';
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export type TSupportedCurrency = 'EUR';
|
export type TSupportedCurrency = 'EUR';
|
||||||
|
|
||||||
@@ -1,3 +1,3 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export interface IDevice extends plugins.tsclass.network.IDevice {}
|
export interface IDevice extends plugins.tsclass.network.IDevice {}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export type TEmailActionTokenAction = 'emailLogin' | 'passwordReset';
|
||||||
|
|
||||||
|
export interface IEmailActionToken {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
email: string;
|
||||||
|
action: TEmailActionTokenAction;
|
||||||
|
tokenHash: string;
|
||||||
|
validUntil: number;
|
||||||
|
createdAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
+21
-13
@@ -1,13 +1,21 @@
|
|||||||
export * from './loint-reception.activity.js';
|
export * from './abusewindow.js';
|
||||||
export * from './loint-reception.app.js';
|
export * from './activity.js';
|
||||||
export * from './loint-reception.oidc.js';
|
export * from './alert.js';
|
||||||
export * from './loint-reception.appconnection.js';
|
export * from './alertrule.js';
|
||||||
export * from './loint-reception.billingplan.js';
|
export * from './app.js';
|
||||||
export * from './loint-reception.device.js';
|
export * from './emailactiontoken.js';
|
||||||
export * from './loint-reception.jwt.js';
|
export * from './oidc.js';
|
||||||
export * from './loint-reception.loginsession.js';
|
export * from './appconnection.js';
|
||||||
export * from './loint-reception.organization.js';
|
export * from './billingplan.js';
|
||||||
export * from './loint-reception.paddlecheckoutdata.js';
|
export * from './device.js';
|
||||||
export * from './loint-reception.role.js';
|
export * from './jwt.js';
|
||||||
export * from './loint-reception.user.js';
|
export * from './loginsession.js';
|
||||||
export * from './loint-reception.userinvitation.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';
|
||||||
|
export * from './userinvitation.js';
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import { type IBillingPlan } from './loint-reception.billingplan.js';
|
|
||||||
import { type IRole } from './loint-reception.role.js';
|
|
||||||
|
|
||||||
export interface IOrganization {
|
|
||||||
id: string;
|
|
||||||
data: {
|
|
||||||
name: string;
|
|
||||||
slug: string;
|
|
||||||
billingPlanId: string;
|
|
||||||
roleIds: string[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -11,86 +11,94 @@ export type TOidcScope = 'openid' | 'profile' | 'email' | 'organizations' | 'rol
|
|||||||
* Authorization code for OAuth 2.0 authorization code flow
|
* Authorization code for OAuth 2.0 authorization code flow
|
||||||
*/
|
*/
|
||||||
export interface IAuthorizationCode {
|
export interface IAuthorizationCode {
|
||||||
/** The authorization code string */
|
id: string;
|
||||||
code: string;
|
data: {
|
||||||
/** OAuth client ID */
|
/** Hashed authorization code string */
|
||||||
clientId: string;
|
codeHash: string;
|
||||||
/** User ID who authorized */
|
/** OAuth client ID */
|
||||||
userId: string;
|
clientId: string;
|
||||||
/** Scopes granted */
|
/** User ID who authorized */
|
||||||
scopes: TOidcScope[];
|
userId: string;
|
||||||
/** Redirect URI used in authorization request */
|
/** Scopes granted */
|
||||||
redirectUri: string;
|
scopes: TOidcScope[];
|
||||||
/** PKCE code challenge (S256 hashed) */
|
/** Redirect URI used in authorization request */
|
||||||
codeChallenge?: string;
|
redirectUri: string;
|
||||||
/** PKCE code challenge method */
|
/** PKCE code challenge (S256 hashed) */
|
||||||
codeChallengeMethod?: 'S256';
|
codeChallenge?: string;
|
||||||
/** Nonce from authorization request (for ID token) */
|
/** PKCE code challenge method */
|
||||||
nonce?: string;
|
codeChallengeMethod?: 'S256';
|
||||||
/** Expiration timestamp (10 minutes from creation) */
|
/** Nonce from authorization request (for ID token) */
|
||||||
expiresAt: number;
|
nonce?: string;
|
||||||
/** Whether the code has been used (single-use) */
|
/** Expiration timestamp (10 minutes from creation) */
|
||||||
used: boolean;
|
expiresAt: number;
|
||||||
|
/** Creation timestamp */
|
||||||
|
issuedAt: number;
|
||||||
|
/** Whether the code has been used (single-use) */
|
||||||
|
used: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OIDC Access Token (opaque or JWT)
|
* OIDC Access Token (opaque or JWT)
|
||||||
*/
|
*/
|
||||||
export interface IOidcAccessToken {
|
export interface IOidcAccessToken {
|
||||||
/** Token identifier */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** The access token string (or hash for storage) */
|
data: {
|
||||||
tokenHash: string;
|
/** The access token string hash for storage */
|
||||||
/** OAuth client ID */
|
tokenHash: string;
|
||||||
clientId: string;
|
/** OAuth client ID */
|
||||||
/** User ID */
|
clientId: string;
|
||||||
userId: string;
|
/** User ID */
|
||||||
/** Granted scopes */
|
userId: string;
|
||||||
scopes: TOidcScope[];
|
/** Granted scopes */
|
||||||
/** Expiration timestamp */
|
scopes: TOidcScope[];
|
||||||
expiresAt: number;
|
/** Expiration timestamp */
|
||||||
/** Creation timestamp */
|
expiresAt: number;
|
||||||
issuedAt: number;
|
/** Creation timestamp */
|
||||||
|
issuedAt: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OIDC Refresh Token
|
* OIDC Refresh Token
|
||||||
*/
|
*/
|
||||||
export interface IOidcRefreshToken {
|
export interface IOidcRefreshToken {
|
||||||
/** Token identifier */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** The refresh token string (or hash for storage) */
|
data: {
|
||||||
tokenHash: string;
|
/** The refresh token string hash for storage */
|
||||||
/** OAuth client ID */
|
tokenHash: string;
|
||||||
clientId: string;
|
/** OAuth client ID */
|
||||||
/** User ID */
|
clientId: string;
|
||||||
userId: string;
|
/** User ID */
|
||||||
/** Granted scopes */
|
userId: string;
|
||||||
scopes: TOidcScope[];
|
/** Granted scopes */
|
||||||
/** Expiration timestamp */
|
scopes: TOidcScope[];
|
||||||
expiresAt: number;
|
/** Expiration timestamp */
|
||||||
/** Creation timestamp */
|
expiresAt: number;
|
||||||
issuedAt: number;
|
/** Creation timestamp */
|
||||||
/** Whether the token has been revoked */
|
issuedAt: number;
|
||||||
revoked: boolean;
|
/** Whether the token has been revoked */
|
||||||
|
revoked: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User consent record for an OAuth client
|
* User consent record for an OAuth client
|
||||||
*/
|
*/
|
||||||
export interface IUserConsent {
|
export interface IUserConsent {
|
||||||
/** Unique identifier */
|
|
||||||
id: string;
|
id: string;
|
||||||
/** User who gave consent */
|
data: {
|
||||||
userId: string;
|
/** User who gave consent */
|
||||||
/** OAuth client ID */
|
userId: string;
|
||||||
clientId: string;
|
/** OAuth client ID */
|
||||||
/** Scopes the user consented to */
|
clientId: string;
|
||||||
scopes: TOidcScope[];
|
/** Scopes the user consented to */
|
||||||
/** When consent was granted */
|
scopes: TOidcScope[];
|
||||||
grantedAt: number;
|
/** When consent was granted */
|
||||||
/** When consent was last updated */
|
grantedAt: number;
|
||||||
updatedAt: number;
|
/** When consent was last updated */
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { type IBillingPlan } from './billingplan.js';
|
||||||
|
import { type IRole } from './role.js';
|
||||||
|
|
||||||
|
export interface IOrganization {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
name: string;
|
||||||
|
slug: string;
|
||||||
|
billingPlanId: string;
|
||||||
|
roleIds: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { IPassportCapabilities } from './passportdevice.js';
|
||||||
|
|
||||||
|
export type TPassportChallengeType =
|
||||||
|
| 'device_enrollment'
|
||||||
|
| 'authentication'
|
||||||
|
| 'step_up'
|
||||||
|
| 'physical_access';
|
||||||
|
|
||||||
|
export type TPassportChallengeStatus = 'pending' | 'approved' | 'expired' | 'rejected';
|
||||||
|
|
||||||
|
export type TPassportChallengeDeliveryStatus = 'pending' | 'sent' | 'failed' | 'seen';
|
||||||
|
|
||||||
|
export type TPassportSignatureFormat = 'raw' | 'der';
|
||||||
|
|
||||||
|
export interface IPassportLocationEvidence {
|
||||||
|
latitude: number;
|
||||||
|
longitude: number;
|
||||||
|
accuracyMeters: number;
|
||||||
|
capturedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPassportNfcEvidence {
|
||||||
|
tagId?: string;
|
||||||
|
readerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPassportChallenge {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
userId: string;
|
||||||
|
deviceId?: string | null;
|
||||||
|
type: TPassportChallengeType;
|
||||||
|
status: TPassportChallengeStatus;
|
||||||
|
tokenHash?: string | null;
|
||||||
|
challenge: string;
|
||||||
|
metadata: {
|
||||||
|
originHost?: string;
|
||||||
|
audience?: string;
|
||||||
|
notificationTitle?: string;
|
||||||
|
deviceLabel?: string;
|
||||||
|
requireLocation: boolean;
|
||||||
|
requireNfc: boolean;
|
||||||
|
requestedCapabilities?: Partial<IPassportCapabilities>;
|
||||||
|
};
|
||||||
|
evidence?: {
|
||||||
|
signatureFormat?: TPassportSignatureFormat;
|
||||||
|
location?: IPassportLocationEvidence;
|
||||||
|
nfc?: IPassportNfcEvidence;
|
||||||
|
};
|
||||||
|
notification?: {
|
||||||
|
hintId: string;
|
||||||
|
status: TPassportChallengeDeliveryStatus;
|
||||||
|
attemptCount: number;
|
||||||
|
createdAt: number;
|
||||||
|
deliveredAt?: number | null;
|
||||||
|
seenAt?: number | null;
|
||||||
|
lastError?: string | null;
|
||||||
|
};
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
completedAt?: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export type TPassportDevicePlatform =
|
||||||
|
| 'ios'
|
||||||
|
| 'ipados'
|
||||||
|
| 'macos'
|
||||||
|
| 'watchos'
|
||||||
|
| 'android'
|
||||||
|
| 'web'
|
||||||
|
| 'unknown';
|
||||||
|
|
||||||
|
export type TPassportDeviceStatus = 'active' | 'revoked';
|
||||||
|
|
||||||
|
export type TPassportPushProvider = 'apns';
|
||||||
|
|
||||||
|
export type TPassportPushEnvironment = 'development' | 'production';
|
||||||
|
|
||||||
|
export interface IPassportCapabilities {
|
||||||
|
gps: boolean;
|
||||||
|
nfc: boolean;
|
||||||
|
push: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPassportDevice {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
userId: string;
|
||||||
|
label: string;
|
||||||
|
platform: TPassportDevicePlatform;
|
||||||
|
status: TPassportDeviceStatus;
|
||||||
|
publicKeyAlgorithm: 'p256';
|
||||||
|
publicKeyX963Base64: string;
|
||||||
|
capabilities: IPassportCapabilities;
|
||||||
|
pushRegistration?: {
|
||||||
|
provider: TPassportPushProvider;
|
||||||
|
token: string;
|
||||||
|
topic: string;
|
||||||
|
environment: TPassportPushEnvironment;
|
||||||
|
registeredAt: number;
|
||||||
|
lastDeliveredAt?: number;
|
||||||
|
lastError?: string;
|
||||||
|
};
|
||||||
|
appVersion?: string;
|
||||||
|
createdAt: number;
|
||||||
|
lastSeenAt?: number;
|
||||||
|
lastChallengeAt?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export interface IPassportNonce {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
deviceId: string;
|
||||||
|
nonceHash: string;
|
||||||
|
createdAt: number;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { type IRole } from './loint-reception.role.js';
|
import { type IRole } from './role.js';
|
||||||
|
|
||||||
export interface ISubOrgProperty {
|
export interface ISubOrgProperty {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type TRegistrationSessionStatus =
|
||||||
|
| 'announced'
|
||||||
|
| 'emailValidated'
|
||||||
|
| 'mobileVerified'
|
||||||
|
| 'registered'
|
||||||
|
| 'failed';
|
||||||
|
|
||||||
|
export interface IRegistrationSession {
|
||||||
|
id: string;
|
||||||
|
data: {
|
||||||
|
emailAddress: string;
|
||||||
|
hashedEmailToken: string;
|
||||||
|
smsCodeHash?: string | null;
|
||||||
|
smsvalidationCounter: number;
|
||||||
|
status: TRegistrationSessionStatus;
|
||||||
|
validUntil: number;
|
||||||
|
createdAt: number;
|
||||||
|
collectedData: {
|
||||||
|
userData: {
|
||||||
|
username?: string | null;
|
||||||
|
connectedOrgs: string[];
|
||||||
|
email?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
status?: 'new' | 'active' | 'deleted' | 'suspended' | null;
|
||||||
|
mobileNumber?: string | null;
|
||||||
|
password?: string | null;
|
||||||
|
passwordHash?: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
/** Standard role types available in all organizations */
|
/** Standard role types available in all organizations */
|
||||||
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
|
export type TStandardRole = 'owner' | 'admin' | 'editor' | 'guest' | 'viewer' | 'outlaw';
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { type IRole } from './loint-reception.role.js';
|
import { type IRole } from './role.js';
|
||||||
|
|
||||||
export interface IUser {
|
export interface IUser {
|
||||||
id: string;
|
id: string;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A UserInvitation represents an invitation to join an organization.
|
* A UserInvitation represents an invitation to join an organization.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as data from '../data/index.js';
|
||||||
|
import type { IPassportDeviceSignedRequest } from './passport.js';
|
||||||
|
|
||||||
|
export interface IReq_ListPassportAlerts
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListPassportAlerts
|
||||||
|
> {
|
||||||
|
method: 'listPassportAlerts';
|
||||||
|
request: IPassportDeviceSignedRequest;
|
||||||
|
response: {
|
||||||
|
alerts: data.IAlert[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetPassportAlertByHint
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetPassportAlertByHint
|
||||||
|
> {
|
||||||
|
method: 'getPassportAlertByHint';
|
||||||
|
request: IPassportDeviceSignedRequest & {
|
||||||
|
hintId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
alert?: data.IAlert;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_MarkPassportAlertSeen
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_MarkPassportAlertSeen
|
||||||
|
> {
|
||||||
|
method: 'markPassportAlertSeen';
|
||||||
|
request: IPassportDeviceSignedRequest & {
|
||||||
|
hintId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_UpsertAlertRule
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_UpsertAlertRule
|
||||||
|
> {
|
||||||
|
method: 'upsertAlertRule';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
ruleId?: string;
|
||||||
|
scope: data.TAlertRuleScope;
|
||||||
|
organizationId?: string;
|
||||||
|
eventType: string;
|
||||||
|
minimumSeverity: data.TAlertSeverity;
|
||||||
|
recipientMode: data.TAlertRuleRecipientMode;
|
||||||
|
recipientUserIds?: string[];
|
||||||
|
push: boolean;
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
rule: data.IAlertRule;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetAlertRules
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetAlertRules
|
||||||
|
> {
|
||||||
|
method: 'getAlertRules';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
scope?: data.TAlertRuleScope;
|
||||||
|
organizationId?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
rules: data.IAlertRule[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_DeleteAlertRule
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_DeleteAlertRule
|
||||||
|
> {
|
||||||
|
method: 'deleteAlertRule';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
ruleId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
// Get all global apps
|
// Get all global apps
|
||||||
export interface IReq_GetGlobalApps
|
export interface IReq_GetGlobalApps
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import { type IUser, type IRole } from '../data/index.js';
|
||||||
|
import { type TOidcScope } from '../data/index.js';
|
||||||
|
|
||||||
|
export interface IReq_InternalAuthorization
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_InternalAuthorization
|
||||||
|
> {
|
||||||
|
method: '';
|
||||||
|
request: {
|
||||||
|
accountData: IUser;
|
||||||
|
jwt: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
accountData: IUser;
|
||||||
|
jwt: string;
|
||||||
|
relevantRoles: IRole[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CompleteOidcAuthorization
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CompleteOidcAuthorization
|
||||||
|
> {
|
||||||
|
method: 'completeOidcAuthorization';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
scope: string;
|
||||||
|
state: string;
|
||||||
|
prompt?: 'none' | 'login' | 'consent';
|
||||||
|
codeChallenge?: string;
|
||||||
|
codeChallengeMethod?: 'S256';
|
||||||
|
nonce?: string;
|
||||||
|
consentApproved?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
code: string;
|
||||||
|
redirectUrl: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_PrepareOidcAuthorization
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_PrepareOidcAuthorization
|
||||||
|
> {
|
||||||
|
method: 'prepareOidcAuthorization';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
clientId: string;
|
||||||
|
redirectUri: string;
|
||||||
|
scope: string;
|
||||||
|
state: string;
|
||||||
|
prompt?: 'none' | 'login' | 'consent';
|
||||||
|
codeChallenge?: string;
|
||||||
|
codeChallengeMethod?: 'S256';
|
||||||
|
nonce?: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
status: 'ready' | 'consent_required';
|
||||||
|
clientId: string;
|
||||||
|
appName: string;
|
||||||
|
appUrl: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
requestedScopes: TOidcScope[];
|
||||||
|
grantedScopes: TOidcScope[];
|
||||||
|
};
|
||||||
|
}
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
|
|
||||||
export interface IReq_UpdatePaymentMethod
|
export interface IReq_UpdatePaymentMethod
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
export * from './loint-reception.admin.js';
|
export * from './admin.js';
|
||||||
export * from './loint-reception.apitoken.js';
|
export * from './apitoken.js';
|
||||||
export * from './loint-reception.app.js';
|
export * from './alert.js';
|
||||||
export * from './loint-reception.authorization.js';
|
export * from './app.js';
|
||||||
export * from './loint-reception.billingplan.js';
|
export * from './authorization.js';
|
||||||
export * from './loint-reception.jwt.js';
|
export * from './billingplan.js';
|
||||||
export * from './loint-reception.login.js';
|
export * from './jwt.js';
|
||||||
export * from './loint-reception.organization.js';
|
export * from './login.js';
|
||||||
export * from './loint-reception.plan.js';
|
export * from './organization.js';
|
||||||
export * from './loint-reception.registration.js';
|
export * from './passport.js';
|
||||||
export * from './loint-reception.user.js';
|
export * from './plan.js';
|
||||||
export * from './loint-reception.userinvitation.js';
|
export * from './registration.js';
|
||||||
|
export * from './user.js';
|
||||||
|
export * from './userinvitation.js';
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request to get the public key for JWT validation.
|
* Request to get the public key for JWT validation.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
|
|
||||||
export interface IReq_LoginWithEmailOrUsernameAndPassword
|
export interface IReq_LoginWithEmailOrUsernameAndPassword
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
|
||||||
import { type IUser, type IRole } from '../data/index.js';
|
|
||||||
|
|
||||||
export interface IReq_InternalAuthorization
|
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
|
||||||
plugins.typedRequestInterfaces.ITypedRequest,
|
|
||||||
IReq_InternalAuthorization
|
|
||||||
> {
|
|
||||||
method: '';
|
|
||||||
request: {
|
|
||||||
accountData: IUser;
|
|
||||||
jwt: string;
|
|
||||||
};
|
|
||||||
response: {
|
|
||||||
accountData: IUser;
|
|
||||||
jwt: string;
|
|
||||||
relevantRoles: IRole[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export interface IReq_GetOrganizationById
|
export interface IReq_GetOrganizationById
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import * as plugins from '../plugins.js';
|
||||||
|
import * as data from '../data/index.js';
|
||||||
|
|
||||||
|
export interface IPassportDeviceSignedRequest {
|
||||||
|
deviceId: string;
|
||||||
|
timestamp: number;
|
||||||
|
nonce: string;
|
||||||
|
signatureBase64: string;
|
||||||
|
signatureFormat?: data.TPassportSignatureFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CreatePassportEnrollmentChallenge
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreatePassportEnrollmentChallenge
|
||||||
|
> {
|
||||||
|
method: 'createPassportEnrollmentChallenge';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
deviceLabel: string;
|
||||||
|
platform: data.TPassportDevicePlatform;
|
||||||
|
appVersion?: string;
|
||||||
|
capabilities?: Partial<data.IPassportCapabilities>;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
challengeId: string;
|
||||||
|
pairingToken: string;
|
||||||
|
pairingPayload: string;
|
||||||
|
signingPayload: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CompletePassportEnrollment
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CompletePassportEnrollment
|
||||||
|
> {
|
||||||
|
method: 'completePassportEnrollment';
|
||||||
|
request: {
|
||||||
|
pairingToken: string;
|
||||||
|
deviceLabel: string;
|
||||||
|
platform: data.TPassportDevicePlatform;
|
||||||
|
publicKeyX963Base64: string;
|
||||||
|
signatureBase64: string;
|
||||||
|
signatureFormat?: data.TPassportSignatureFormat;
|
||||||
|
appVersion?: string;
|
||||||
|
capabilities?: Partial<data.IPassportCapabilities>;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
device: data.IPassportDevice;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetPassportDevices
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetPassportDevices
|
||||||
|
> {
|
||||||
|
method: 'getPassportDevices';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
devices: data.IPassportDevice[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_RevokePassportDevice
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RevokePassportDevice
|
||||||
|
> {
|
||||||
|
method: 'revokePassportDevice';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
deviceId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_CreatePassportChallenge
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_CreatePassportChallenge
|
||||||
|
> {
|
||||||
|
method: 'createPassportChallenge';
|
||||||
|
request: {
|
||||||
|
jwt: string;
|
||||||
|
type?: Exclude<data.TPassportChallengeType, 'device_enrollment'>;
|
||||||
|
preferredDeviceId?: string;
|
||||||
|
audience?: string;
|
||||||
|
notificationTitle?: string;
|
||||||
|
requireLocation?: boolean;
|
||||||
|
requireNfc?: boolean;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
challengeId: string;
|
||||||
|
challenge: string;
|
||||||
|
signingPayload: string;
|
||||||
|
deviceId: string;
|
||||||
|
expiresAt: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_ApprovePassportChallenge
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ApprovePassportChallenge
|
||||||
|
> {
|
||||||
|
method: 'approvePassportChallenge';
|
||||||
|
request: {
|
||||||
|
challengeId: string;
|
||||||
|
deviceId: string;
|
||||||
|
signatureBase64: string;
|
||||||
|
signatureFormat?: data.TPassportSignatureFormat;
|
||||||
|
location?: data.IPassportLocationEvidence;
|
||||||
|
nfc?: data.IPassportNfcEvidence;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
challenge: data.IPassportChallenge;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_RegisterPassportPushToken
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_RegisterPassportPushToken
|
||||||
|
> {
|
||||||
|
method: 'registerPassportPushToken';
|
||||||
|
request: IPassportDeviceSignedRequest & {
|
||||||
|
provider: data.TPassportPushProvider;
|
||||||
|
token: string;
|
||||||
|
topic: string;
|
||||||
|
environment: data.TPassportPushEnvironment;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_ListPendingPassportChallenges
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_ListPendingPassportChallenges
|
||||||
|
> {
|
||||||
|
method: 'listPendingPassportChallenges';
|
||||||
|
request: IPassportDeviceSignedRequest;
|
||||||
|
response: {
|
||||||
|
challenges: data.IPassportChallenge[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_GetPassportChallengeByHint
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_GetPassportChallengeByHint
|
||||||
|
> {
|
||||||
|
method: 'getPassportChallengeByHint';
|
||||||
|
request: IPassportDeviceSignedRequest & {
|
||||||
|
hintId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
challenge?: data.IPassportChallenge;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IReq_MarkPassportChallengeSeen
|
||||||
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
|
plugins.typedRequestInterfaces.ITypedRequest,
|
||||||
|
IReq_MarkPassportChallengeSeen
|
||||||
|
> {
|
||||||
|
method: 'markPassportChallengeSeen';
|
||||||
|
request: IPassportDeviceSignedRequest & {
|
||||||
|
hintId: string;
|
||||||
|
};
|
||||||
|
response: {
|
||||||
|
success: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export interface IReq_GetPlansForOrganizationId
|
export interface IReq_GetPlansForOrganizationId
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
import { type IUser } from '../data/index.js';
|
import { type IUser } from '../data/index.js';
|
||||||
|
|
||||||
export interface IReq_FirstRegistration
|
export interface IReq_FirstRegistration
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export interface IReq_GetUserData
|
export interface IReq_GetUserData
|
||||||
extends plugins.typedRequestInterfaces.implementsTR<
|
extends plugins.typedRequestInterfaces.implementsTR<
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import * as data from '../data/index.js';
|
import * as data from '../data/index.js';
|
||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create an invitation to join an organization
|
* Create an invitation to join an organization
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import * as plugins from '../loint-reception.plugins.js';
|
import * as plugins from '../plugins.js';
|
||||||
|
|
||||||
export interface ITag_LolePubapi
|
export interface ITag_LolePubapi
|
||||||
extends plugins.typedRequestInterfaces.implementsTag<
|
extends plugins.typedRequestInterfaces.implementsTag<
|
||||||
|
|||||||
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@idp.global/idp.global',
|
name: '@idp.global/idp.global',
|
||||||
version: '1.17.1',
|
version: '1.21.0',
|
||||||
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
description: 'An identity provider software managing user authentications, registrations, and sessions.'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
domtools,
|
domtools,
|
||||||
} from '@design.estate/dees-element';
|
} from '@design.estate/dees-element';
|
||||||
|
|
||||||
// third party catalogs
|
|
||||||
import '@uptime.link/webwidget';
|
import '@uptime.link/webwidget';
|
||||||
|
|
||||||
import '@design.estate/dees-catalog';
|
import '@design.estate/dees-catalog';
|
||||||
@@ -29,6 +28,12 @@ declare global {
|
|||||||
export class IdpLoginPrompt extends DeesElement {
|
export class IdpLoginPrompt extends DeesElement {
|
||||||
public static demo = () => html`<idp-loginprompt></idp-loginprompt>`;
|
public static demo = () => html`<idp-loginprompt></idp-loginprompt>`;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor oidcConsentState: plugins.idpInterfaces.request.IReq_PrepareOidcAuthorization['response'] | null = null;
|
||||||
|
|
||||||
|
@state()
|
||||||
|
accessor oidcConsentError = '';
|
||||||
|
|
||||||
@property()
|
@property()
|
||||||
accessor productOfInterest: string;
|
accessor productOfInterest: string;
|
||||||
|
|
||||||
@@ -48,6 +53,155 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
domtools.elementBasic.setup();
|
domtools.elementBasic.setup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getOidcAuthorizationContext(): Omit<
|
||||||
|
plugins.idpInterfaces.request.IReq_CompleteOidcAuthorization['request'],
|
||||||
|
'jwt'
|
||||||
|
> | null {
|
||||||
|
const currentUrl = plugins.smarturl.Smarturl.createFromUrl(window.location.href);
|
||||||
|
|
||||||
|
if (currentUrl.searchParams.oauth !== 'true') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const clientId = currentUrl.searchParams.client_id;
|
||||||
|
const redirectUri = currentUrl.searchParams.redirect_uri;
|
||||||
|
const scope = currentUrl.searchParams.scope;
|
||||||
|
const state = currentUrl.searchParams.state;
|
||||||
|
|
||||||
|
if (!clientId || !redirectUri || !scope || !state) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = ['none', 'login', 'consent'].includes(currentUrl.searchParams.prompt)
|
||||||
|
? (currentUrl.searchParams.prompt as 'none' | 'login' | 'consent')
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
clientId,
|
||||||
|
redirectUri,
|
||||||
|
scope,
|
||||||
|
state,
|
||||||
|
prompt,
|
||||||
|
codeChallenge: currentUrl.searchParams.code_challenge || undefined,
|
||||||
|
codeChallengeMethod:
|
||||||
|
currentUrl.searchParams.code_challenge_method === 'S256' ? 'S256' : undefined,
|
||||||
|
nonce: currentUrl.searchParams.nonce || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private redirectOidcError(errorArg: string, descriptionArg?: string) {
|
||||||
|
const oidcContext = this.getOidcAuthorizationContext();
|
||||||
|
if (!oidcContext) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const redirectUrl = new URL(oidcContext.redirectUri);
|
||||||
|
redirectUrl.searchParams.set('error', errorArg);
|
||||||
|
redirectUrl.searchParams.set('state', oidcContext.state);
|
||||||
|
if (descriptionArg) {
|
||||||
|
redirectUrl.searchParams.set('error_description', descriptionArg);
|
||||||
|
}
|
||||||
|
window.location.href = redirectUrl.toString();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOidcScopeDescription(scopeArg: plugins.idpInterfaces.data.TOidcScope) {
|
||||||
|
const scopeMap: Record<plugins.idpInterfaces.data.TOidcScope, string> = {
|
||||||
|
openid: 'Confirm your identity with this app.',
|
||||||
|
profile: 'Share your display name and username.',
|
||||||
|
email: 'Share your email address.',
|
||||||
|
organizations: 'Share your organizations and their roles.',
|
||||||
|
roles: 'Share your platform roles.',
|
||||||
|
};
|
||||||
|
|
||||||
|
return scopeMap[scopeArg];
|
||||||
|
}
|
||||||
|
|
||||||
|
private getOidcAppHost(appUrlArg: string) {
|
||||||
|
try {
|
||||||
|
return new URL(appUrlArg).hostname;
|
||||||
|
} catch {
|
||||||
|
return appUrlArg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async prepareOidcAuthorization(jwtArg: string) {
|
||||||
|
const oidcContext = this.getOidcAuthorizationContext();
|
||||||
|
if (!oidcContext) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
return idpState.idpClient.requests.prepareOidcAuthorization
|
||||||
|
.fire({
|
||||||
|
jwt: jwtArg,
|
||||||
|
...oidcContext,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleOidcAfterLogin(jwtArg: string) {
|
||||||
|
const oidcContext = this.getOidcAuthorizationContext();
|
||||||
|
if (!oidcContext) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null;
|
||||||
|
loginForm?.setStatus('pending', 'preparing application authorization...');
|
||||||
|
this.oidcConsentError = '';
|
||||||
|
|
||||||
|
const preparation = await this.prepareOidcAuthorization(jwtArg);
|
||||||
|
if (!preparation) {
|
||||||
|
loginForm?.setStatus('error', 'could not prepare the application authorization');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preparation.status === 'consent_required') {
|
||||||
|
if (oidcContext.prompt === 'none') {
|
||||||
|
this.redirectOidcError('consent_required');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.oidcConsentState = preparation;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.completeOidcAuthorization(jwtArg);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async completeOidcAuthorization(jwtArg: string, consentApproved = false) {
|
||||||
|
const oidcContext = this.getOidcAuthorizationContext();
|
||||||
|
if (!oidcContext) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm | null;
|
||||||
|
loginForm?.setStatus('pending', 'authorizing application...');
|
||||||
|
this.oidcConsentError = '';
|
||||||
|
|
||||||
|
const response = await idpState.idpClient.requests.completeOidcAuthorization
|
||||||
|
.fire({
|
||||||
|
jwt: jwtArg,
|
||||||
|
...oidcContext,
|
||||||
|
consentApproved,
|
||||||
|
})
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!response?.redirectUrl) {
|
||||||
|
if (this.oidcConsentState) {
|
||||||
|
this.oidcConsentError = 'Could not authorize the application.';
|
||||||
|
} else {
|
||||||
|
loginForm?.setStatus('error', 'could not authorize the application');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.location.href = response.redirectUrl;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public static styles = [
|
public static styles = [
|
||||||
cssManager.defaultStyles,
|
cssManager.defaultStyles,
|
||||||
css`
|
css`
|
||||||
@@ -103,10 +257,147 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
.form-footer a:hover {
|
.form-footer a:hover {
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.consent-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgba(255, 255, 255, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-appname {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-appurl {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-scopes {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-scope {
|
||||||
|
padding: 14px 16px;
|
||||||
|
border-radius: 14px;
|
||||||
|
background: rgba(255, 255, 255, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-scope-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-scope-tag {
|
||||||
|
color: #9cd67c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-scope-description {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-button {
|
||||||
|
border: none;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 12px 18px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-button-secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-button-primary {
|
||||||
|
background: linear-gradient(135deg, #9b7bff, #5fd1ff);
|
||||||
|
color: #0a0a0a;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consent-error {
|
||||||
|
color: #ff9a9a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
`,
|
`,
|
||||||
];
|
];
|
||||||
|
|
||||||
public render(): TemplateResult {
|
public render(): TemplateResult {
|
||||||
|
if (this.oidcConsentState) {
|
||||||
|
return html`
|
||||||
|
<idp-centercontainer>
|
||||||
|
<div class="form-header">
|
||||||
|
<h2>Continue to ${this.oidcConsentState.appName}</h2>
|
||||||
|
<p>Review and approve the access this app is requesting.</p>
|
||||||
|
</div>
|
||||||
|
<div class="consent-card">
|
||||||
|
<div class="consent-appname">${this.oidcConsentState.appName}</div>
|
||||||
|
<div class="consent-appurl">${this.getOidcAppHost(this.oidcConsentState.appUrl)}</div>
|
||||||
|
<div class="consent-scopes">
|
||||||
|
${this.oidcConsentState.requestedScopes.map((scopeArg) => html`
|
||||||
|
<div class="consent-scope">
|
||||||
|
<div class="consent-scope-header">
|
||||||
|
<span>${scopeArg}</span>
|
||||||
|
${this.oidcConsentState.grantedScopes.includes(scopeArg)
|
||||||
|
? html`<span class="consent-scope-tag">Previously allowed</span>`
|
||||||
|
: null}
|
||||||
|
</div>
|
||||||
|
<div class="consent-scope-description">${this.getOidcScopeDescription(scopeArg)}</div>
|
||||||
|
</div>
|
||||||
|
`)}
|
||||||
|
</div>
|
||||||
|
${this.oidcConsentError ? html`<div class="consent-error">${this.oidcConsentError}</div>` : null}
|
||||||
|
<div class="consent-actions">
|
||||||
|
<button
|
||||||
|
class="consent-button consent-button-secondary"
|
||||||
|
@click=${() => {
|
||||||
|
this.redirectOidcError('access_denied');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="consent-button consent-button-primary"
|
||||||
|
@click=${async () => {
|
||||||
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
if (!jwt) {
|
||||||
|
this.redirectOidcError('login_required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await this.completeOidcAuthorization(jwt, true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Allow and continue
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</idp-centercontainer>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<idp-centercontainer>
|
<idp-centercontainer>
|
||||||
<div class="form-header">
|
<div class="form-header">
|
||||||
@@ -115,12 +406,12 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
</div>
|
</div>
|
||||||
<dees-form
|
<dees-form
|
||||||
id="loginForm"
|
id="loginForm"
|
||||||
@formData="${(eventArg) => {
|
@formData=${(eventArg) => {
|
||||||
this.login({
|
this.login({
|
||||||
emailAddress: eventArg.detail.data.emailAddress,
|
emailAddress: eventArg.detail.data.emailAddress,
|
||||||
passwordArg: eventArg.detail.data.password,
|
passwordArg: eventArg.detail.data.password,
|
||||||
});
|
});
|
||||||
}}"
|
}}
|
||||||
>
|
>
|
||||||
<dees-input-text
|
<dees-input-text
|
||||||
id="loginEmailInput"
|
id="loginEmailInput"
|
||||||
@@ -137,7 +428,8 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
<dees-form-submit id="loginSubmitButton"></dees-form-submit>
|
<dees-form-submit id="loginSubmitButton"></dees-form-submit>
|
||||||
</dees-form>
|
</dees-form>
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
Don't have an account? <a @click=${async () => {
|
Don't have an account?
|
||||||
|
<a @click=${async () => {
|
||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
idpState.domtools.router.pushUrl('/register');
|
idpState.domtools.router.pushUrl('/register');
|
||||||
}}>Create one</a>
|
}}>Create one</a>
|
||||||
@@ -147,32 +439,48 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async firstUpdated() {
|
public async firstUpdated() {
|
||||||
const domtoolsInstance = await this.domtoolsPromise;
|
await this.domtoolsPromise;
|
||||||
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const loginPasswordInput: DeesInputText = loginForm.querySelector('#loginPasswordInput');
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm;
|
||||||
const loginSubmitButton: DeesFormSubmit = loginForm.querySelector('#loginSubmitButton');
|
const loginPasswordInput = loginForm.querySelector('#loginPasswordInput') as DeesInputText;
|
||||||
|
const loginSubmitButton = loginForm.querySelector('#loginSubmitButton') as DeesFormSubmit;
|
||||||
|
const oidcContext = this.getOidcAuthorizationContext();
|
||||||
const setButtonText = async () => {
|
const setButtonText = async () => {
|
||||||
if (loginPasswordInput.value) {
|
if (loginPasswordInput.value) {
|
||||||
console.log('updating text of loginprompt.');
|
loginSubmitButton.text = oidcContext ? 'Sign in and continue' : 'Login';
|
||||||
loginSubmitButton.text = 'Login';
|
|
||||||
} else {
|
} else {
|
||||||
loginSubmitButton.text = 'Send magic link (or enter password)';
|
loginSubmitButton.text = 'Send magic link (or enter password)';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loginForm.changeSubject.subscribe(() => {
|
loginForm.changeSubject.subscribe(() => {
|
||||||
console.log(`checking button text ${loginPasswordInput.value}`);
|
void setButtonText();
|
||||||
setButtonText();
|
|
||||||
});
|
});
|
||||||
setButtonText();
|
await setButtonText();
|
||||||
|
|
||||||
|
if (oidcContext) {
|
||||||
|
const loggedIn = await idpState.idpClient.determineLoginStatus(false);
|
||||||
|
if (!loggedIn && oidcContext.prompt === 'none') {
|
||||||
|
this.redirectOidcError('login_required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loggedIn && oidcContext.prompt !== 'login') {
|
||||||
|
const jwt = await idpState.idpClient.getJwt();
|
||||||
|
if (jwt) {
|
||||||
|
await this.handleOidcAfterLogin(jwt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private login = async (valueArg: { emailAddress: string; passwordArg: string }) => {
|
private login = async (valueArg: { emailAddress: string; passwordArg: string }) => {
|
||||||
// lets disable the submit button
|
const loginSubmitButton = this.shadowRoot.querySelector(
|
||||||
const loginSubmitButton: plugins.deesCatalog.DeesFormSubmit = this.shadowRoot.querySelector('#loginSubmitButton');
|
'#loginSubmitButton'
|
||||||
|
) as plugins.deesCatalog.DeesFormSubmit;
|
||||||
loginSubmitButton.disabled = true;
|
loginSubmitButton.disabled = true;
|
||||||
// lets define the needed requests
|
|
||||||
const idpState = await IdpState.getSingletonInstance();
|
const idpState = await IdpState.getSingletonInstance();
|
||||||
const loginForm: DeesForm = this.shadowRoot.querySelector('#loginForm');
|
const loginForm = this.shadowRoot.querySelector('#loginForm') as DeesForm;
|
||||||
const loginRequestWithUsernameAndPassword =
|
const loginRequestWithUsernameAndPassword =
|
||||||
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
idpState.idpClient.typedsocket.createTypedRequest<plugins.idpInterfaces.request.IReq_LoginWithEmailOrUsernameAndPassword>(
|
||||||
'loginWithEmailOrUsernameAndPassword'
|
'loginWithEmailOrUsernameAndPassword'
|
||||||
@@ -182,19 +490,19 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
'loginWithEmail'
|
'loginWithEmail'
|
||||||
);
|
);
|
||||||
|
|
||||||
// lets do the actual logging in
|
|
||||||
if (valueArg.emailAddress && valueArg.passwordArg) {
|
if (valueArg.emailAddress && valueArg.passwordArg) {
|
||||||
loginForm.setStatus('pending', 'logging in...');
|
loginForm.setStatus('pending', 'logging in...');
|
||||||
const response = await loginRequestWithUsernameAndPassword
|
const response = await loginRequestWithUsernameAndPassword
|
||||||
.fire({
|
.fire({
|
||||||
username: valueArg.emailAddress, // TODO: rename to emailAddress
|
username: valueArg.emailAddress,
|
||||||
password: valueArg.passwordArg,
|
password: valueArg.passwordArg,
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
loginForm.setStatus('error', 'could not log you in. Try Again!');
|
loginForm.setStatus('error', 'could not log you in. Try Again!');
|
||||||
return;
|
return null;
|
||||||
});
|
});
|
||||||
if (!response) {
|
if (!response) {
|
||||||
|
loginSubmitButton.disabled = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (response.refreshToken) {
|
if (response.refreshToken) {
|
||||||
@@ -202,11 +510,13 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
|
const jwt = await idpState.idpClient.refreshJwt(response.refreshToken);
|
||||||
if (jwt) {
|
if (jwt) {
|
||||||
loginForm.setStatus('success', 'obtained jwt.');
|
loginForm.setStatus('success', 'obtained jwt.');
|
||||||
idpState.domtools.router.pushUrl('/account');
|
const oidcHandled = await this.handleOidcAfterLogin(jwt);
|
||||||
|
if (!oidcHandled) {
|
||||||
|
idpState.domtools.router.pushUrl('/account');
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
loginForm.setStatus('error', 'something went wrong');
|
loginForm.setStatus('error', 'something went wrong');
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
}
|
}
|
||||||
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
|
} else if (valueArg.emailAddress && !valueArg.passwordArg) {
|
||||||
loginForm.setStatus('pending', 'sending magic link...');
|
loginForm.setStatus('pending', 'sending magic link...');
|
||||||
@@ -216,13 +526,13 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
if (response.status === 'ok') {
|
if (response.status === 'ok') {
|
||||||
loginForm.setStatus('success', 'Please check your email!');
|
loginForm.setStatus('success', 'Please check your email!');
|
||||||
}
|
}
|
||||||
console.log(response);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loginSubmitButton.disabled = false;
|
||||||
};
|
};
|
||||||
|
|
||||||
public async dispatchJwt(jwtArg?: string) {
|
public async dispatchJwt(jwtArg?: string) {
|
||||||
if (jwtArg !== undefined) {
|
if (jwtArg !== undefined) {
|
||||||
console.log(`dispatching jwt from loginprompt.`);
|
|
||||||
this.jwt = jwtArg;
|
this.jwt = jwtArg;
|
||||||
await domtools.plugins.smartdelay.delayFor(200);
|
await domtools.plugins.smartdelay.delayFor(200);
|
||||||
this.dispatchEvent(
|
this.dispatchEvent(
|
||||||
@@ -237,9 +547,7 @@ export class IdpLoginPrompt extends DeesElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async focus() {
|
public async focus() {
|
||||||
(
|
(this.shadowRoot.querySelector('#loginEmailInput') as plugins.deesCatalog.DeesInputText).focus();
|
||||||
this.shadowRoot.querySelector('#loginEmailInput') as plugins.deesCatalog.DeesInputText
|
|
||||||
).focus();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async show() {
|
public async show() {
|
||||||
|
|||||||
Reference in New Issue
Block a user