fase(25-26): keyboard shortcuts, mobile responsive, enterprise SSO/audit

- Phase 25.4: N shortcut for new exploration on dashboard (react-hotkeys-hook)
- Phase 25.5: overflow-x-auto on tables, responsive padding (p-4 md:p-6)
- Phase 26: SAML/OIDC/LDAP providers (build-fixed), TOTP/MFA service
- Phase 26: KyselySSOConfigRepository + KyselyTOTPRepository
- Phase 26: SSO HTTP controller (config CRUD + MFA setup/verify/disable)
- Phase 26: Audit module index.ts + SSO module index.ts
- Phase 26: Session management endpoints (findByUserId, deleteById, list/revoke)
- Phase 26: SSO and audit routes feature-gated (auth:sso, audit:logs)
- Phase 26: Frontend SSOSection (SAML/OIDC/LDAP config + TOTP setup)
- Phase 26: Frontend SessionsSection (list/revoke active sessions)
- Phase 26: Settings navigation updated with SSO & Sessions sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
debian
2026-03-08 13:38:25 -04:00
parent c3911bafe8
commit 08011d22d5
58 changed files with 2689 additions and 23 deletions

View File

@@ -1 +1 @@
49e76c92b17a3510da50ae1deaf8002c5c67d010
c3911bafe885d664a6870305dff172e1410a95ac

View File

@@ -1 +1 @@
{"status": "completed", "timestamp": "2026-03-08 05:49:12"}
{"status": "failed", "timestamp": "2026-03-08 07:22:04"}

6
dist/api/router.js vendored
View File

@@ -16,6 +16,8 @@ const LicensingController_1 = require("../modules/licensing/infrastructure/http/
const FeatureGateMiddleware_1 = require("../modules/licensing/infrastructure/middleware/FeatureGateMiddleware");
const AuthController_1 = require("../modules/auth/infrastructure/http/AuthController");
const AuthMiddleware_1 = require("../modules/auth/application/middleware/AuthMiddleware");
const SSOController_1 = require("../modules/sso/infrastructure/http/SSOController");
const AuditController_1 = require("../modules/audit/infrastructure/http/AuditController");
function createRouter(deps) {
const router = (0, express_1.Router)();
const { authDeps, licenseService } = deps;
@@ -34,5 +36,9 @@ function createRouter(deps) {
// Licensing routes (public-ish — only status and activate, no sensitive data)
const licensingController = new LicensingController_1.LicensingController(licenseService);
router.use('/license', licensingController.router);
// Enterprise: SSO + MFA (feature-gated)
router.use('/sso', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'auth:sso'), (0, SSOController_1.createSSORouter)(deps.ssoDeps));
// Enterprise: Audit logs (feature-gated)
router.use('/audit', (0, FeatureGateMiddleware_1.requireFeature)(licenseService, 'audit:logs'), (0, AuditController_1.createAuditRouter)(deps.auditRepository));
return router;
}

View File

@@ -0,0 +1,50 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.up = up;
exports.down = down;
const kysely_1 = require("kysely");
async function up(db) {
// SSO configurations per organization
await db.schema
.createTable('sso_configs')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('organization_id', 'text', (c) => c.notNull())
.addColumn('provider', 'text', (c) => c.notNull())
.addColumn('enabled', 'integer', (c) => c.notNull().defaultTo(1))
.addColumn('config_json', 'text', (c) => c.notNull().defaultTo('{}'))
.addColumn('created_at', 'integer', (c) => c.notNull())
.execute();
// TOTP secrets for MFA
await db.schema
.createTable('totp_secrets')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text', (c) => c.notNull().unique())
.addColumn('secret', 'text', (c) => c.notNull())
.addColumn('verified', 'integer', (c) => c.notNull().defaultTo(0))
.addColumn('created_at', 'integer', (c) => c.notNull())
.execute();
// Audit logs
await db.schema
.createTable('audit_logs')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text')
.addColumn('organization_id', 'text')
.addColumn('action', 'text', (c) => c.notNull())
.addColumn('resource', 'text', (c) => c.notNull())
.addColumn('resource_id', 'text')
.addColumn('ip_address', 'text')
.addColumn('user_agent', 'text')
.addColumn('details_json', 'text', (c) => c.notNull().defaultTo('{}'))
.addColumn('occurred_at', 'integer', (c) => c.notNull())
.execute();
await (0, kysely_1.sql) `CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs (user_id)`.execute(db);
await (0, kysely_1.sql) `CREATE INDEX IF NOT EXISTS idx_audit_logs_occurred ON audit_logs (occurred_at)`.execute(db);
}
async function down(db) {
await db.schema.dropTable('audit_logs').ifExists().execute();
await db.schema.dropTable('totp_secrets').ifExists().execute();
await db.schema.dropTable('sso_configs').ifExists().execute();
}

12
dist/main.js vendored
View File

@@ -69,6 +69,11 @@ const ApproveAllNewStatesCommand_1 = require("./modules/visual-regression/applic
const ListComparisonsQuery_1 = require("./modules/visual-regression/application/queries/ListComparisonsQuery");
const StorageProvider_1 = require("./shared/infrastructure/StorageProvider");
const path_1 = __importDefault(require("path"));
// SSO + Audit modules (enterprise)
const KyselySSOConfigRepository_1 = require("./modules/sso/infrastructure/repositories/KyselySSOConfigRepository");
const KyselyTOTPRepository_1 = require("./modules/sso/infrastructure/repositories/KyselyTOTPRepository");
const TOTPService_1 = require("./modules/sso/infrastructure/providers/TOTPService");
const KyselyAuditRepository_1 = require("./modules/audit/infrastructure/repositories/KyselyAuditRepository");
// Scheduling module
const KyselyScheduleRepository_1 = require("./modules/scheduling/infrastructure/repositories/KyselyScheduleRepository");
const CreateScheduleCommand_1 = require("./modules/scheduling/application/commands/CreateScheduleCommand");
@@ -173,6 +178,11 @@ async function bootstrap() {
const listSchedules = new ListSchedulesQuery_1.ListSchedulesQuery(scheduleRepo);
const schedulingService = new SchedulingService_1.SchedulingService(scheduleRepo, jobQueue, eventBus, logger);
await schedulingService.start();
// 12c. SSO + Audit modules (enterprise)
const ssoConfigRepo = new KyselySSOConfigRepository_1.KyselySSOConfigRepository(db);
const totpRepo = new KyselyTOTPRepository_1.KyselyTOTPRepository(db);
const totpService = new TOTPService_1.TOTPService();
const auditRepo = new KyselyAuditRepository_1.KyselyAuditRepository(db);
// 13. HTTP server
const app = (0, server_1.createServer)({
config,
@@ -198,6 +208,8 @@ async function bootstrap() {
apiKeyRepository: apiKeyRepo,
userRepository: userRepo,
},
ssoDeps: { ssoConfigRepository: ssoConfigRepo, totpRepository: totpRepo, totpService },
auditRepository: auditRepo,
});
const httpServer = http_1.default.createServer(app);
// 12. Socket.io + gateway

View File

@@ -0,0 +1,23 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.AuditLog = void 0;
const Entity_1 = require("../../../../shared/domain/Entity");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class AuditLog extends Entity_1.Entity {
static create(props, id) {
return new AuditLog(props, id ?? UniqueId_1.UniqueId.create());
}
static reconstitute(props, id) {
return new AuditLog(props, id);
}
get userId() { return this.props.userId; }
get organizationId() { return this.props.organizationId; }
get action() { return this.props.action; }
get resource() { return this.props.resource; }
get resourceId() { return this.props.resourceId; }
get ipAddress() { return this.props.ipAddress; }
get userAgent() { return this.props.userAgent; }
get details() { return this.props.details; }
get occurredAt() { return this.props.occurredAt; }
}
exports.AuditLog = AuditLog;

9
dist/modules/audit/index.js vendored Normal file
View File

@@ -0,0 +1,9 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuditRouter = exports.KyselyAuditRepository = exports.AuditLog = void 0;
var AuditLog_1 = require("./domain/entities/AuditLog");
Object.defineProperty(exports, "AuditLog", { enumerable: true, get: function () { return AuditLog_1.AuditLog; } });
var KyselyAuditRepository_1 = require("./infrastructure/repositories/KyselyAuditRepository");
Object.defineProperty(exports, "KyselyAuditRepository", { enumerable: true, get: function () { return KyselyAuditRepository_1.KyselyAuditRepository; } });
var AuditController_1 = require("./infrastructure/http/AuditController");
Object.defineProperty(exports, "createAuditRouter", { enumerable: true, get: function () { return AuditController_1.createAuditRouter; } });

View File

@@ -0,0 +1,39 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createAuditRouter = createAuditRouter;
const express_1 = require("express");
function createAuditRouter(repo) {
const router = (0, express_1.Router)();
// GET /api/audit — list audit logs (enterprise only)
router.get('/', async (req, res, next) => {
try {
const filters = {
userId: req.query['userId'],
organizationId: req.query['organizationId'],
action: req.query['action'],
resource: req.query['resource'],
limit: req.query['limit'] ? Number(req.query['limit']) : 100,
};
if (req.query['from'])
filters.from = new Date(req.query['from']);
if (req.query['to'])
filters.to = new Date(req.query['to']);
const logs = await repo.findAll(filters);
res.json(logs.map((l) => ({
id: l.id.toString(),
userId: l.userId,
organizationId: l.organizationId,
action: l.action,
resource: l.resource,
resourceId: l.resourceId,
ipAddress: l.ipAddress,
details: l.details,
occurredAt: l.occurredAt.toISOString(),
})));
}
catch (err) {
next(err);
}
});
return router;
}

View File

@@ -0,0 +1,55 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselyAuditRepository = void 0;
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
const AuditLog_1 = require("../../domain/entities/AuditLog");
class KyselyAuditRepository {
constructor(db) {
this.db = db;
}
async save(log) {
await this.db.insertInto('audit_logs').values({
id: log.id.toString(),
user_id: log.userId,
organization_id: log.organizationId,
action: log.action,
resource: log.resource,
resource_id: log.resourceId,
ip_address: log.ipAddress,
user_agent: log.userAgent,
details_json: JSON.stringify(log.details),
occurred_at: log.occurredAt.getTime(),
}).execute();
}
async findAll(filters = {}) {
let query = this.db.selectFrom('audit_logs').selectAll();
if (filters.userId)
query = query.where('user_id', '=', filters.userId);
if (filters.organizationId)
query = query.where('organization_id', '=', filters.organizationId);
if (filters.action)
query = query.where('action', '=', filters.action);
if (filters.resource)
query = query.where('resource', '=', filters.resource);
if (filters.from)
query = query.where('occurred_at', '>=', filters.from.getTime());
if (filters.to)
query = query.where('occurred_at', '<=', filters.to.getTime());
const rows = await query
.orderBy('occurred_at', 'desc')
.limit(filters.limit ?? 100)
.execute();
return rows.map((row) => AuditLog_1.AuditLog.reconstitute({
userId: row.user_id,
organizationId: row.organization_id,
action: row.action,
resource: row.resource,
resourceId: row.resource_id,
ipAddress: row.ip_address,
userAgent: row.user_agent,
details: JSON.parse(row.details_json),
occurredAt: new Date(row.occurred_at),
}, UniqueId_1.UniqueId.from(row.id)));
}
}
exports.KyselyAuditRepository = KyselyAuditRepository;

View File

@@ -166,5 +166,27 @@ function createAuthController(registerCommand, loginCommand, createOrgCommand, i
await apiKeyRepository.delete(keyId);
res.json({ success: true });
});
// GET /api/auth/sessions — list active sessions (session management dashboard)
router.get('/sessions', authMiddleware, async (req, res) => {
const sessions = await sessionRepository.findByUserId(req.user.id);
res.json(sessions.map((s) => ({
id: s.id,
createdAt: new Date(s.createdAt).toISOString(),
expiresAt: new Date(s.expiresAt).toISOString(),
})));
});
// DELETE /api/auth/sessions/:id — revoke a specific session
router.delete('/sessions/:id', authMiddleware, async (req, res) => {
const sessionId = String(req.params['id']);
// Only allow revoking own sessions
const userSessions = await sessionRepository.findByUserId(req.user.id);
const owns = userSessions.some((s) => s.id === sessionId);
if (!owns) {
res.status(404).json({ error: 'Session not found' });
return;
}
await sessionRepository.deleteById(sessionId);
res.json({ success: true });
});
return router;
}

View File

@@ -33,9 +33,28 @@ class KyselySessionRepository {
createdAt: new Date(row.created_at),
};
}
async findByUserId(userId) {
const rows = await this.db
.selectFrom('auth_sessions')
.selectAll()
.where('user_id', '=', userId)
.where('expires_at', '>', Date.now())
.orderBy('created_at', 'desc')
.execute();
return rows.map((row) => ({
id: row.id,
userId: row.user_id,
token: row.token,
expiresAt: new Date(row.expires_at),
createdAt: new Date(row.created_at),
}));
}
async deleteByToken(token) {
await this.db.deleteFrom('auth_sessions').where('token', '=', token).execute();
}
async deleteById(id) {
await this.db.deleteFrom('auth_sessions').where('id', '=', id).execute();
}
async deleteExpired() {
await this.db
.deleteFrom('auth_sessions')

View File

@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SSOConfig = void 0;
const Entity_1 = require("../../../../shared/domain/Entity");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class SSOConfig extends Entity_1.Entity {
static create(props, id) {
return new SSOConfig(props, id ?? UniqueId_1.UniqueId.create());
}
static reconstitute(props, id) {
return new SSOConfig(props, id);
}
get organizationId() { return this.props.organizationId; }
get provider() { return this.props.provider; }
get enabled() { return this.props.enabled; }
get config() { return this.props.config; }
get createdAt() { return this.props.createdAt; }
enable() { this.props.enabled = true; }
disable() { this.props.enabled = false; }
}
exports.SSOConfig = SSOConfig;

View File

@@ -0,0 +1,19 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TOTPSecret = void 0;
const Entity_1 = require("../../../../shared/domain/Entity");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class TOTPSecret extends Entity_1.Entity {
static create(props, id) {
return new TOTPSecret(props, id ?? UniqueId_1.UniqueId.create());
}
static reconstitute(props, id) {
return new TOTPSecret(props, id);
}
get userId() { return this.props.userId; }
get secret() { return this.props.secret; }
get verified() { return this.props.verified; }
get createdAt() { return this.props.createdAt; }
verify() { this.props.verified = true; }
}
exports.TOTPSecret = TOTPSecret;

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

View File

@@ -0,0 +1,2 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });

21
dist/modules/sso/index.js vendored Normal file
View File

@@ -0,0 +1,21 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createSSORouter = exports.LDAPProvider = exports.OIDCProvider = exports.SAMLProvider = exports.TOTPService = exports.KyselyTOTPRepository = exports.KyselySSOConfigRepository = exports.TOTPSecret = exports.SSOConfig = void 0;
var SSOConfig_1 = require("./domain/entities/SSOConfig");
Object.defineProperty(exports, "SSOConfig", { enumerable: true, get: function () { return SSOConfig_1.SSOConfig; } });
var TOTPSecret_1 = require("./domain/entities/TOTPSecret");
Object.defineProperty(exports, "TOTPSecret", { enumerable: true, get: function () { return TOTPSecret_1.TOTPSecret; } });
var KyselySSOConfigRepository_1 = require("./infrastructure/repositories/KyselySSOConfigRepository");
Object.defineProperty(exports, "KyselySSOConfigRepository", { enumerable: true, get: function () { return KyselySSOConfigRepository_1.KyselySSOConfigRepository; } });
var KyselyTOTPRepository_1 = require("./infrastructure/repositories/KyselyTOTPRepository");
Object.defineProperty(exports, "KyselyTOTPRepository", { enumerable: true, get: function () { return KyselyTOTPRepository_1.KyselyTOTPRepository; } });
var TOTPService_1 = require("./infrastructure/providers/TOTPService");
Object.defineProperty(exports, "TOTPService", { enumerable: true, get: function () { return TOTPService_1.TOTPService; } });
var SAMLProvider_1 = require("./infrastructure/providers/SAMLProvider");
Object.defineProperty(exports, "SAMLProvider", { enumerable: true, get: function () { return SAMLProvider_1.SAMLProvider; } });
var OIDCProvider_1 = require("./infrastructure/providers/OIDCProvider");
Object.defineProperty(exports, "OIDCProvider", { enumerable: true, get: function () { return OIDCProvider_1.OIDCProvider; } });
var LDAPProvider_1 = require("./infrastructure/providers/LDAPProvider");
Object.defineProperty(exports, "LDAPProvider", { enumerable: true, get: function () { return LDAPProvider_1.LDAPProvider; } });
var SSOController_1 = require("./infrastructure/http/SSOController");
Object.defineProperty(exports, "createSSORouter", { enumerable: true, get: function () { return SSOController_1.createSSORouter; } });

View File

@@ -0,0 +1,147 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.createSSORouter = createSSORouter;
/**
* SSO Controller — manages SSO provider configurations and TOTP/MFA.
* Feature-gated: enterprise license required.
*/
const express_1 = require("express");
const SSOConfig_1 = require("../../domain/entities/SSOConfig");
const TOTPSecret_1 = require("../../domain/entities/TOTPSecret");
function createSSORouter(deps) {
const router = (0, express_1.Router)();
const { ssoConfigRepository, totpRepository, totpService } = deps;
// GET /api/sso/config — get SSO config for the current org
router.get('/config', async (req, res, next) => {
try {
const user = req.user;
if (!user?.orgId)
return res.status(400).json({ error: 'No organization' });
const config = await ssoConfigRepository.findByOrganizationId(user.orgId);
if (!config)
return res.json(null);
res.json({
id: config.id.toString(),
provider: config.provider,
enabled: config.enabled,
config: config.config,
createdAt: config.createdAt.toISOString(),
});
}
catch (err) {
next(err);
}
});
// PUT /api/sso/config — create or update SSO config
router.put('/config', async (req, res, next) => {
try {
const user = req.user;
if (!user?.orgId)
return res.status(400).json({ error: 'No organization' });
const { provider, enabled, config } = req.body;
if (!['saml', 'oidc', 'ldap'].includes(provider)) {
return res.status(400).json({ error: 'Invalid provider' });
}
const existing = await ssoConfigRepository.findByOrganizationId(user.orgId);
if (existing) {
if (enabled !== undefined) {
enabled ? existing.enable() : existing.disable();
}
// Merge config fields
Object.assign(existing.config, config ?? {});
await ssoConfigRepository.save(existing);
return res.json({ id: existing.id.toString(), provider: existing.provider, enabled: existing.enabled });
}
const ssoConfig = SSOConfig_1.SSOConfig.create({
organizationId: user.orgId,
provider,
enabled: enabled ?? true,
config: config ?? {},
createdAt: new Date(),
});
await ssoConfigRepository.save(ssoConfig);
res.status(201).json({ id: ssoConfig.id.toString(), provider, enabled: ssoConfig.enabled });
}
catch (err) {
next(err);
}
});
// DELETE /api/sso/config — remove SSO config (disables SSO)
router.delete('/config', async (req, res, next) => {
try {
const user = req.user;
if (!user?.orgId)
return res.status(400).json({ error: 'No organization' });
const config = await ssoConfigRepository.findByOrganizationId(user.orgId);
if (!config)
return res.status(404).json({ error: 'Not found' });
config.disable();
await ssoConfigRepository.save(config);
res.json({ success: true });
}
catch (err) {
next(err);
}
});
// POST /api/sso/mfa/setup — generate TOTP secret for current user
router.post('/mfa/setup', async (req, res, next) => {
try {
const user = req.user;
const { otpauthUrl, secret } = totpService.generateSecret(user.id, user.email);
// Save unverified secret
const totpSecret = TOTPSecret_1.TOTPSecret.create({
userId: user.id,
secret,
verified: false,
createdAt: new Date(),
});
await totpRepository.save(totpSecret);
res.json({ otpauthUrl, secret });
}
catch (err) {
next(err);
}
});
// POST /api/sso/mfa/verify — verify TOTP token and mark as verified
router.post('/mfa/verify', async (req, res, next) => {
try {
const user = req.user;
const { token } = req.body;
const stored = await totpRepository.findByUserId(user.id);
if (!stored)
return res.status(400).json({ error: 'MFA not set up' });
if (!totpService.verify(stored.secret, token)) {
return res.status(400).json({ error: 'Invalid token' });
}
stored.verify();
await totpRepository.save(stored);
res.json({ verified: true });
}
catch (err) {
next(err);
}
});
// DELETE /api/sso/mfa — remove MFA for current user
router.delete('/mfa', async (req, res, next) => {
try {
const user = req.user;
await totpRepository.delete(user.id);
res.json({ success: true });
}
catch (err) {
next(err);
}
});
// GET /api/sso/mfa/status — check MFA status
router.get('/mfa/status', async (req, res, next) => {
try {
const user = req.user;
const stored = await totpRepository.findByUserId(user.id);
res.json({ enabled: !!stored, verified: stored?.verified ?? false });
}
catch (err) {
next(err);
}
});
return router;
}

View File

@@ -0,0 +1,81 @@
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.LDAPProvider = void 0;
/**
* LDAP/Active Directory authentication provider.
*/
const ldapjs_1 = __importDefault(require("ldapjs"));
class LDAPProvider {
constructor(config) {
this.config = config;
}
async authenticate(username, password) {
return new Promise((resolve, reject) => {
const client = ldapjs_1.default.createClient({
url: this.config.url,
tlsOptions: this.config.tlsOptions,
});
client.on('error', (err) => {
reject(err);
});
const escaped = username.replace(/[\\*()\x00]/g, (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
const filter = (this.config.userSearchFilter ?? '(uid={username})')
.replace('{username}', escaped);
// Bind with service account if provided
const bindDN = this.config.bindDN ?? '';
const bindPwd = this.config.bindPassword ?? '';
client.bind(bindDN, bindPwd, (err) => {
if (err) {
client.destroy();
return resolve(null);
}
client.search(this.config.baseDN, { filter, scope: 'sub', attributes: ['dn', 'mail', 'displayName', 'memberOf'] }, (searchErr, res) => {
if (searchErr) {
client.destroy();
return reject(searchErr);
}
let foundEntry = null;
res.on('searchEntry', (entry) => {
foundEntry = entry;
});
res.on('error', (resErr) => {
client.destroy();
reject(resErr);
});
res.on('end', () => {
if (!foundEntry) {
client.destroy();
return resolve(null);
}
const entry = foundEntry;
const userDN = entry.objectName ?? '';
// Authenticate as the found user
client.bind(userDN, password, (authErr) => {
client.destroy();
if (authErr) {
return resolve(null);
}
const obj = entry.pojo;
const getAttr = (name) => {
const attr = obj.attributes.find((a) => a.type === name);
return attr?.values[0];
};
resolve({
dn: userDN,
email: getAttr('mail'),
displayName: getAttr('displayName'),
groups: obj.attributes
.find((a) => a.type === 'memberOf')
?.values ?? [],
});
});
});
});
});
});
}
}
exports.LDAPProvider = LDAPProvider;

View File

@@ -0,0 +1,45 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.OIDCProvider = void 0;
/**
* OIDC (OpenID Connect) SSO provider.
* Supports Okta, Azure AD, Google Workspace.
*/
const openid_client_1 = require("openid-client");
class OIDCProvider {
constructor(config) {
this.config = config;
}
async generateAuthUrl() {
const issuerUrl = new URL(this.config.issuer);
const oidcConfig = await (0, openid_client_1.discovery)(issuerUrl, this.config.clientId, this.config.clientSecret);
const state = (0, openid_client_1.randomState)();
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
response_type: 'code',
scope: (this.config.scopes ?? ['openid', 'email', 'profile']).join(' '),
state,
});
const url = (0, openid_client_1.buildAuthorizationUrl)(oidcConfig, params);
return { url: url.href, state, codeVerifier: '' };
}
async handleCallback(params, expectedState, _codeVerifier) {
const issuerUrl = new URL(this.config.issuer);
const oidcConfig = await (0, openid_client_1.discovery)(issuerUrl, this.config.clientId, this.config.clientSecret);
const currentUrl = new URL(`${this.config.redirectUri}?${params.toString()}`);
const tokens = await (0, openid_client_1.authorizationCodeGrant)(oidcConfig, currentUrl, {
expectedState,
});
const claims = tokens.claims();
if (!claims) {
throw new Error('OIDC: no claims in token response');
}
return {
sub: String(claims['sub'] ?? ''),
email: typeof claims['email'] === 'string' ? claims['email'] : undefined,
name: typeof claims['name'] === 'string' ? claims['name'] : undefined,
};
}
}
exports.OIDCProvider = OIDCProvider;

View File

@@ -0,0 +1,35 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.SAMLProvider = void 0;
/**
* SAML 2.0 SSO provider.
* Uses @node-saml/node-saml for SP-initiated SSO.
*/
const node_saml_1 = require("@node-saml/node-saml");
class SAMLProvider {
constructor(config) {
const samlConfig = {
entryPoint: config.entryPoint,
issuer: config.issuer,
idpCert: config.cert,
callbackUrl: config.callbackUrl,
wantAuthnResponseSigned: false,
};
this.saml = new node_saml_1.SAML(samlConfig);
}
async generateAuthUrl(relayState) {
return this.saml.getAuthorizeUrlAsync(relayState ?? '', undefined, {});
}
async validateResponse(body) {
const { profile } = await this.saml.validatePostResponseAsync(body);
if (!profile) {
throw new Error('SAML validation failed: no profile');
}
return {
nameID: typeof profile.nameID === 'string' ? profile.nameID : '',
email: typeof profile['email'] === 'string' ? profile['email'] : undefined,
displayName: typeof profile.displayName === 'string' ? profile.displayName : undefined,
};
}
}
exports.SAMLProvider = SAMLProvider;

View File

@@ -0,0 +1,39 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.TOTPService = void 0;
/**
* TOTP (Time-based One-Time Password) service.
* Uses otpauth library for RFC 6238 compliant TOTP.
*/
const otpauth_1 = require("otpauth");
class TOTPService {
constructor(issuer = 'ABE') {
this.issuer = issuer;
}
generateSecret(userId, label) {
const totp = new otpauth_1.TOTP({
issuer: this.issuer,
label,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: new otpauth_1.Secret({ size: 20 }),
});
return {
secret: totp.secret.base32,
otpauthUrl: totp.toString(),
};
}
verify(secret, token) {
const totp = new otpauth_1.TOTP({
issuer: this.issuer,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: otpauth_1.Secret.fromBase32(secret),
});
const delta = totp.validate({ token, window: 1 });
return delta !== null;
}
}
exports.TOTPService = TOTPService;

View File

@@ -0,0 +1,53 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselySSOConfigRepository = void 0;
const SSOConfig_1 = require("../../domain/entities/SSOConfig");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class KyselySSOConfigRepository {
constructor(db) {
this.db = db;
}
async save(config) {
await this.db
.insertInto('sso_configs')
.values({
id: config.id.toString(),
organization_id: config.organizationId,
provider: config.provider,
enabled: config.enabled ? 1 : 0,
config_json: JSON.stringify(config.config),
created_at: config.createdAt.getTime(),
})
.onConflict((oc) => oc.column('id').doUpdateSet({
enabled: config.enabled ? 1 : 0,
config_json: JSON.stringify(config.config),
}))
.execute();
}
async findByOrganizationId(organizationId) {
const row = await this.db
.selectFrom('sso_configs')
.selectAll()
.where('organization_id', '=', organizationId)
.executeTakeFirst();
return row ? this.toDomain(row) : null;
}
async findById(id) {
const row = await this.db
.selectFrom('sso_configs')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : null;
}
toDomain(row) {
return SSOConfig_1.SSOConfig.reconstitute({
organizationId: row.organization_id,
provider: row.provider,
enabled: row.enabled === 1,
config: JSON.parse(row.config_json),
createdAt: new Date(row.created_at),
}, UniqueId_1.UniqueId.from(row.id));
}
}
exports.KyselySSOConfigRepository = KyselySSOConfigRepository;

View File

@@ -0,0 +1,45 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.KyselyTOTPRepository = void 0;
const TOTPSecret_1 = require("../../domain/entities/TOTPSecret");
const UniqueId_1 = require("../../../../shared/domain/UniqueId");
class KyselyTOTPRepository {
constructor(db) {
this.db = db;
}
async save(secret) {
await this.db
.insertInto('totp_secrets')
.values({
id: secret.id.toString(),
user_id: secret.userId,
secret: secret.secret,
verified: secret.verified ? 1 : 0,
created_at: secret.createdAt.getTime(),
})
.onConflict((oc) => oc.column('user_id').doUpdateSet({
secret: secret.secret,
verified: secret.verified ? 1 : 0,
}))
.execute();
}
async findByUserId(userId) {
const row = await this.db
.selectFrom('totp_secrets')
.selectAll()
.where('user_id', '=', userId)
.executeTakeFirst();
if (!row)
return null;
return TOTPSecret_1.TOTPSecret.reconstitute({
userId: row.user_id,
secret: row.secret,
verified: row.verified === 1,
createdAt: new Date(row.created_at),
}, UniqueId_1.UniqueId.from(row.id));
}
async delete(userId) {
await this.db.deleteFrom('totp_secrets').where('user_id', '=', userId).execute();
}
}
exports.KyselyTOTPRepository = KyselyTOTPRepository;

View File

@@ -23,6 +23,8 @@ import { IntegrationsSection } from '@/pages/settings/IntegrationsSection'
import { AppearanceSection } from '@/pages/settings/AppearanceSection'
import { LicenseSection } from '@/pages/settings/LicenseSection'
import { SchedulesSection } from '@/pages/settings/SchedulesSection'
import { SSOSection } from '@/pages/settings/SSOSection'
import { SessionsSection } from '@/pages/settings/SessionsSection'
import { Reports } from '@/pages/Reports'
import { VisualReview } from '@/pages/VisualReview'
import { ErrorBoundary } from '@/components/layout/ErrorBoundary'
@@ -55,6 +57,8 @@ export default function App() {
<Route path="profile" element={<ProfileSection />} />
<Route path="organization" element={<OrganizationSection />} />
<Route path="api-keys" element={<ApiKeysSection />} />
<Route path="sessions" element={<SessionsSection />} />
<Route path="sso" element={<SSOSection />} />
<Route path="defaults" element={<ExplorationDefaultsSection />} />
<Route path="schedules" element={<SchedulesSection />} />
<Route path="notifications" element={<NotificationsSection />} />

View File

@@ -11,7 +11,7 @@ export function AppLayout() {
<AppSidebar />
<div className="flex flex-col flex-1 overflow-hidden">
<TopBar />
<main className="flex-1 overflow-auto p-6">
<main className="flex-1 overflow-auto p-4 md:p-6">
<Outlet />
</main>
</div>

View File

@@ -1,5 +1,7 @@
import { useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { useQueryClient } from '@tanstack/react-query'
import { useHotkeys } from 'react-hotkeys-hook'
import { KPICards } from '@/components/dashboard/KPICards'
import { TrendChart } from '@/components/dashboard/TrendChart'
import { SeverityDistribution } from '@/components/dashboard/SeverityDistribution'
@@ -12,6 +14,10 @@ import { useSocket } from '@/hooks/useSocket'
export function Dashboard() {
const queryClient = useQueryClient()
const navigate = useNavigate()
// N → new exploration shortcut (only when not in input)
useHotkeys('n', () => { navigate('/sessions?new=1') }, { preventDefault: true })
const { data: findings = [], isLoading: findingsLoading } = useFindings()
const { data: stats, isLoading: statsLoading } = useFindingStats()

View File

@@ -140,7 +140,7 @@ export function FindingsList() {
{[1, 2, 3, 4, 5].map(i => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : (
<div className="rounded-md border">
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (

View File

@@ -150,7 +150,7 @@ export function SessionList() {
{[1, 2, 3].map(i => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : (
<div className="rounded-md border">
<div className="rounded-md border overflow-x-auto">
<Table>
<TableHeader>
{table.getHeaderGroups().map(hg => (

View File

@@ -0,0 +1,226 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Switch } from '@/components/ui/switch'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { apiFetch } from '@/lib/api'
import { toast } from 'sonner'
interface SSOConfig {
id: string
provider: 'saml' | 'oidc' | 'ldap'
enabled: boolean
config: Record<string, string>
createdAt: string
}
interface MFAStatus {
enabled: boolean
verified: boolean
}
type SSOProvider = 'saml' | 'oidc' | 'ldap'
export function SSOSection() {
const queryClient = useQueryClient()
const { data: ssoConfig } = useQuery<SSOConfig | null>({
queryKey: ['sso-config'],
queryFn: () => apiFetch<SSOConfig | null>('/api/sso/config'),
})
const { data: mfaStatus } = useQuery<MFAStatus>({
queryKey: ['mfa-status'],
queryFn: () => apiFetch<MFAStatus>('/api/sso/mfa/status'),
})
const [provider, setProvider] = useState<SSOProvider>('saml')
const [configFields, setConfigFields] = useState<Record<string, string>>({})
const [mfaToken, setMfaToken] = useState('')
const [showMFASetup, setShowMFASetup] = useState(false)
const [otpauthUrl, setOtpauthUrl] = useState('')
const saveSSOConfig = useMutation({
mutationFn: (data: { provider: SSOProvider; enabled: boolean; config: Record<string, string> }) =>
apiFetch('/api/sso/config', { method: 'PUT', body: JSON.stringify(data) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['sso-config'] })
toast.success('SSO configuration saved')
},
})
const setupMFA = useMutation({
mutationFn: () =>
apiFetch<{ otpauthUrl: string; secret: string }>('/api/sso/mfa/setup', {
method: 'POST',
body: JSON.stringify({}),
}),
onSuccess: (data) => {
setOtpauthUrl(data.otpauthUrl)
setShowMFASetup(true)
},
})
const verifyMFA = useMutation({
mutationFn: (token: string) =>
apiFetch('/api/sso/mfa/verify', { method: 'POST', body: JSON.stringify({ token }) }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
setShowMFASetup(false)
toast.success('MFA enabled successfully')
},
onError: () => toast.error('Invalid token'),
})
const disableMFA = useMutation({
mutationFn: () => apiFetch('/api/sso/mfa', { method: 'DELETE' }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['mfa-status'] })
toast.success('MFA disabled')
},
})
function getProviderFields(): { key: string; label: string; placeholder?: string }[] {
switch (provider) {
case 'saml':
return [
{ key: 'entryPoint', label: 'IdP Entry Point URL', placeholder: 'https://idp.example.com/saml/sso' },
{ key: 'issuer', label: 'Issuer / Entity ID', placeholder: 'abe-app' },
{ key: 'cert', label: 'IdP Certificate (PEM)', placeholder: '-----BEGIN CERTIFICATE-----...' },
{ key: 'callbackUrl', label: 'Callback URL', placeholder: 'https://abe.example.com/api/sso/saml/callback' },
]
case 'oidc':
return [
{ key: 'issuer', label: 'OIDC Issuer URL', placeholder: 'https://accounts.google.com' },
{ key: 'clientId', label: 'Client ID' },
{ key: 'clientSecret', label: 'Client Secret' },
{ key: 'redirectUri', label: 'Redirect URI', placeholder: 'https://abe.example.com/api/sso/oidc/callback' },
]
case 'ldap':
return [
{ key: 'url', label: 'LDAP URL', placeholder: 'ldap://ldap.example.com:389' },
{ key: 'baseDN', label: 'Base DN', placeholder: 'dc=example,dc=com' },
{ key: 'bindDN', label: 'Bind DN (optional)', placeholder: 'cn=admin,dc=example,dc=com' },
{ key: 'bindPassword', label: 'Bind Password (optional)' },
{ key: 'userSearchFilter', label: 'User Search Filter', placeholder: '(uid={username})' },
]
}
}
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold">SSO &amp; Security</h3>
<p className="text-sm text-muted-foreground">
Configure single sign-on and multi-factor authentication. Requires enterprise license.
</p>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Single Sign-On</CardTitle>
<CardDescription>Configure your identity provider.</CardDescription>
</div>
{ssoConfig && (
<Badge variant={ssoConfig.enabled ? 'default' : 'secondary'}>
{ssoConfig.enabled ? 'Enabled' : 'Disabled'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>Provider</Label>
<Select value={provider} onValueChange={(v) => setProvider(v as SSOProvider)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="saml">SAML 2.0</SelectItem>
<SelectItem value="oidc">OpenID Connect (OIDC)</SelectItem>
<SelectItem value="ldap">LDAP / Active Directory</SelectItem>
</SelectContent>
</Select>
</div>
{getProviderFields().map((field) => (
<div key={field.key} className="space-y-2">
<Label>{field.label}</Label>
<Input
value={configFields[field.key] ?? ssoConfig?.config[field.key] ?? ''}
placeholder={field.placeholder}
onChange={(e) => setConfigFields((prev) => ({ ...prev, [field.key]: e.target.value }))}
/>
</div>
))}
<Button
onClick={() => saveSSOConfig.mutate({ provider, enabled: true, config: configFields })}
disabled={saveSSOConfig.isPending}
>
Save SSO Configuration
</Button>
</CardContent>
</Card>
<Separator />
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-base">Multi-Factor Authentication</CardTitle>
<CardDescription>Add TOTP-based MFA to your account.</CardDescription>
</div>
{mfaStatus && (
<Badge variant={mfaStatus.verified ? 'default' : 'secondary'}>
{mfaStatus.verified ? 'Active' : mfaStatus.enabled ? 'Pending Verification' : 'Not Enabled'}
</Badge>
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{!mfaStatus?.enabled && !showMFASetup && (
<Button onClick={() => setupMFA.mutate()} disabled={setupMFA.isPending}>
Enable MFA
</Button>
)}
{showMFASetup && (
<div className="space-y-4">
<div className="p-3 bg-muted rounded-md text-sm">
<p className="font-medium mb-1">Setup Instructions:</p>
<ol className="list-decimal list-inside space-y-1 text-muted-foreground">
<li>Open your authenticator app (Google Authenticator, Authy, etc.)</li>
<li>Scan the QR code or enter the key URL manually into your app</li>
<li>Enter the 6-digit code below to verify</li>
</ol>
<p className="mt-2 font-mono text-xs break-all">{otpauthUrl}</p>
</div>
<div className="flex gap-2">
<Input
placeholder="Enter 6-digit code"
value={mfaToken}
onChange={(e) => setMfaToken(e.target.value)}
maxLength={6}
className="max-w-xs"
/>
<Button
onClick={() => verifyMFA.mutate(mfaToken)}
disabled={verifyMFA.isPending || mfaToken.length !== 6}
>
Verify
</Button>
</div>
</div>
)}
{mfaStatus?.verified && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Switch checked={true} disabled />
<span className="text-sm">MFA is active on this account</span>
</div>
<Button
variant="destructive"
size="sm"
onClick={() => disableMFA.mutate()}
disabled={disableMFA.isPending}
>
Disable MFA
</Button>
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -0,0 +1,100 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Skeleton } from '@/components/ui/skeleton'
import { Monitor, Trash2 } from 'lucide-react'
import { apiFetch } from '@/lib/api'
import { toast } from 'sonner'
interface ActiveSession {
id: string
createdAt: string
expiresAt: string
}
export function SessionsSection() {
const queryClient = useQueryClient()
const { data: sessions = [], isLoading } = useQuery<ActiveSession[]>({
queryKey: ['auth-sessions'],
queryFn: () => apiFetch<ActiveSession[]>('/api/auth/sessions'),
})
const revokeSession = useMutation({
mutationFn: (id: string) =>
apiFetch(`/api/auth/sessions/${id}`, { method: 'DELETE' }),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['auth-sessions'] })
toast.success('Session revoked')
},
})
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold">Active Sessions</h3>
<p className="text-sm text-muted-foreground">
View and revoke active login sessions for your account.
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="text-base">Sessions</CardTitle>
<CardDescription>
{sessions.length} active session{sessions.length !== 1 ? 's' : ''}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{isLoading && (
<div className="space-y-2">
{[1, 2, 3].map((i) => <Skeleton key={i} className="h-14 w-full" />)}
</div>
)}
{!isLoading && sessions.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No active sessions found.</p>
)}
{sessions.map((session) => {
const createdAt = new Date(session.createdAt)
const expiresAt = new Date(session.expiresAt)
const isExpiringSoon = expiresAt.getTime() - Date.now() < 24 * 60 * 60 * 1000
return (
<div key={session.id} className="flex items-center justify-between p-3 rounded-lg border">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-muted flex items-center justify-center">
<Monitor className="h-4 w-4 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Session {session.id.slice(0, 8)}</span>
{isExpiringSoon && (
<Badge variant="outline" className="text-xs">Expiring soon</Badge>
)}
</div>
<div className="text-xs text-muted-foreground">
Created {createdAt.toLocaleString()} · Expires {expiresAt.toLocaleString()}
</div>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => revokeSession.mutate(session.id)}
disabled={revokeSession.isPending}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)
})}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,11 +1,13 @@
import { NavLink, Outlet } from 'react-router-dom'
import { User, Building, Key, Sliders, Bell, Palette, Shield, Plug, Clock } from 'lucide-react'
import { User, Building, Key, Sliders, Bell, Palette, Shield, Plug, Clock, Lock, Monitor } from 'lucide-react'
import { cn } from '@/lib/utils'
const navItems = [
{ label: 'Profile', href: '/settings/profile', icon: User },
{ label: 'Organization', href: '/settings/organization', icon: Building },
{ label: 'API Keys', href: '/settings/api-keys', icon: Key },
{ label: 'Sessions', href: '/settings/sessions', icon: Monitor },
{ label: 'SSO & MFA', href: '/settings/sso', icon: Lock },
{ label: 'Exploration Defaults', href: '/settings/defaults', icon: Sliders },
{ label: 'Schedules', href: '/settings/schedules', icon: Clock },
{ label: 'Notifications', href: '/settings/notifications', icon: Bell },

643
package-lock.json generated
View File

@@ -12,6 +12,7 @@
"@asteasolutions/zod-to-openapi": "^8.4.3",
"@axe-core/playwright": "^4.11.1",
"@casl/ability": "^6.8.0",
"@node-saml/node-saml": "^5.1.0",
"@octokit/rest": "^22.0.1",
"@playwright/test": "^1.40.0",
"@scalar/express-api-reference": "^0.8.48",
@@ -31,11 +32,16 @@
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"kysely": "^0.28.11",
"ldapjs": "^3.0.7",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.1",
"openid-client": "^6.8.2",
"otpauth": "^9.5.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"pixelmatch": "^7.1.0",
"playwright": "^1.40.0",
"qrcode": "^1.5.4",
"recharts": "^3.7.0",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",
@@ -46,7 +52,10 @@
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/jest": "^29.5.0",
"@types/ldapjs": "^3.0.6",
"@types/node": "^20.0.0",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0",
"jest": "^29.5.0",
@@ -1510,6 +1519,125 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@ldapjs/asn1": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-2.0.0.tgz",
"integrity": "sha512-G9+DkEOirNgdPmD0I8nu57ygQJKOOgFEMKknEuQvIHbGLwP3ny1mY+OTUYLCbCaGJP4sox5eYgBJRuSUpnAddA==",
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
"license": "MIT"
},
"node_modules/@ldapjs/attribute": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ldapjs/attribute/-/attribute-1.0.0.tgz",
"integrity": "sha512-ptMl2d/5xJ0q+RgmnqOi3Zgwk/TMJYG7dYMC0Keko+yZU6n+oFM59MjQOUht5pxJeS4FWrImhu/LebX24vJNRQ==",
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
"license": "MIT",
"dependencies": {
"@ldapjs/asn1": "2.0.0",
"@ldapjs/protocol": "^1.2.1",
"process-warning": "^2.1.0"
}
},
"node_modules/@ldapjs/attribute/node_modules/process-warning": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz",
"integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==",
"license": "MIT"
},
"node_modules/@ldapjs/change": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@ldapjs/change/-/change-1.0.0.tgz",
"integrity": "sha512-EOQNFH1RIku3M1s0OAJOzGfAohuFYXFY4s73wOhRm4KFGhmQQ7MChOh2YtYu9Kwgvuq1B0xKciXVzHCGkB5V+Q==",
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
"license": "MIT",
"dependencies": {
"@ldapjs/asn1": "2.0.0",
"@ldapjs/attribute": "1.0.0"
}
},
"node_modules/@ldapjs/controls": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@ldapjs/controls/-/controls-2.1.0.tgz",
"integrity": "sha512-2pFdD1yRC9V9hXfAWvCCO2RRWK9OdIEcJIos/9cCVP9O4k72BY1bLDQQ4KpUoJnl4y/JoD4iFgM+YWT3IfITWw==",
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
"license": "MIT",
"dependencies": {
"@ldapjs/asn1": "^1.2.0",
"@ldapjs/protocol": "^1.2.1"
}
},
"node_modules/@ldapjs/controls/node_modules/@ldapjs/asn1": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@ldapjs/asn1/-/asn1-1.2.0.tgz",
"integrity": "sha512-KX/qQJ2xxzvO2/WOvr1UdQ+8P5dVvuOLk/C9b1bIkXxZss8BaR28njXdPgFCpj5aHaf1t8PmuVnea+N9YG9YMw==",
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
"license": "MIT"
},
"node_modules/@ldapjs/dn": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@ldapjs/dn/-/dn-1.1.0.tgz",
"integrity": "sha512-R72zH5ZeBj/Fujf/yBu78YzpJjJXG46YHFo5E4W1EqfNpo1UsVPqdLrRMXeKIsJT3x9dJVIfR6OpzgINlKpi0A==",
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
"license": "MIT",
"dependencies": {
"@ldapjs/asn1": "2.0.0",
"process-warning": "^2.1.0"
}
},
"node_modules/@ldapjs/dn/node_modules/process-warning": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz",
"integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==",
"license": "MIT"
},
"node_modules/@ldapjs/filter": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@ldapjs/filter/-/filter-2.1.1.tgz",
"integrity": "sha512-TwPK5eEgNdUO1ABPBUQabcZ+h9heDORE4V9WNZqCtYLKc06+6+UAJ3IAbr0L0bYTnkkWC/JEQD2F+zAFsuikNw==",
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
"license": "MIT",
"dependencies": {
"@ldapjs/asn1": "2.0.0",
"@ldapjs/protocol": "^1.2.1",
"process-warning": "^2.1.0"
}
},
"node_modules/@ldapjs/filter/node_modules/process-warning": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz",
"integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==",
"license": "MIT"
},
"node_modules/@ldapjs/messages": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@ldapjs/messages/-/messages-1.3.0.tgz",
"integrity": "sha512-K7xZpXJ21bj92jS35wtRbdcNrwmxAtPwy4myeh9duy/eR3xQKvikVycbdWVzkYEAVE5Ce520VXNOwCHjomjCZw==",
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
"license": "MIT",
"dependencies": {
"@ldapjs/asn1": "^2.0.0",
"@ldapjs/attribute": "^1.0.0",
"@ldapjs/change": "^1.0.0",
"@ldapjs/controls": "^2.1.0",
"@ldapjs/dn": "^1.1.0",
"@ldapjs/filter": "^2.1.1",
"@ldapjs/protocol": "^1.2.1",
"process-warning": "^2.2.0"
}
},
"node_modules/@ldapjs/messages/node_modules/process-warning": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-2.3.2.tgz",
"integrity": "sha512-n9wh8tvBe5sFmsqlg+XQhaQLumwpqoAUruLwjCopgTmUBjJ/fjtBsJzKleCaIGBOMXYEhp1YfKl4d7rJ5ZKJGA==",
"license": "MIT"
},
"node_modules/@ldapjs/protocol": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@ldapjs/protocol/-/protocol-1.2.1.tgz",
"integrity": "sha512-O89xFDLW2gBoZWNXuXpBSM32/KealKCTb3JGtJdtUQc7RjAk8XzrRgyz02cPAwGKwKPxy0ivuC7UP9bmN87egQ==",
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
"license": "MIT"
},
"node_modules/@noble/hashes": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz",
@@ -1523,6 +1651,29 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/@node-saml/node-saml": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@node-saml/node-saml/-/node-saml-5.1.0.tgz",
"integrity": "sha512-t3cJnZ4aC7HhPZ6MGylGZULvUtBOZ6FzuUndaHGXjmIZHXnLfC/7L8a57O9Q9V7AxJGKAiRM5zu2wNm9EsvQpw==",
"license": "MIT",
"dependencies": {
"@types/debug": "^4.1.12",
"@types/qs": "^6.9.18",
"@types/xml-encryption": "^1.2.4",
"@types/xml2js": "^0.4.14",
"@xmldom/is-dom-node": "^1.0.1",
"@xmldom/xmldom": "^0.8.10",
"debug": "^4.4.0",
"xml-crypto": "^6.1.2",
"xml-encryption": "^3.1.0",
"xml2js": "^0.6.2",
"xmlbuilder": "^15.1.1",
"xpath": "^0.0.34"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@octokit/auth-token": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz",
@@ -2100,6 +2251,15 @@
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
"license": "MIT",
"dependencies": {
"@types/ms": "*"
}
},
"node_modules/@types/express": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
@@ -2186,6 +2346,16 @@
"pretty-format": "^29.0.0"
}
},
"node_modules/@types/ldapjs": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/ldapjs/-/ldapjs-3.0.6.tgz",
"integrity": "sha512-E2Tn1ltJDYBsidOT9QG4engaQeQzRQ9aYNxVmjCkD33F7cIeLPgrRDXAYs0O35mK2YDU20c/+ZkNjeAPRGLM0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/methods": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
@@ -2193,6 +2363,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/ms": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "20.19.35",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.35.tgz",
@@ -2208,6 +2384,16 @@
"integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==",
"license": "MIT"
},
"node_modules/@types/nodemailer": {
"version": "7.0.11",
"resolved": "https://registry.npmjs.org/@types/nodemailer/-/nodemailer-7.0.11.tgz",
"integrity": "sha512-E+U4RzR2dKrx+u3N4DlsmLaDC6mMZOM/TPROxA0UAPiTgI0y4CEFBmZE+coGWTjakDriRsXG368lNk1u9Q0a2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/pino": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/@types/pino/-/pino-7.0.4.tgz",
@@ -2217,6 +2403,16 @@
"pino": "*"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
@@ -2298,6 +2494,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/xml-encryption": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/xml-encryption/-/xml-encryption-1.2.4.tgz",
"integrity": "sha512-I69K/WW1Dv7j6O3jh13z0X8sLWJRXbu5xnHDl9yHzUNDUBtUoBY058eb5s+x/WG6yZC1h8aKdI2EoyEPjyEh+Q==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/xml2js": {
"version": "0.4.14",
"resolved": "https://registry.npmjs.org/@types/xml2js/-/xml2js-0.4.14.tgz",
"integrity": "sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/yargs": {
"version": "17.0.35",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.35.tgz",
@@ -2350,6 +2564,30 @@
"@ucast/mongo": "2.4.3"
}
},
"node_modules/@xmldom/is-dom-node": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@xmldom/is-dom-node/-/is-dom-node-1.0.1.tgz",
"integrity": "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q==",
"license": "MIT",
"engines": {
"node": ">= 16"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.11",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz",
"integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -2409,7 +2647,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -2419,7 +2656,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -2485,6 +2721,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/assert-plus": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
"integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==",
"license": "MIT",
"engines": {
"node": ">=0.8"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2636,6 +2881,18 @@
"@babel/core": "^7.0.0"
}
},
"node_modules/backoff": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz",
"integrity": "sha512-wC5ihrnUXmR2douXmXLCe5O3zg3GKIyvRi/hi58a/XyRxVI+3/yM0PYueQOZXPXQ9pxBislYkw+sF9b7C/RuMA==",
"license": "MIT",
"dependencies": {
"precond": "0.2"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -2913,7 +3170,6 @@
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -3042,7 +3298,6 @@
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -3055,7 +3310,6 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/colorette": {
@@ -3175,6 +3429,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==",
"license": "MIT"
},
"node_modules/cors": {
"version": "2.8.6",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz",
@@ -3399,6 +3659,15 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
@@ -3522,6 +3791,12 @@
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
"license": "MIT"
},
"node_modules/dotenv": {
"version": "17.3.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.3.1.tgz",
@@ -3578,7 +3853,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/encodeurl": {
@@ -3911,6 +4185,15 @@
"express": ">= 4.11"
}
},
"node_modules/extsprintf": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz",
"integrity": "sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==",
"engines": [
"node >=0.6.0"
],
"license": "MIT"
},
"node_modules/fast-content-type-parse": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz",
@@ -4000,7 +4283,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dev": true,
"license": "MIT",
"dependencies": {
"locate-path": "^5.0.0",
@@ -4154,7 +4436,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true,
"license": "ISC",
"engines": {
"node": "6.* || 8.* || >= 10.*"
@@ -4550,7 +4831,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -5280,6 +5560,15 @@
"url": "https://github.com/chalk/supports-color?sponsor=1"
}
},
"node_modules/jose": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.0.tgz",
"integrity": "sha512-xsfE1TcSCbUdo6U07tR0mvhg0flGxU8tPLbF03mirl2ukGQENhUg4ubGYQnhVH0b5stLlPM+WOqDkEl1R1y5sQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/joycon": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz",
@@ -5368,6 +5657,29 @@
"node": ">=20.0.0"
}
},
"node_modules/ldapjs": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-3.0.7.tgz",
"integrity": "sha512-1ky+WrN+4CFMuoekUOv7Y1037XWdjKpu0xAPwSP+9KdvmV9PG+qOKlssDV6a+U32apwxdD3is/BZcWOYzN30cg==",
"deprecated": "This package has been decomissioned. See https://github.com/ldapjs/node-ldapjs/blob/8ffd0bc9c149088a10ec4c1ec6a18450f76ad05d/README.md",
"license": "MIT",
"dependencies": {
"@ldapjs/asn1": "^2.0.0",
"@ldapjs/attribute": "^1.0.0",
"@ldapjs/change": "^1.0.0",
"@ldapjs/controls": "^2.1.0",
"@ldapjs/dn": "^1.1.0",
"@ldapjs/filter": "^2.1.1",
"@ldapjs/messages": "^1.3.0",
"@ldapjs/protocol": "^1.2.1",
"abstract-logging": "^2.0.1",
"assert-plus": "^1.0.0",
"backoff": "^2.5.0",
"once": "^1.4.0",
"vasync": "^2.2.1",
"verror": "^1.10.1"
}
},
"node_modules/leven": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
@@ -5389,7 +5701,6 @@
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-locate": "^4.1.0"
@@ -5730,6 +6041,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.1.tgz",
"integrity": "sha512-5kcldIXmaEjZcHR6F28IKGSgpmZHaF1IXLWFTG+Xh3S+Cce4MiakLtWY+PlBU69fLbRa8HlaGIrC/QolUpHkhg==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
@@ -5753,6 +6073,15 @@
"node": ">=8"
}
},
"node_modules/oauth4webapi": {
"version": "3.8.5",
"resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.5.tgz",
"integrity": "sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@@ -5829,6 +6158,43 @@
"yaml": "^2.8.0"
}
},
"node_modules/openid-client": {
"version": "6.8.2",
"resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.2.tgz",
"integrity": "sha512-uOvTCndr4udZsKihJ68H9bUICrriHdUVJ6Az+4Ns6cW55rwM5h0bjVIzDz2SxgOI84LKjFyjOFvERLzdTUROGA==",
"license": "MIT",
"dependencies": {
"jose": "^6.1.3",
"oauth4webapi": "^3.8.4"
},
"funding": {
"url": "https://github.com/sponsors/panva"
}
},
"node_modules/otpauth": {
"version": "9.5.0",
"resolved": "https://registry.npmjs.org/otpauth/-/otpauth-9.5.0.tgz",
"integrity": "sha512-Ldhc6UYl4baR5toGr8nfKC+L/b8/RgHKoIixAebgoNGzUUCET02g04rMEZ2ZsPfeVQhMHcuaOgb28nwMr81zCA==",
"license": "MIT",
"dependencies": {
"@noble/hashes": "2.0.1"
},
"funding": {
"url": "https://github.com/hectorm/otpauth?sponsor=1"
}
},
"node_modules/otpauth/node_modules/@noble/hashes": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz",
"integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==",
"license": "MIT",
"engines": {
"node": ">= 20.19.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
@@ -5858,7 +6224,6 @@
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-limit": "^2.2.0"
@@ -5871,7 +6236,6 @@
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dev": true,
"license": "MIT",
"dependencies": {
"p-try": "^2.0.0"
@@ -5934,7 +6298,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
@@ -5972,7 +6335,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6222,6 +6584,14 @@
"node": ">=10"
}
},
"node_modules/precond": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz",
"integrity": "sha512-QCYG84SgGyGzqJ/vlMsxeXd/pgL/I94ixdNFyh1PusWmTCyVfPJjZ1K1jvHtsbfnXQs2TSkEP2fR7QiMZAnKFQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/pretty-format": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz",
@@ -6326,6 +6696,98 @@
],
"license": "MIT"
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"license": "MIT",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"license": "ISC",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/qrcode/node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"license": "MIT",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qrcode/node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
"license": "ISC"
},
"node_modules/qrcode/node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"license": "MIT",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/qrcode/node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"license": "ISC",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/qs": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
@@ -6519,12 +6981,17 @@
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
"license": "ISC"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
@@ -6645,6 +7112,15 @@
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/sax": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz",
"integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==",
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=11.0.0"
}
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -6723,6 +7199,12 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -7126,7 +7608,6 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -7141,7 +7622,6 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -7665,6 +8145,46 @@
"node": ">= 0.8"
}
},
"node_modules/vasync": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/vasync/-/vasync-2.2.1.tgz",
"integrity": "sha512-Hq72JaTpcTFdWiNA4Y22Amej2GH3BFmBaKPPlDZ4/oC8HNn2ISHLkFrJU4Ds8R3jcUi7oo5Y9jcMHKjES+N9wQ==",
"engines": [
"node >=0.6.0"
],
"license": "MIT",
"dependencies": {
"verror": "1.10.0"
}
},
"node_modules/vasync/node_modules/verror": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz",
"integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==",
"engines": [
"node >=0.6.0"
],
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
}
},
"node_modules/verror": {
"version": "1.10.1",
"resolved": "https://registry.npmjs.org/verror/-/verror-1.10.1.tgz",
"integrity": "sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==",
"license": "MIT",
"dependencies": {
"assert-plus": "^1.0.0",
"core-util-is": "1.0.2",
"extsprintf": "^1.2.0"
},
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
@@ -7712,6 +8232,12 @@
"node": ">= 8"
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
"license": "ISC"
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
@@ -7778,6 +8304,89 @@
}
}
},
"node_modules/xml-crypto": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/xml-crypto/-/xml-crypto-6.1.2.tgz",
"integrity": "sha512-leBOVQdVi8FvPJrMYoum7Ici9qyxfE4kVi+AkpUoYCSXaQF4IlBm1cneTK9oAxR61LpYxTx7lNcsnBIeRpGW2w==",
"license": "MIT",
"dependencies": {
"@xmldom/is-dom-node": "^1.0.1",
"@xmldom/xmldom": "^0.8.10",
"xpath": "^0.0.33"
},
"engines": {
"node": ">=16"
}
},
"node_modules/xml-crypto/node_modules/xpath": {
"version": "0.0.33",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.33.tgz",
"integrity": "sha512-NNXnzrkDrAzalLhIUc01jO2mOzXGXh1JwPgkihcLLzw98c0WgYDmmjSh1Kl3wzaxSVWMuA+fe0WTWOBDWCBmNA==",
"license": "MIT",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/xml-encryption": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/xml-encryption/-/xml-encryption-3.1.0.tgz",
"integrity": "sha512-PV7qnYpoAMXbf1kvQkqMScLeQpjCMixddAKq9PtqVrho8HnYbBOWNfG0kA4R7zxQDo7w9kiYAyzS/ullAyO55Q==",
"license": "MIT",
"dependencies": {
"@xmldom/xmldom": "^0.8.5",
"escape-html": "^1.0.3",
"xpath": "0.0.32"
}
},
"node_modules/xml-encryption/node_modules/xpath": {
"version": "0.0.32",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.32.tgz",
"integrity": "sha512-rxMJhSIoiO8vXcWvSifKqhvV96GjiD5wYb8/QHdoRyQvraTpp4IEv944nhGausZZ3u7dhQXteZuZbaqfpB7uYw==",
"license": "MIT",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/xml2js": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz",
"integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==",
"license": "MIT",
"dependencies": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
},
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/xml2js/node_modules/xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
},
"node_modules/xmlbuilder": {
"version": "15.1.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz",
"integrity": "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==",
"license": "MIT",
"engines": {
"node": ">=8.0"
}
},
"node_modules/xpath": {
"version": "0.0.34",
"resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz",
"integrity": "sha512-FxF6+rkr1rNSQrhUNYrAFJpRXNzlDoMxeXN5qI84939ylEv3qqPFKa85Oxr6tDaJKqwW6KKyo2v26TSv3k6LeA==",
"license": "MIT",
"engines": {
"node": ">=0.6.0"
}
},
"node_modules/y18n": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",

View File

@@ -26,7 +26,10 @@
"@types/better-sqlite3": "^7.6.13",
"@types/cookie-parser": "^1.4.10",
"@types/jest": "^29.5.0",
"@types/ldapjs": "^3.0.6",
"@types/node": "^20.0.0",
"@types/nodemailer": "^7.0.11",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^7.2.0",
"@types/uuid": "^10.0.0",
"jest": "^29.5.0",
@@ -39,6 +42,7 @@
"@asteasolutions/zod-to-openapi": "^8.4.3",
"@axe-core/playwright": "^4.11.1",
"@casl/ability": "^6.8.0",
"@node-saml/node-saml": "^5.1.0",
"@octokit/rest": "^22.0.1",
"@playwright/test": "^1.40.0",
"@scalar/express-api-reference": "^0.8.48",
@@ -58,11 +62,16 @@
"express-rate-limit": "^8.2.1",
"helmet": "^8.1.0",
"kysely": "^0.28.11",
"ldapjs": "^3.0.7",
"node-cron": "^4.2.1",
"nodemailer": "^8.0.1",
"openid-client": "^6.8.2",
"otpauth": "^9.5.0",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
"pixelmatch": "^7.1.0",
"playwright": "^1.40.0",
"qrcode": "^1.5.4",
"recharts": "^3.7.0",
"sharp": "^0.34.5",
"socket.io": "^4.8.3",

View File

@@ -14,6 +14,8 @@ import { LicenseService } from '../modules/licensing/application/LicenseService'
import { requireFeature } from '../modules/licensing/infrastructure/middleware/FeatureGateMiddleware';
import { createAuthController } from '../modules/auth/infrastructure/http/AuthController';
import { createAuthMiddleware } from '../modules/auth/application/middleware/AuthMiddleware';
import { createSSORouter } from '../modules/sso/infrastructure/http/SSOController';
import { createAuditRouter } from '../modules/audit/infrastructure/http/AuditController';
import { ServerDependencies } from './server';
import { RegisterCommand } from '../modules/auth/application/commands/RegisterCommand';
import { LoginCommand } from '../modules/auth/application/commands/LoginCommand';
@@ -80,5 +82,19 @@ export function createRouter(deps: ServerDependencies): Router {
const licensingController = new LicensingController(licenseService);
router.use('/license', licensingController.router);
// Enterprise: SSO + MFA (feature-gated)
router.use(
'/sso',
requireFeature(licenseService, 'auth:sso'),
createSSORouter(deps.ssoDeps)
);
// Enterprise: Audit logs (feature-gated)
router.use(
'/audit',
requireFeature(licenseService, 'audit:logs'),
createAuditRouter(deps.auditRepository)
);
return router;
}

View File

@@ -25,6 +25,16 @@ import { AuthControllerDeps } from './router';
import { LicenseService } from '../modules/licensing/application/LicenseService';
import { SchedulingControllerDeps } from '../modules/scheduling/infrastructure/http/SchedulingController';
import { VisualRegressionControllerDeps } from '../modules/visual-regression/infrastructure/http/VisualRegressionController';
import { KyselySSOConfigRepository } from '../modules/sso/infrastructure/repositories/KyselySSOConfigRepository';
import { KyselyTOTPRepository } from '../modules/sso/infrastructure/repositories/KyselyTOTPRepository';
import { TOTPService } from '../modules/sso/infrastructure/providers/TOTPService';
import { KyselyAuditRepository } from '../modules/audit/infrastructure/repositories/KyselyAuditRepository';
export interface SSODeps {
ssoConfigRepository: KyselySSOConfigRepository;
totpRepository: KyselyTOTPRepository;
totpService: TOTPService;
}
export interface ServerDependencies {
config: AppConfig;
@@ -39,6 +49,8 @@ export interface ServerDependencies {
visualRegressionDeps: VisualRegressionControllerDeps;
authDeps: AuthControllerDeps;
licenseService: LicenseService;
ssoDeps: SSODeps;
auditRepository: KyselyAuditRepository;
}
export function createServer(deps: ServerDependencies): Express {

View File

@@ -0,0 +1,51 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<unknown>): Promise<void> {
// SSO configurations per organization
await db.schema
.createTable('sso_configs')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('organization_id', 'text', (c) => c.notNull())
.addColumn('provider', 'text', (c) => c.notNull())
.addColumn('enabled', 'integer', (c) => c.notNull().defaultTo(1))
.addColumn('config_json', 'text', (c) => c.notNull().defaultTo('{}'))
.addColumn('created_at', 'integer', (c) => c.notNull())
.execute();
// TOTP secrets for MFA
await db.schema
.createTable('totp_secrets')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text', (c) => c.notNull().unique())
.addColumn('secret', 'text', (c) => c.notNull())
.addColumn('verified', 'integer', (c) => c.notNull().defaultTo(0))
.addColumn('created_at', 'integer', (c) => c.notNull())
.execute();
// Audit logs
await db.schema
.createTable('audit_logs')
.ifNotExists()
.addColumn('id', 'text', (c) => c.primaryKey())
.addColumn('user_id', 'text')
.addColumn('organization_id', 'text')
.addColumn('action', 'text', (c) => c.notNull())
.addColumn('resource', 'text', (c) => c.notNull())
.addColumn('resource_id', 'text')
.addColumn('ip_address', 'text')
.addColumn('user_agent', 'text')
.addColumn('details_json', 'text', (c) => c.notNull().defaultTo('{}'))
.addColumn('occurred_at', 'integer', (c) => c.notNull())
.execute();
await sql`CREATE INDEX IF NOT EXISTS idx_audit_logs_user ON audit_logs (user_id)`.execute(db);
await sql`CREATE INDEX IF NOT EXISTS idx_audit_logs_occurred ON audit_logs (occurred_at)`.execute(db);
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.dropTable('audit_logs').ifExists().execute();
await db.schema.dropTable('totp_secrets').ifExists().execute();
await db.schema.dropTable('sso_configs').ifExists().execute();
}

View File

@@ -75,6 +75,12 @@ import { ListComparisonsQuery } from './modules/visual-regression/application/qu
import { LocalStorageProvider } from './shared/infrastructure/StorageProvider';
import path from 'path';
// SSO + Audit modules (enterprise)
import { KyselySSOConfigRepository } from './modules/sso/infrastructure/repositories/KyselySSOConfigRepository';
import { KyselyTOTPRepository } from './modules/sso/infrastructure/repositories/KyselyTOTPRepository';
import { TOTPService } from './modules/sso/infrastructure/providers/TOTPService';
import { KyselyAuditRepository } from './modules/audit/infrastructure/repositories/KyselyAuditRepository';
// Scheduling module
import { KyselyScheduleRepository } from './modules/scheduling/infrastructure/repositories/KyselyScheduleRepository';
import { CreateScheduleCommand } from './modules/scheduling/application/commands/CreateScheduleCommand';
@@ -210,6 +216,12 @@ async function bootstrap(): Promise<void> {
const schedulingService = new SchedulingService(scheduleRepo, jobQueue, eventBus, logger);
await schedulingService.start();
// 12c. SSO + Audit modules (enterprise)
const ssoConfigRepo = new KyselySSOConfigRepository(db);
const totpRepo = new KyselyTOTPRepository(db);
const totpService = new TOTPService();
const auditRepo = new KyselyAuditRepository(db);
// 13. HTTP server
const app = createServer({
config,
@@ -235,6 +247,8 @@ async function bootstrap(): Promise<void> {
apiKeyRepository: apiKeyRepo,
userRepository: userRepo,
},
ssoDeps: { ssoConfigRepository: ssoConfigRepo, totpRepository: totpRepo, totpService },
auditRepository: auditRepo,
});
const httpServer = http.createServer(app);

View File

@@ -0,0 +1,34 @@
import { Entity } from '../../../../shared/domain/Entity';
import { UniqueId } from '../../../../shared/domain/UniqueId';
export interface AuditLogProps {
userId: string | null;
organizationId: string | null;
action: string;
resource: string;
resourceId: string | null;
ipAddress: string | null;
userAgent: string | null;
details: Record<string, unknown>;
occurredAt: Date;
}
export class AuditLog extends Entity<AuditLogProps> {
static create(props: AuditLogProps, id?: UniqueId): AuditLog {
return new AuditLog(props, id ?? UniqueId.create());
}
static reconstitute(props: AuditLogProps, id: UniqueId): AuditLog {
return new AuditLog(props, id);
}
get userId(): string | null { return this.props.userId; }
get organizationId(): string | null { return this.props.organizationId; }
get action(): string { return this.props.action; }
get resource(): string { return this.props.resource; }
get resourceId(): string | null { return this.props.resourceId; }
get ipAddress(): string | null { return this.props.ipAddress; }
get userAgent(): string | null { return this.props.userAgent; }
get details(): Record<string, unknown> { return this.props.details; }
get occurredAt(): Date { return this.props.occurredAt; }
}

View File

@@ -0,0 +1,5 @@
export { AuditLog } from './domain/entities/AuditLog';
export type { AuditLogProps } from './domain/entities/AuditLog';
export { KyselyAuditRepository } from './infrastructure/repositories/KyselyAuditRepository';
export type { AuditFilters } from './infrastructure/repositories/KyselyAuditRepository';
export { createAuditRouter } from './infrastructure/http/AuditController';

View File

@@ -0,0 +1,39 @@
import { Router, Request, Response, NextFunction } from 'express';
import { KyselyAuditRepository, AuditFilters } from '../repositories/KyselyAuditRepository';
export function createAuditRouter(repo: KyselyAuditRepository): Router {
const router = Router();
// GET /api/audit — list audit logs (enterprise only)
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
try {
const filters: AuditFilters = {
userId: req.query['userId'] as string | undefined,
organizationId: req.query['organizationId'] as string | undefined,
action: req.query['action'] as string | undefined,
resource: req.query['resource'] as string | undefined,
limit: req.query['limit'] ? Number(req.query['limit']) : 100,
};
if (req.query['from']) filters.from = new Date(req.query['from'] as string);
if (req.query['to']) filters.to = new Date(req.query['to'] as string);
const logs = await repo.findAll(filters);
res.json(logs.map((l) => ({
id: l.id.toString(),
userId: l.userId,
organizationId: l.organizationId,
action: l.action,
resource: l.resource,
resourceId: l.resourceId,
ipAddress: l.ipAddress,
details: l.details,
occurredAt: l.occurredAt.toISOString(),
})));
} catch (err) {
next(err);
}
});
return router;
}

View File

@@ -0,0 +1,66 @@
import { Kysely } from 'kysely';
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
import { UniqueId } from '../../../../shared/domain/UniqueId';
import { AuditLog } from '../../domain/entities/AuditLog';
export interface AuditFilters {
userId?: string;
organizationId?: string;
action?: string;
resource?: string;
from?: Date;
to?: Date;
limit?: number;
}
export class KyselyAuditRepository {
constructor(private readonly db: Kysely<Database>) {}
async save(log: AuditLog): Promise<void> {
await this.db.insertInto('audit_logs').values({
id: log.id.toString(),
user_id: log.userId,
organization_id: log.organizationId,
action: log.action,
resource: log.resource,
resource_id: log.resourceId,
ip_address: log.ipAddress,
user_agent: log.userAgent,
details_json: JSON.stringify(log.details),
occurred_at: log.occurredAt.getTime(),
}).execute();
}
async findAll(filters: AuditFilters = {}): Promise<AuditLog[]> {
let query = this.db.selectFrom('audit_logs').selectAll();
if (filters.userId) query = query.where('user_id', '=', filters.userId);
if (filters.organizationId) query = query.where('organization_id', '=', filters.organizationId);
if (filters.action) query = query.where('action', '=', filters.action);
if (filters.resource) query = query.where('resource', '=', filters.resource);
if (filters.from) query = query.where('occurred_at', '>=', filters.from.getTime());
if (filters.to) query = query.where('occurred_at', '<=', filters.to.getTime());
const rows = await query
.orderBy('occurred_at', 'desc')
.limit(filters.limit ?? 100)
.execute();
return rows.map((row) =>
AuditLog.reconstitute(
{
userId: row.user_id,
organizationId: row.organization_id,
action: row.action,
resource: row.resource,
resourceId: row.resource_id,
ipAddress: row.ip_address,
userAgent: row.user_agent,
details: JSON.parse(row.details_json) as Record<string, unknown>,
occurredAt: new Date(row.occurred_at),
},
UniqueId.from(row.id)
)
);
}
}

View File

@@ -9,6 +9,8 @@ export interface AuthSession {
export interface ISessionRepository {
save(session: AuthSession): Promise<void>;
findByToken(token: string): Promise<AuthSession | undefined>;
findByUserId(userId: string): Promise<AuthSession[]>;
deleteByToken(token: string): Promise<void>;
deleteById(id: string): Promise<void>;
deleteExpired(): Promise<void>;
}

View File

@@ -206,5 +206,31 @@ export function createAuthController(
res.json({ success: true });
});
// GET /api/auth/sessions — list active sessions (session management dashboard)
router.get('/sessions', authMiddleware, async (req: Request, res: Response) => {
const sessions = await sessionRepository.findByUserId(req.user!.id);
res.json(
sessions.map((s) => ({
id: s.id,
createdAt: new Date(s.createdAt).toISOString(),
expiresAt: new Date(s.expiresAt).toISOString(),
}))
);
});
// DELETE /api/auth/sessions/:id — revoke a specific session
router.delete('/sessions/:id', authMiddleware, async (req: Request, res: Response) => {
const sessionId = String(req.params['id']);
// Only allow revoking own sessions
const userSessions = await sessionRepository.findByUserId(req.user!.id);
const owns = userSessions.some((s) => s.id === sessionId);
if (!owns) {
res.status(404).json({ error: 'Session not found' });
return;
}
await sessionRepository.deleteById(sessionId);
res.json({ success: true });
});
return router;
}

View File

@@ -34,10 +34,31 @@ export class KyselySessionRepository implements ISessionRepository {
};
}
async findByUserId(userId: string): Promise<AuthSession[]> {
const rows = await this.db
.selectFrom('auth_sessions')
.selectAll()
.where('user_id', '=', userId)
.where('expires_at', '>', Date.now())
.orderBy('created_at', 'desc')
.execute();
return rows.map((row) => ({
id: row.id,
userId: row.user_id,
token: row.token,
expiresAt: new Date(row.expires_at),
createdAt: new Date(row.created_at),
}));
}
async deleteByToken(token: string): Promise<void> {
await this.db.deleteFrom('auth_sessions').where('token', '=', token).execute();
}
async deleteById(id: string): Promise<void> {
await this.db.deleteFrom('auth_sessions').where('id', '=', id).execute();
}
async deleteExpired(): Promise<void> {
await this.db
.deleteFrom('auth_sessions')

View File

@@ -0,0 +1,31 @@
import { Entity } from '../../../../shared/domain/Entity';
import { UniqueId } from '../../../../shared/domain/UniqueId';
export type SSOProvider = 'saml' | 'oidc' | 'ldap';
export interface SSOConfigProps {
organizationId: string;
provider: SSOProvider;
enabled: boolean;
config: Record<string, string>;
createdAt: Date;
}
export class SSOConfig extends Entity<SSOConfigProps> {
static create(props: SSOConfigProps, id?: UniqueId): SSOConfig {
return new SSOConfig(props, id ?? UniqueId.create());
}
static reconstitute(props: SSOConfigProps, id: UniqueId): SSOConfig {
return new SSOConfig(props, id);
}
get organizationId(): string { return this.props.organizationId; }
get provider(): SSOProvider { return this.props.provider; }
get enabled(): boolean { return this.props.enabled; }
get config(): Record<string, string> { return this.props.config; }
get createdAt(): Date { return this.props.createdAt; }
enable(): void { this.props.enabled = true; }
disable(): void { this.props.enabled = false; }
}

View File

@@ -0,0 +1,26 @@
import { Entity } from '../../../../shared/domain/Entity';
import { UniqueId } from '../../../../shared/domain/UniqueId';
export interface TOTPSecretProps {
userId: string;
secret: string;
verified: boolean;
createdAt: Date;
}
export class TOTPSecret extends Entity<TOTPSecretProps> {
static create(props: TOTPSecretProps, id?: UniqueId): TOTPSecret {
return new TOTPSecret(props, id ?? UniqueId.create());
}
static reconstitute(props: TOTPSecretProps, id: UniqueId): TOTPSecret {
return new TOTPSecret(props, id);
}
get userId(): string { return this.props.userId; }
get secret(): string { return this.props.secret; }
get verified(): boolean { return this.props.verified; }
get createdAt(): Date { return this.props.createdAt; }
verify(): void { this.props.verified = true; }
}

View File

@@ -0,0 +1,7 @@
import { SSOConfig } from '../entities/SSOConfig';
export interface ISSOConfigRepository {
save(config: SSOConfig): Promise<void>;
findByOrganizationId(organizationId: string): Promise<SSOConfig | null>;
findById(id: string): Promise<SSOConfig | null>;
}

View File

@@ -0,0 +1,7 @@
import { TOTPSecret } from '../entities/TOTPSecret';
export interface ITOTPRepository {
save(secret: TOTPSecret): Promise<void>;
findByUserId(userId: string): Promise<TOTPSecret | null>;
delete(userId: string): Promise<void>;
}

12
src/modules/sso/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export { SSOConfig } from './domain/entities/SSOConfig';
export type { SSOProvider } from './domain/entities/SSOConfig';
export { TOTPSecret } from './domain/entities/TOTPSecret';
export type { ISSOConfigRepository } from './domain/ports/ISSOConfigRepository';
export type { ITOTPRepository } from './domain/ports/ITOTPRepository';
export { KyselySSOConfigRepository } from './infrastructure/repositories/KyselySSOConfigRepository';
export { KyselyTOTPRepository } from './infrastructure/repositories/KyselyTOTPRepository';
export { TOTPService } from './infrastructure/providers/TOTPService';
export { SAMLProvider } from './infrastructure/providers/SAMLProvider';
export { OIDCProvider } from './infrastructure/providers/OIDCProvider';
export { LDAPProvider } from './infrastructure/providers/LDAPProvider';
export { createSSORouter } from './infrastructure/http/SSOController';

View File

@@ -0,0 +1,169 @@
/**
* SSO Controller — manages SSO provider configurations and TOTP/MFA.
* Feature-gated: enterprise license required.
*/
import { Router, Request, Response, NextFunction } from 'express';
import { KyselySSOConfigRepository } from '../repositories/KyselySSOConfigRepository';
import { KyselyTOTPRepository } from '../repositories/KyselyTOTPRepository';
import { TOTPService } from '../providers/TOTPService';
import { SSOConfig } from '../../domain/entities/SSOConfig';
import { TOTPSecret } from '../../domain/entities/TOTPSecret';
import { AuthenticatedUser } from '../../../auth/application/middleware/AuthMiddleware';
interface SSODeps {
ssoConfigRepository: KyselySSOConfigRepository;
totpRepository: KyselyTOTPRepository;
totpService: TOTPService;
}
export function createSSORouter(deps: SSODeps): Router {
const router = Router();
const { ssoConfigRepository, totpRepository, totpService } = deps;
// GET /api/sso/config — get SSO config for the current org
router.get('/config', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user as AuthenticatedUser;
if (!user?.orgId) return res.status(400).json({ error: 'No organization' });
const config = await ssoConfigRepository.findByOrganizationId(user.orgId);
if (!config) return res.json(null);
res.json({
id: config.id.toString(),
provider: config.provider,
enabled: config.enabled,
config: config.config,
createdAt: config.createdAt.toISOString(),
});
} catch (err) {
next(err);
}
});
// PUT /api/sso/config — create or update SSO config
router.put('/config', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user as AuthenticatedUser;
if (!user?.orgId) return res.status(400).json({ error: 'No organization' });
const { provider, enabled, config } = req.body as {
provider: 'saml' | 'oidc' | 'ldap';
enabled: boolean;
config: Record<string, string>;
};
if (!['saml', 'oidc', 'ldap'].includes(provider)) {
return res.status(400).json({ error: 'Invalid provider' });
}
const existing = await ssoConfigRepository.findByOrganizationId(user.orgId);
if (existing) {
if (enabled !== undefined) {
enabled ? existing.enable() : existing.disable();
}
// Merge config fields
Object.assign(existing.config, config ?? {});
await ssoConfigRepository.save(existing);
return res.json({ id: existing.id.toString(), provider: existing.provider, enabled: existing.enabled });
}
const ssoConfig = SSOConfig.create({
organizationId: user.orgId,
provider,
enabled: enabled ?? true,
config: config ?? {},
createdAt: new Date(),
});
await ssoConfigRepository.save(ssoConfig);
res.status(201).json({ id: ssoConfig.id.toString(), provider, enabled: ssoConfig.enabled });
} catch (err) {
next(err);
}
});
// DELETE /api/sso/config — remove SSO config (disables SSO)
router.delete('/config', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user as AuthenticatedUser;
if (!user?.orgId) return res.status(400).json({ error: 'No organization' });
const config = await ssoConfigRepository.findByOrganizationId(user.orgId);
if (!config) return res.status(404).json({ error: 'Not found' });
config.disable();
await ssoConfigRepository.save(config);
res.json({ success: true });
} catch (err) {
next(err);
}
});
// POST /api/sso/mfa/setup — generate TOTP secret for current user
router.post('/mfa/setup', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user as AuthenticatedUser;
const { otpauthUrl, secret } = totpService.generateSecret(user.id, user.email);
// Save unverified secret
const totpSecret = TOTPSecret.create({
userId: user.id,
secret,
verified: false,
createdAt: new Date(),
});
await totpRepository.save(totpSecret);
res.json({ otpauthUrl, secret });
} catch (err) {
next(err);
}
});
// POST /api/sso/mfa/verify — verify TOTP token and mark as verified
router.post('/mfa/verify', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user as AuthenticatedUser;
const { token } = req.body as { token: string };
const stored = await totpRepository.findByUserId(user.id);
if (!stored) return res.status(400).json({ error: 'MFA not set up' });
if (!totpService.verify(stored.secret, token)) {
return res.status(400).json({ error: 'Invalid token' });
}
stored.verify();
await totpRepository.save(stored);
res.json({ verified: true });
} catch (err) {
next(err);
}
});
// DELETE /api/sso/mfa — remove MFA for current user
router.delete('/mfa', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user as AuthenticatedUser;
await totpRepository.delete(user.id);
res.json({ success: true });
} catch (err) {
next(err);
}
});
// GET /api/sso/mfa/status — check MFA status
router.get('/mfa/status', async (req: Request, res: Response, next: NextFunction) => {
try {
const user = req.user as AuthenticatedUser;
const stored = await totpRepository.findByUserId(user.id);
res.json({ enabled: !!stored, verified: stored?.verified ?? false });
} catch (err) {
next(err);
}
});
return router;
}

View File

@@ -0,0 +1,107 @@
/**
* LDAP/Active Directory authentication provider.
*/
import ldap from 'ldapjs';
export interface LDAPProviderConfig {
url: string;
baseDN: string;
bindDN?: string;
bindPassword?: string;
userSearchFilter?: string;
tlsOptions?: { rejectUnauthorized: boolean };
}
export interface LDAPUser {
dn: string;
email?: string;
displayName?: string;
groups?: string[];
}
export class LDAPProvider {
constructor(private readonly config: LDAPProviderConfig) {}
async authenticate(username: string, password: string): Promise<LDAPUser | null> {
return new Promise((resolve, reject) => {
const client = ldap.createClient({
url: this.config.url,
tlsOptions: this.config.tlsOptions,
});
client.on('error', (err: Error) => {
reject(err);
});
const escaped = username.replace(/[\\*()\x00]/g, (c) => `\\${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
const filter = (this.config.userSearchFilter ?? '(uid={username})')
.replace('{username}', escaped);
// Bind with service account if provided
const bindDN = this.config.bindDN ?? '';
const bindPwd = this.config.bindPassword ?? '';
client.bind(bindDN, bindPwd, (err) => {
if (err) {
client.destroy();
return resolve(null);
}
client.search(
this.config.baseDN,
{ filter, scope: 'sub', attributes: ['dn', 'mail', 'displayName', 'memberOf'] },
(searchErr, res) => {
if (searchErr) {
client.destroy();
return reject(searchErr);
}
let foundEntry: ldap.SearchEntry | null = null;
res.on('searchEntry', (entry) => {
foundEntry = entry;
});
res.on('error', (resErr) => {
client.destroy();
reject(resErr);
});
res.on('end', () => {
if (!foundEntry) {
client.destroy();
return resolve(null);
}
const entry = foundEntry as ldap.SearchEntry;
const userDN = entry.objectName ?? '';
// Authenticate as the found user
client.bind(userDN, password, (authErr) => {
client.destroy();
if (authErr) {
return resolve(null);
}
const obj = entry.pojo;
const getAttr = (name: string): string | undefined => {
const attr = obj.attributes.find((a) => a.type === name);
return attr?.values[0];
};
resolve({
dn: userDN,
email: getAttr('mail'),
displayName: getAttr('displayName'),
groups: obj.attributes
.find((a) => a.type === 'memberOf')
?.values ?? [],
});
});
});
}
);
});
});
}
}

View File

@@ -0,0 +1,68 @@
/**
* OIDC (OpenID Connect) SSO provider.
* Supports Okta, Azure AD, Google Workspace.
*/
import { discovery, authorizationCodeGrant, randomState, buildAuthorizationUrl } from 'openid-client';
export interface OIDCProviderConfig {
issuer: string;
clientId: string;
clientSecret: string;
redirectUri: string;
scopes?: string[];
}
export interface OIDCProfile {
sub: string;
email?: string;
name?: string;
}
export class OIDCProvider {
private readonly config: OIDCProviderConfig;
constructor(config: OIDCProviderConfig) {
this.config = config;
}
async generateAuthUrl(): Promise<{ url: string; state: string; codeVerifier: string }> {
const issuerUrl = new URL(this.config.issuer);
const oidcConfig = await discovery(issuerUrl, this.config.clientId, this.config.clientSecret);
const state = randomState();
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
response_type: 'code',
scope: (this.config.scopes ?? ['openid', 'email', 'profile']).join(' '),
state,
});
const url = buildAuthorizationUrl(oidcConfig, params);
return { url: url.href, state, codeVerifier: '' };
}
async handleCallback(
params: URLSearchParams,
expectedState: string,
_codeVerifier: string
): Promise<OIDCProfile> {
const issuerUrl = new URL(this.config.issuer);
const oidcConfig = await discovery(issuerUrl, this.config.clientId, this.config.clientSecret);
const currentUrl = new URL(`${this.config.redirectUri}?${params.toString()}`);
const tokens = await authorizationCodeGrant(oidcConfig, currentUrl, {
expectedState,
});
const claims = tokens.claims();
if (!claims) {
throw new Error('OIDC: no claims in token response');
}
return {
sub: String(claims['sub'] ?? ''),
email: typeof claims['email'] === 'string' ? claims['email'] : undefined,
name: typeof claims['name'] === 'string' ? claims['name'] : undefined,
};
}
}

View File

@@ -0,0 +1,49 @@
/**
* SAML 2.0 SSO provider.
* Uses @node-saml/node-saml for SP-initiated SSO.
*/
import { SAML, SamlConfig } from '@node-saml/node-saml';
export interface SAMLProviderConfig {
entryPoint: string;
issuer: string;
cert: string;
callbackUrl: string;
}
export interface SAMLProfile {
nameID: string;
email?: string;
displayName?: string;
}
export class SAMLProvider {
private readonly saml: SAML;
constructor(config: SAMLProviderConfig) {
const samlConfig: SamlConfig = {
entryPoint: config.entryPoint,
issuer: config.issuer,
idpCert: config.cert,
callbackUrl: config.callbackUrl,
wantAuthnResponseSigned: false,
};
this.saml = new SAML(samlConfig);
}
async generateAuthUrl(relayState?: string): Promise<string> {
return this.saml.getAuthorizeUrlAsync(relayState ?? '', undefined, {});
}
async validateResponse(body: Record<string, string>): Promise<SAMLProfile> {
const { profile } = await this.saml.validatePostResponseAsync(body);
if (!profile) {
throw new Error('SAML validation failed: no profile');
}
return {
nameID: typeof profile.nameID === 'string' ? profile.nameID : '',
email: typeof profile['email'] === 'string' ? profile['email'] : undefined,
displayName: typeof profile.displayName === 'string' ? profile.displayName : undefined,
};
}
}

View File

@@ -0,0 +1,47 @@
/**
* TOTP (Time-based One-Time Password) service.
* Uses otpauth library for RFC 6238 compliant TOTP.
*/
import { TOTP, Secret } from 'otpauth';
export interface TOTPSetup {
secret: string;
otpauthUrl: string;
}
export class TOTPService {
private readonly issuer: string;
constructor(issuer = 'ABE') {
this.issuer = issuer;
}
generateSecret(userId: string, label: string): TOTPSetup {
const totp = new TOTP({
issuer: this.issuer,
label,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: new Secret({ size: 20 }),
});
return {
secret: totp.secret.base32,
otpauthUrl: totp.toString(),
};
}
verify(secret: string, token: string): boolean {
const totp = new TOTP({
issuer: this.issuer,
algorithm: 'SHA1',
digits: 6,
period: 30,
secret: Secret.fromBase32(secret),
});
const delta = totp.validate({ token, window: 1 });
return delta !== null;
}
}

View File

@@ -0,0 +1,67 @@
import { Kysely } from 'kysely';
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
import { ISSOConfigRepository } from '../../domain/ports/ISSOConfigRepository';
import { SSOConfig, SSOProvider } from '../../domain/entities/SSOConfig';
import { UniqueId } from '../../../../shared/domain/UniqueId';
export class KyselySSOConfigRepository implements ISSOConfigRepository {
constructor(private readonly db: Kysely<Database>) {}
async save(config: SSOConfig): Promise<void> {
await this.db
.insertInto('sso_configs')
.values({
id: config.id.toString(),
organization_id: config.organizationId,
provider: config.provider,
enabled: config.enabled ? 1 : 0,
config_json: JSON.stringify(config.config),
created_at: config.createdAt.getTime(),
})
.onConflict((oc) =>
oc.column('id').doUpdateSet({
enabled: config.enabled ? 1 : 0,
config_json: JSON.stringify(config.config),
})
)
.execute();
}
async findByOrganizationId(organizationId: string): Promise<SSOConfig | null> {
const row = await this.db
.selectFrom('sso_configs')
.selectAll()
.where('organization_id', '=', organizationId)
.executeTakeFirst();
return row ? this.toDomain(row) : null;
}
async findById(id: string): Promise<SSOConfig | null> {
const row = await this.db
.selectFrom('sso_configs')
.selectAll()
.where('id', '=', id)
.executeTakeFirst();
return row ? this.toDomain(row) : null;
}
private toDomain(row: {
id: string;
organization_id: string;
provider: string;
enabled: number;
config_json: string;
created_at: number;
}): SSOConfig {
return SSOConfig.reconstitute(
{
organizationId: row.organization_id,
provider: row.provider as SSOProvider,
enabled: row.enabled === 1,
config: JSON.parse(row.config_json) as Record<string, string>,
createdAt: new Date(row.created_at),
},
UniqueId.from(row.id)
);
}
}

View File

@@ -0,0 +1,50 @@
import { Kysely } from 'kysely';
import { Database } from '../../../../shared/infrastructure/DatabaseConnection';
import { ITOTPRepository } from '../../domain/ports/ITOTPRepository';
import { TOTPSecret } from '../../domain/entities/TOTPSecret';
import { UniqueId } from '../../../../shared/domain/UniqueId';
export class KyselyTOTPRepository implements ITOTPRepository {
constructor(private readonly db: Kysely<Database>) {}
async save(secret: TOTPSecret): Promise<void> {
await this.db
.insertInto('totp_secrets')
.values({
id: secret.id.toString(),
user_id: secret.userId,
secret: secret.secret,
verified: secret.verified ? 1 : 0,
created_at: secret.createdAt.getTime(),
})
.onConflict((oc) =>
oc.column('user_id').doUpdateSet({
secret: secret.secret,
verified: secret.verified ? 1 : 0,
})
)
.execute();
}
async findByUserId(userId: string): Promise<TOTPSecret | null> {
const row = await this.db
.selectFrom('totp_secrets')
.selectAll()
.where('user_id', '=', userId)
.executeTakeFirst();
if (!row) return null;
return TOTPSecret.reconstitute(
{
userId: row.user_id,
secret: row.secret,
verified: row.verified === 1,
createdAt: new Date(row.created_at),
},
UniqueId.from(row.id)
);
}
async delete(userId: string): Promise<void> {
await this.db.deleteFrom('totp_secrets').where('user_id', '=', userId).execute();
}
}

View File

@@ -242,6 +242,36 @@ export interface WebhookDeliveryTable {
attempted_at: number;
}
export interface SSOConfigTable {
id: string;
organization_id: string;
provider: string;
enabled: number;
config_json: string;
created_at: number;
}
export interface TOTPSecretTable {
id: string;
user_id: string;
secret: string;
verified: number;
created_at: number;
}
export interface AuditLogTable {
id: string;
user_id: string | null;
organization_id: string | null;
action: string;
resource: string;
resource_id: string | null;
ip_address: string | null;
user_agent: string | null;
details_json: string;
occurred_at: number;
}
export interface Database {
sessions: SessionTable;
states: StateTable;
@@ -263,6 +293,9 @@ export interface Database {
integrations: IntegrationTable;
webhook_endpoints: WebhookEndpointTable;
webhook_deliveries: WebhookDeliveryTable;
sso_configs: SSOConfigTable;
totp_secrets: TOTPSecretTable;
audit_logs: AuditLogTable;
}
export function createDatabase(config: { driver: string; path: string; url?: string }): Kysely<Database> {

View File

@@ -61,6 +61,12 @@ class InMemorySessionRepository implements ISessionRepository {
if (session.expiresAt < now) this.store.delete(token);
}
}
async findByUserId(userId: string): Promise<AuthSession[]> {
return Array.from(this.store.values()).filter(s => s.userId === userId);
}
async deleteById(id: string): Promise<void> {
for (const [token, s] of this.store) { if (s.id === id) { this.store.delete(token); break; } }
}
}
// ─── Tests: Email Value Object ─────────────────────────────────────────────────