Compare commits

..

4 Commits

Author SHA1 Message Date
jkunz a1a684ee81 v1.21.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-20 10:26:22 +00:00
jkunz 6044928c70 feat(reception): add passport device authentication flows and alert delivery management 2026-04-20 10:26:22 +00:00
jkunz 3cd7499f3f v1.20.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2026-04-20 09:46:13 +00:00
jkunz 29a21fd3b3 feat(auth): add abuse protection for login and OIDC flows with consent-based authorization handling 2026-04-20 09:46:13 +00:00
56 changed files with 4071 additions and 87 deletions
+18
View File
@@ -1,5 +1,23 @@
# 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) ## 2026-04-20 - 1.19.1 - fix(ts_interfaces)
rename generated TypeScript interface files to remove the loint-reception prefix rename generated TypeScript interface files to remove the loint-reception prefix
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@idp.global/idp.global", "name": "@idp.global/idp.global",
"version": "1.19.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",
+94
View File
@@ -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();
+307
View File
@@ -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();
+132
View File
@@ -2,9 +2,20 @@ import { tap, expect } from '@git.zone/tstest/tapbundle';
import { OidcAccessToken } from '../ts/reception/classes.oidcaccesstoken.js'; import { OidcAccessToken } from '../ts/reception/classes.oidcaccesstoken.js';
import { OidcAuthorizationCode } from '../ts/reception/classes.oidcauthorizationcode.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 { OidcRefreshToken } from '../ts/reception/classes.oidcrefreshtoken.js';
import { OidcUserConsent } from '../ts/reception/classes.oidcuserconsent.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 () => { tap.test('stores authorization codes as hashes and marks them used', async () => {
const authCode = new OidcAuthorizationCode(); const authCode = new OidcAuthorizationCode();
authCode.id = 'oidc-auth-code'; authCode.id = 'oidc-auth-code';
@@ -73,4 +84,125 @@ tap.test('merges user consent scopes without duplicates', async () => {
expect(saveCount).toEqual(1); 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(); export default tap.start();
+434
View File
@@ -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();
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@idp.global/idp.global', name: '@idp.global/idp.global',
version: '1.19.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();
}
}
+33
View File
@@ -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;
}
}
+39
View File
@@ -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,
};
}
+425
View File
@@ -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);
}
}
}
}
+28
View File
@@ -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,
};
}
+26 -2
View File
@@ -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
+50
View File
@@ -74,6 +74,56 @@ export class ReceptionHousekeeping {
'2 * * * * *' '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');
} }
@@ -5,6 +5,34 @@ 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() {
@@ -23,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,
@@ -54,6 +90,11 @@ export class LoginSessionManager {
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,
@@ -69,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: {
@@ -101,6 +148,12 @@ 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) => {
await this.receptionRef.abuseProtectionManager.consumeAttempt(
'emailLoginToken',
requestArg.email,
this.abuseProtectionConfigs.emailLoginToken,
'Too many magic link attempts. Please wait before trying again.'
);
const tokenObject = await this.consumeEmailActionToken( const tokenObject = await this.consumeEmailActionToken(
requestArg.email, requestArg.email,
requestArg.token, requestArg.token,
@@ -120,6 +173,10 @@ export class LoginSessionManager {
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,
}; };
@@ -188,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: {
@@ -216,6 +279,12 @@ 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({ const user = await this.receptionRef.userManager.CUser.getInstance({
data: { data: {
email: requestData.email, email: requestData.email,
@@ -253,6 +322,10 @@ export class LoginSessionManager {
requestData.newPassword requestData.newPassword
); );
await user.save(); await user.save();
await this.receptionRef.abuseProtectionManager.clearAttempts(
'passwordResetCompletion',
requestData.email
);
return { return {
status: 'ok', status: 'ok',
}; };
+197 -4
View File
@@ -11,11 +11,21 @@ import { OidcUserConsent } from './classes.oidcuserconsent.js';
* 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;
} }
public typedRouter = new plugins.typedrequest.TypedRouter();
public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc( public COidcAuthorizationCode = plugins.smartdata.setDefaultManagerForDoc(
this, this,
OidcAuthorizationCode OidcAuthorizationCode
@@ -31,6 +41,35 @@ export class OidcManager {
constructor(receptionRefArg: Reception) { constructor(receptionRefArg: Reception) {
this.receptionRef = receptionRefArg; this.receptionRef = receptionRefArg;
this.receptionRef.typedrouter.addTypedRouter(this.typedRouter);
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();
} }
@@ -128,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');
@@ -169,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);
} }
@@ -202,10 +248,71 @@ export class OidcManager {
}; };
await authCode.save(); await authCode.save();
await this.upsertUserConsent(userId, clientId, scopes);
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
*/ */
@@ -236,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) {
@@ -250,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;
} }
/** /**
@@ -625,6 +746,78 @@ 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 { private createOpaqueToken(byteLength = 32): string {
return plugins.crypto.randomBytes(byteLength).toString('base64url'); return plugins.crypto.randomBytes(byteLength).toString('base64url');
} }
+59
View File
@@ -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();
}
}
+37
View File
@@ -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';
}
}
+816
View File
@@ -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}`);
}
}
}
}
+29
View File
@@ -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;
}
}
+231
View File
@@ -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;
}
}
+8
View File
@@ -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);
+12
View File
@@ -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'
+13
View File
@@ -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
View File
@@ -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'
+35
View File
@@ -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;
};
}
+22
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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 {}
+21 -15
View File
@@ -1,15 +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.emailactiontoken.js'; export * from './alert.js';
export * from './loint-reception.oidc.js'; export * from './alertrule.js';
export * from './loint-reception.appconnection.js'; export * from './app.js';
export * from './loint-reception.billingplan.js'; export * from './emailactiontoken.js';
export * from './loint-reception.device.js'; export * from './oidc.js';
export * from './loint-reception.jwt.js'; export * from './appconnection.js';
export * from './loint-reception.loginsession.js'; export * from './billingplan.js';
export * from './loint-reception.organization.js'; export * from './device.js';
export * from './loint-reception.paddlecheckoutdata.js'; export * from './jwt.js';
export * from './loint-reception.registrationsession.js'; export * from './loginsession.js';
export * from './loint-reception.role.js'; export * from './organization.js';
export * from './loint-reception.user.js'; export * from './paddlecheckoutdata.js';
export * from './loint-reception.userinvitation.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';
+3 -3
View File
@@ -1,6 +1,6 @@
import * as plugins from '../loint-reception.plugins.js'; import * as plugins from '../plugins.js';
import { type IBillingPlan } from './loint-reception.billingplan.js'; import { type IBillingPlan } from './billingplan.js';
import { type IRole } from './loint-reception.role.js'; import { type IRole } from './role.js';
export interface IOrganization { export interface IOrganization {
id: string; id: string;
+63
View File
@@ -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;
};
}
+46
View File
@@ -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;
};
}
+9
View File
@@ -0,0 +1,9 @@
export interface IPassportNonce {
id: string;
data: {
deviceId: string;
nonceHash: string;
createdAt: number;
expiresAt: number;
};
}
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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';
+2 -2
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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';
/** /**
+97
View File
@@ -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 -1
View File
@@ -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
+54 -1
View File
@@ -1,5 +1,6 @@
import * as plugins from '../loint-reception.plugins.js'; import * as plugins from '../plugins.js';
import { type IUser, type IRole } from '../data/index.js'; import { type IUser, type IRole } from '../data/index.js';
import { type TOidcScope } from '../data/index.js';
export interface IReq_InternalAuthorization export interface IReq_InternalAuthorization
extends plugins.typedRequestInterfaces.implementsTR< extends plugins.typedRequestInterfaces.implementsTR<
@@ -17,3 +18,55 @@ export interface IReq_InternalAuthorization
relevantRoles: IRole[]; 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
View File
@@ -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
+14 -12
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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<
+183
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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<
+1 -1
View File
@@ -3,6 +3,6 @@
*/ */
export const commitinfo = { export const commitinfo = {
name: '@idp.global/idp.global', name: '@idp.global/idp.global',
version: '1.19.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.'
} }
+335 -27
View File
@@ -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() {