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:
@@ -1 +1 @@
|
||||
49e76c92b17a3510da50ae1deaf8002c5c67d010
|
||||
c3911bafe885d664a6870305dff172e1410a95ac
|
||||
|
||||
@@ -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
6
dist/api/router.js
vendored
@@ -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;
|
||||
}
|
||||
|
||||
50
dist/db/migrations/007_enterprise_tables.js
vendored
Normal file
50
dist/db/migrations/007_enterprise_tables.js
vendored
Normal 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
12
dist/main.js
vendored
@@ -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
|
||||
|
||||
23
dist/modules/audit/domain/entities/AuditLog.js
vendored
Normal file
23
dist/modules/audit/domain/entities/AuditLog.js
vendored
Normal 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
9
dist/modules/audit/index.js
vendored
Normal 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; } });
|
||||
39
dist/modules/audit/infrastructure/http/AuditController.js
vendored
Normal file
39
dist/modules/audit/infrastructure/http/AuditController.js
vendored
Normal 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;
|
||||
}
|
||||
55
dist/modules/audit/infrastructure/repositories/KyselyAuditRepository.js
vendored
Normal file
55
dist/modules/audit/infrastructure/repositories/KyselyAuditRepository.js
vendored
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
21
dist/modules/sso/domain/entities/SSOConfig.js
vendored
Normal file
21
dist/modules/sso/domain/entities/SSOConfig.js
vendored
Normal 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;
|
||||
19
dist/modules/sso/domain/entities/TOTPSecret.js
vendored
Normal file
19
dist/modules/sso/domain/entities/TOTPSecret.js
vendored
Normal 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;
|
||||
2
dist/modules/sso/domain/ports/ISSOConfigRepository.js
vendored
Normal file
2
dist/modules/sso/domain/ports/ISSOConfigRepository.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
2
dist/modules/sso/domain/ports/ITOTPRepository.js
vendored
Normal file
2
dist/modules/sso/domain/ports/ITOTPRepository.js
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
21
dist/modules/sso/index.js
vendored
Normal file
21
dist/modules/sso/index.js
vendored
Normal 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; } });
|
||||
147
dist/modules/sso/infrastructure/http/SSOController.js
vendored
Normal file
147
dist/modules/sso/infrastructure/http/SSOController.js
vendored
Normal 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;
|
||||
}
|
||||
81
dist/modules/sso/infrastructure/providers/LDAPProvider.js
vendored
Normal file
81
dist/modules/sso/infrastructure/providers/LDAPProvider.js
vendored
Normal 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;
|
||||
45
dist/modules/sso/infrastructure/providers/OIDCProvider.js
vendored
Normal file
45
dist/modules/sso/infrastructure/providers/OIDCProvider.js
vendored
Normal 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;
|
||||
35
dist/modules/sso/infrastructure/providers/SAMLProvider.js
vendored
Normal file
35
dist/modules/sso/infrastructure/providers/SAMLProvider.js
vendored
Normal 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;
|
||||
39
dist/modules/sso/infrastructure/providers/TOTPService.js
vendored
Normal file
39
dist/modules/sso/infrastructure/providers/TOTPService.js
vendored
Normal 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;
|
||||
53
dist/modules/sso/infrastructure/repositories/KyselySSOConfigRepository.js
vendored
Normal file
53
dist/modules/sso/infrastructure/repositories/KyselySSOConfigRepository.js
vendored
Normal 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;
|
||||
45
dist/modules/sso/infrastructure/repositories/KyselyTOTPRepository.js
vendored
Normal file
45
dist/modules/sso/infrastructure/repositories/KyselyTOTPRepository.js
vendored
Normal 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;
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
@@ -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 => (
|
||||
|
||||
226
frontend/src/pages/settings/SSOSection.tsx
Normal file
226
frontend/src/pages/settings/SSOSection.tsx
Normal 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 & 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>
|
||||
)
|
||||
}
|
||||
100
frontend/src/pages/settings/SessionsSection.tsx
Normal file
100
frontend/src/pages/settings/SessionsSection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
643
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
51
src/db/migrations/007_enterprise_tables.ts
Normal file
51
src/db/migrations/007_enterprise_tables.ts
Normal 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();
|
||||
}
|
||||
14
src/main.ts
14
src/main.ts
@@ -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);
|
||||
|
||||
34
src/modules/audit/domain/entities/AuditLog.ts
Normal file
34
src/modules/audit/domain/entities/AuditLog.ts
Normal 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; }
|
||||
}
|
||||
5
src/modules/audit/index.ts
Normal file
5
src/modules/audit/index.ts
Normal 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';
|
||||
39
src/modules/audit/infrastructure/http/AuditController.ts
Normal file
39
src/modules/audit/infrastructure/http/AuditController.ts
Normal 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;
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
|
||||
31
src/modules/sso/domain/entities/SSOConfig.ts
Normal file
31
src/modules/sso/domain/entities/SSOConfig.ts
Normal 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; }
|
||||
}
|
||||
26
src/modules/sso/domain/entities/TOTPSecret.ts
Normal file
26
src/modules/sso/domain/entities/TOTPSecret.ts
Normal 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; }
|
||||
}
|
||||
7
src/modules/sso/domain/ports/ISSOConfigRepository.ts
Normal file
7
src/modules/sso/domain/ports/ISSOConfigRepository.ts
Normal 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>;
|
||||
}
|
||||
7
src/modules/sso/domain/ports/ITOTPRepository.ts
Normal file
7
src/modules/sso/domain/ports/ITOTPRepository.ts
Normal 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
12
src/modules/sso/index.ts
Normal 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';
|
||||
169
src/modules/sso/infrastructure/http/SSOController.ts
Normal file
169
src/modules/sso/infrastructure/http/SSOController.ts
Normal 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;
|
||||
}
|
||||
107
src/modules/sso/infrastructure/providers/LDAPProvider.ts
Normal file
107
src/modules/sso/infrastructure/providers/LDAPProvider.ts
Normal 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 ?? [],
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
68
src/modules/sso/infrastructure/providers/OIDCProvider.ts
Normal file
68
src/modules/sso/infrastructure/providers/OIDCProvider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
49
src/modules/sso/infrastructure/providers/SAMLProvider.ts
Normal file
49
src/modules/sso/infrastructure/providers/SAMLProvider.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
47
src/modules/sso/infrastructure/providers/TOTPService.ts
Normal file
47
src/modules/sso/infrastructure/providers/TOTPService.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user