diff --git a/.ralph/.loop_start_sha b/.ralph/.loop_start_sha
index d1bb909..dfdc193 100644
--- a/.ralph/.loop_start_sha
+++ b/.ralph/.loop_start_sha
@@ -1 +1 @@
-49e76c92b17a3510da50ae1deaf8002c5c67d010
+c3911bafe885d664a6870305dff172e1410a95ac
diff --git a/.ralph/progress.json b/.ralph/progress.json
index 2a47513..9f786fd 100644
--- a/.ralph/progress.json
+++ b/.ralph/progress.json
@@ -1 +1 @@
-{"status": "completed", "timestamp": "2026-03-08 05:49:12"}
+{"status": "failed", "timestamp": "2026-03-08 07:22:04"}
diff --git a/dist/api/router.js b/dist/api/router.js
index 8c5c583..f3cd188 100644
--- a/dist/api/router.js
+++ b/dist/api/router.js
@@ -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;
}
diff --git a/dist/db/migrations/007_enterprise_tables.js b/dist/db/migrations/007_enterprise_tables.js
new file mode 100644
index 0000000..b50dcee
--- /dev/null
+++ b/dist/db/migrations/007_enterprise_tables.js
@@ -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();
+}
diff --git a/dist/main.js b/dist/main.js
index fbe0913..64e99c7 100644
--- a/dist/main.js
+++ b/dist/main.js
@@ -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
diff --git a/dist/modules/audit/domain/entities/AuditLog.js b/dist/modules/audit/domain/entities/AuditLog.js
new file mode 100644
index 0000000..29dab59
--- /dev/null
+++ b/dist/modules/audit/domain/entities/AuditLog.js
@@ -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;
diff --git a/dist/modules/audit/index.js b/dist/modules/audit/index.js
new file mode 100644
index 0000000..ec57877
--- /dev/null
+++ b/dist/modules/audit/index.js
@@ -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; } });
diff --git a/dist/modules/audit/infrastructure/http/AuditController.js b/dist/modules/audit/infrastructure/http/AuditController.js
new file mode 100644
index 0000000..910cb67
--- /dev/null
+++ b/dist/modules/audit/infrastructure/http/AuditController.js
@@ -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;
+}
diff --git a/dist/modules/audit/infrastructure/repositories/KyselyAuditRepository.js b/dist/modules/audit/infrastructure/repositories/KyselyAuditRepository.js
new file mode 100644
index 0000000..322071b
--- /dev/null
+++ b/dist/modules/audit/infrastructure/repositories/KyselyAuditRepository.js
@@ -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;
diff --git a/dist/modules/auth/infrastructure/http/AuthController.js b/dist/modules/auth/infrastructure/http/AuthController.js
index 71dd515..ac77161 100644
--- a/dist/modules/auth/infrastructure/http/AuthController.js
+++ b/dist/modules/auth/infrastructure/http/AuthController.js
@@ -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;
}
diff --git a/dist/modules/auth/infrastructure/repositories/KyselySessionRepository.js b/dist/modules/auth/infrastructure/repositories/KyselySessionRepository.js
index c858841..0a57f84 100644
--- a/dist/modules/auth/infrastructure/repositories/KyselySessionRepository.js
+++ b/dist/modules/auth/infrastructure/repositories/KyselySessionRepository.js
@@ -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')
diff --git a/dist/modules/sso/domain/entities/SSOConfig.js b/dist/modules/sso/domain/entities/SSOConfig.js
new file mode 100644
index 0000000..17f3ace
--- /dev/null
+++ b/dist/modules/sso/domain/entities/SSOConfig.js
@@ -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;
diff --git a/dist/modules/sso/domain/entities/TOTPSecret.js b/dist/modules/sso/domain/entities/TOTPSecret.js
new file mode 100644
index 0000000..b8d5681
--- /dev/null
+++ b/dist/modules/sso/domain/entities/TOTPSecret.js
@@ -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;
diff --git a/dist/modules/sso/domain/ports/ISSOConfigRepository.js b/dist/modules/sso/domain/ports/ISSOConfigRepository.js
new file mode 100644
index 0000000..c8ad2e5
--- /dev/null
+++ b/dist/modules/sso/domain/ports/ISSOConfigRepository.js
@@ -0,0 +1,2 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
diff --git a/dist/modules/sso/domain/ports/ITOTPRepository.js b/dist/modules/sso/domain/ports/ITOTPRepository.js
new file mode 100644
index 0000000..c8ad2e5
--- /dev/null
+++ b/dist/modules/sso/domain/ports/ITOTPRepository.js
@@ -0,0 +1,2 @@
+"use strict";
+Object.defineProperty(exports, "__esModule", { value: true });
diff --git a/dist/modules/sso/index.js b/dist/modules/sso/index.js
new file mode 100644
index 0000000..224aeff
--- /dev/null
+++ b/dist/modules/sso/index.js
@@ -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; } });
diff --git a/dist/modules/sso/infrastructure/http/SSOController.js b/dist/modules/sso/infrastructure/http/SSOController.js
new file mode 100644
index 0000000..45d5ff8
--- /dev/null
+++ b/dist/modules/sso/infrastructure/http/SSOController.js
@@ -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;
+}
diff --git a/dist/modules/sso/infrastructure/providers/LDAPProvider.js b/dist/modules/sso/infrastructure/providers/LDAPProvider.js
new file mode 100644
index 0000000..5971c1d
--- /dev/null
+++ b/dist/modules/sso/infrastructure/providers/LDAPProvider.js
@@ -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;
diff --git a/dist/modules/sso/infrastructure/providers/OIDCProvider.js b/dist/modules/sso/infrastructure/providers/OIDCProvider.js
new file mode 100644
index 0000000..13af499
--- /dev/null
+++ b/dist/modules/sso/infrastructure/providers/OIDCProvider.js
@@ -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;
diff --git a/dist/modules/sso/infrastructure/providers/SAMLProvider.js b/dist/modules/sso/infrastructure/providers/SAMLProvider.js
new file mode 100644
index 0000000..7715258
--- /dev/null
+++ b/dist/modules/sso/infrastructure/providers/SAMLProvider.js
@@ -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;
diff --git a/dist/modules/sso/infrastructure/providers/TOTPService.js b/dist/modules/sso/infrastructure/providers/TOTPService.js
new file mode 100644
index 0000000..926a462
--- /dev/null
+++ b/dist/modules/sso/infrastructure/providers/TOTPService.js
@@ -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;
diff --git a/dist/modules/sso/infrastructure/repositories/KyselySSOConfigRepository.js b/dist/modules/sso/infrastructure/repositories/KyselySSOConfigRepository.js
new file mode 100644
index 0000000..09c2db7
--- /dev/null
+++ b/dist/modules/sso/infrastructure/repositories/KyselySSOConfigRepository.js
@@ -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;
diff --git a/dist/modules/sso/infrastructure/repositories/KyselyTOTPRepository.js b/dist/modules/sso/infrastructure/repositories/KyselyTOTPRepository.js
new file mode 100644
index 0000000..e0c7603
--- /dev/null
+++ b/dist/modules/sso/infrastructure/repositories/KyselyTOTPRepository.js
@@ -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;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 1312b86..ebe5efc 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -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() {
} />
} />
} />
+ } />
+ } />
} />
} />
} />
diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx
index fbcdd6e..5d2014f 100644
--- a/frontend/src/components/layout/AppLayout.tsx
+++ b/frontend/src/components/layout/AppLayout.tsx
@@ -11,7 +11,7 @@ export function AppLayout() {
-
+
diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx
index bdca851..d260b03 100644
--- a/frontend/src/pages/Dashboard.tsx
+++ b/frontend/src/pages/Dashboard.tsx
@@ -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()
diff --git a/frontend/src/pages/findings/FindingsList.tsx b/frontend/src/pages/findings/FindingsList.tsx
index 6957bb5..840122c 100644
--- a/frontend/src/pages/findings/FindingsList.tsx
+++ b/frontend/src/pages/findings/FindingsList.tsx
@@ -140,7 +140,7 @@ export function FindingsList() {
{[1, 2, 3, 4, 5].map(i => )}
) : (
-
+
{table.getHeaderGroups().map(hg => (
diff --git a/frontend/src/pages/sessions/SessionList.tsx b/frontend/src/pages/sessions/SessionList.tsx
index b7d67cc..5c7246b 100644
--- a/frontend/src/pages/sessions/SessionList.tsx
+++ b/frontend/src/pages/sessions/SessionList.tsx
@@ -150,7 +150,7 @@ export function SessionList() {
{[1, 2, 3].map(i => )}
) : (
-
+
{table.getHeaderGroups().map(hg => (
diff --git a/frontend/src/pages/settings/SSOSection.tsx b/frontend/src/pages/settings/SSOSection.tsx
new file mode 100644
index 0000000..79ee004
--- /dev/null
+++ b/frontend/src/pages/settings/SSOSection.tsx
@@ -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
+ createdAt: string
+}
+interface MFAStatus {
+ enabled: boolean
+ verified: boolean
+}
+type SSOProvider = 'saml' | 'oidc' | 'ldap'
+export function SSOSection() {
+ const queryClient = useQueryClient()
+
+ const { data: ssoConfig } = useQuery({
+ queryKey: ['sso-config'],
+ queryFn: () => apiFetch('/api/sso/config'),
+ })
+ const { data: mfaStatus } = useQuery({
+ queryKey: ['mfa-status'],
+ queryFn: () => apiFetch('/api/sso/mfa/status'),
+ })
+ const [provider, setProvider] = useState('saml')
+ const [configFields, setConfigFields] = useState>({})
+ const [mfaToken, setMfaToken] = useState('')
+ const [showMFASetup, setShowMFASetup] = useState(false)
+ const [otpauthUrl, setOtpauthUrl] = useState('')
+ const saveSSOConfig = useMutation({
+ mutationFn: (data: { provider: SSOProvider; enabled: boolean; config: Record }) =>
+ 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 (
+
+
+
SSO & Security
+
+ Configure single sign-on and multi-factor authentication. Requires enterprise license.
+
+
+
+
+
+
+ Single Sign-On
+ Configure your identity provider.
+
+ {ssoConfig && (
+
+ {ssoConfig.enabled ? 'Enabled' : 'Disabled'}
+
+ )}
+
+
+
+
+
+
+
+ {getProviderFields().map((field) => (
+
+
+ setConfigFields((prev) => ({ ...prev, [field.key]: e.target.value }))}
+ />
+
+ ))}
+
+
+
+
+
+
+
+
+ Multi-Factor Authentication
+ Add TOTP-based MFA to your account.
+
+ {mfaStatus && (
+
+ {mfaStatus.verified ? 'Active' : mfaStatus.enabled ? 'Pending Verification' : 'Not Enabled'}
+
+ )}
+
+
+
+ {!mfaStatus?.enabled && !showMFASetup && (
+
+ )}
+ {showMFASetup && (
+
+
+
Setup Instructions:
+
+ - Open your authenticator app (Google Authenticator, Authy, etc.)
+ - Scan the QR code or enter the key URL manually into your app
+ - Enter the 6-digit code below to verify
+
+
{otpauthUrl}
+
+
+ setMfaToken(e.target.value)}
+ maxLength={6}
+ className="max-w-xs"
+ />
+
+
+
+ )}
+ {mfaStatus?.verified && (
+
+
+
+ MFA is active on this account
+
+
+
+ )}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/SessionsSection.tsx b/frontend/src/pages/settings/SessionsSection.tsx
new file mode 100644
index 0000000..a5f2efc
--- /dev/null
+++ b/frontend/src/pages/settings/SessionsSection.tsx
@@ -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({
+ queryKey: ['auth-sessions'],
+ queryFn: () => apiFetch('/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 (
+
+
+
Active Sessions
+
+ View and revoke active login sessions for your account.
+
+
+
+
+
+ Sessions
+
+ {sessions.length} active session{sessions.length !== 1 ? 's' : ''}
+
+
+
+ {isLoading && (
+
+ {[1, 2, 3].map((i) => )}
+
+ )}
+
+ {!isLoading && sessions.length === 0 && (
+ No active sessions found.
+ )}
+
+ {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 (
+
+
+
+
+
+
+
+ Session {session.id.slice(0, 8)}
+ {isExpiringSoon && (
+ Expiring soon
+ )}
+
+
+ Created {createdAt.toLocaleString()} · Expires {expiresAt.toLocaleString()}
+
+
+
+
+
+ )
+ })}
+
+
+
+ )
+}
diff --git a/frontend/src/pages/settings/SettingsLayout.tsx b/frontend/src/pages/settings/SettingsLayout.tsx
index 5c90b49..9fa7f37 100644
--- a/frontend/src/pages/settings/SettingsLayout.tsx
+++ b/frontend/src/pages/settings/SettingsLayout.tsx
@@ -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 },
diff --git a/package-lock.json b/package-lock.json
index 9eaf86b..3675a9e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/package.json b/package.json
index 00e0b6e..94b0f96 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/api/router.ts b/src/api/router.ts
index 1936e53..beb6441 100644
--- a/src/api/router.ts
+++ b/src/api/router.ts
@@ -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;
}
diff --git a/src/api/server.ts b/src/api/server.ts
index d298262..d4fbfe3 100644
--- a/src/api/server.ts
+++ b/src/api/server.ts
@@ -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 {
diff --git a/src/db/migrations/007_enterprise_tables.ts b/src/db/migrations/007_enterprise_tables.ts
new file mode 100644
index 0000000..d1bb543
--- /dev/null
+++ b/src/db/migrations/007_enterprise_tables.ts
@@ -0,0 +1,51 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ // 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): Promise {
+ await db.schema.dropTable('audit_logs').ifExists().execute();
+ await db.schema.dropTable('totp_secrets').ifExists().execute();
+ await db.schema.dropTable('sso_configs').ifExists().execute();
+}
diff --git a/src/main.ts b/src/main.ts
index ef85441..914b0ba 100644
--- a/src/main.ts
+++ b/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 {
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 {
apiKeyRepository: apiKeyRepo,
userRepository: userRepo,
},
+ ssoDeps: { ssoConfigRepository: ssoConfigRepo, totpRepository: totpRepo, totpService },
+ auditRepository: auditRepo,
});
const httpServer = http.createServer(app);
diff --git a/src/modules/audit/domain/entities/AuditLog.ts b/src/modules/audit/domain/entities/AuditLog.ts
new file mode 100644
index 0000000..b7fc466
--- /dev/null
+++ b/src/modules/audit/domain/entities/AuditLog.ts
@@ -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;
+ occurredAt: Date;
+}
+
+export class AuditLog extends Entity {
+ 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 { return this.props.details; }
+ get occurredAt(): Date { return this.props.occurredAt; }
+}
diff --git a/src/modules/audit/index.ts b/src/modules/audit/index.ts
new file mode 100644
index 0000000..6992f1a
--- /dev/null
+++ b/src/modules/audit/index.ts
@@ -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';
diff --git a/src/modules/audit/infrastructure/http/AuditController.ts b/src/modules/audit/infrastructure/http/AuditController.ts
new file mode 100644
index 0000000..8ffe27c
--- /dev/null
+++ b/src/modules/audit/infrastructure/http/AuditController.ts
@@ -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;
+}
diff --git a/src/modules/audit/infrastructure/repositories/KyselyAuditRepository.ts b/src/modules/audit/infrastructure/repositories/KyselyAuditRepository.ts
new file mode 100644
index 0000000..cc768fe
--- /dev/null
+++ b/src/modules/audit/infrastructure/repositories/KyselyAuditRepository.ts
@@ -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) {}
+
+ async save(log: AuditLog): Promise {
+ 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 {
+ 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,
+ occurredAt: new Date(row.occurred_at),
+ },
+ UniqueId.from(row.id)
+ )
+ );
+ }
+}
diff --git a/src/modules/auth/domain/ports/ISessionRepository.ts b/src/modules/auth/domain/ports/ISessionRepository.ts
index 62877e2..4080a2c 100644
--- a/src/modules/auth/domain/ports/ISessionRepository.ts
+++ b/src/modules/auth/domain/ports/ISessionRepository.ts
@@ -9,6 +9,8 @@ export interface AuthSession {
export interface ISessionRepository {
save(session: AuthSession): Promise;
findByToken(token: string): Promise;
+ findByUserId(userId: string): Promise;
deleteByToken(token: string): Promise;
+ deleteById(id: string): Promise;
deleteExpired(): Promise;
}
diff --git a/src/modules/auth/infrastructure/http/AuthController.ts b/src/modules/auth/infrastructure/http/AuthController.ts
index 5741208..6f6da3a 100644
--- a/src/modules/auth/infrastructure/http/AuthController.ts
+++ b/src/modules/auth/infrastructure/http/AuthController.ts
@@ -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;
}
diff --git a/src/modules/auth/infrastructure/repositories/KyselySessionRepository.ts b/src/modules/auth/infrastructure/repositories/KyselySessionRepository.ts
index 934c539..9b9607e 100644
--- a/src/modules/auth/infrastructure/repositories/KyselySessionRepository.ts
+++ b/src/modules/auth/infrastructure/repositories/KyselySessionRepository.ts
@@ -34,10 +34,31 @@ export class KyselySessionRepository implements ISessionRepository {
};
}
+ async findByUserId(userId: string): Promise {
+ 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 {
await this.db.deleteFrom('auth_sessions').where('token', '=', token).execute();
}
+ async deleteById(id: string): Promise {
+ await this.db.deleteFrom('auth_sessions').where('id', '=', id).execute();
+ }
+
async deleteExpired(): Promise {
await this.db
.deleteFrom('auth_sessions')
diff --git a/src/modules/sso/domain/entities/SSOConfig.ts b/src/modules/sso/domain/entities/SSOConfig.ts
new file mode 100644
index 0000000..ae182e8
--- /dev/null
+++ b/src/modules/sso/domain/entities/SSOConfig.ts
@@ -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;
+ createdAt: Date;
+}
+
+export class SSOConfig extends Entity {
+ 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 { return this.props.config; }
+ get createdAt(): Date { return this.props.createdAt; }
+
+ enable(): void { this.props.enabled = true; }
+ disable(): void { this.props.enabled = false; }
+}
diff --git a/src/modules/sso/domain/entities/TOTPSecret.ts b/src/modules/sso/domain/entities/TOTPSecret.ts
new file mode 100644
index 0000000..98b17eb
--- /dev/null
+++ b/src/modules/sso/domain/entities/TOTPSecret.ts
@@ -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 {
+ 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; }
+}
diff --git a/src/modules/sso/domain/ports/ISSOConfigRepository.ts b/src/modules/sso/domain/ports/ISSOConfigRepository.ts
new file mode 100644
index 0000000..76dff9e
--- /dev/null
+++ b/src/modules/sso/domain/ports/ISSOConfigRepository.ts
@@ -0,0 +1,7 @@
+import { SSOConfig } from '../entities/SSOConfig';
+
+export interface ISSOConfigRepository {
+ save(config: SSOConfig): Promise;
+ findByOrganizationId(organizationId: string): Promise;
+ findById(id: string): Promise;
+}
diff --git a/src/modules/sso/domain/ports/ITOTPRepository.ts b/src/modules/sso/domain/ports/ITOTPRepository.ts
new file mode 100644
index 0000000..b94360e
--- /dev/null
+++ b/src/modules/sso/domain/ports/ITOTPRepository.ts
@@ -0,0 +1,7 @@
+import { TOTPSecret } from '../entities/TOTPSecret';
+
+export interface ITOTPRepository {
+ save(secret: TOTPSecret): Promise;
+ findByUserId(userId: string): Promise;
+ delete(userId: string): Promise;
+}
diff --git a/src/modules/sso/index.ts b/src/modules/sso/index.ts
new file mode 100644
index 0000000..dbe6cb1
--- /dev/null
+++ b/src/modules/sso/index.ts
@@ -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';
diff --git a/src/modules/sso/infrastructure/http/SSOController.ts b/src/modules/sso/infrastructure/http/SSOController.ts
new file mode 100644
index 0000000..3c0d3c7
--- /dev/null
+++ b/src/modules/sso/infrastructure/http/SSOController.ts
@@ -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;
+ };
+
+ 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;
+}
diff --git a/src/modules/sso/infrastructure/providers/LDAPProvider.ts b/src/modules/sso/infrastructure/providers/LDAPProvider.ts
new file mode 100644
index 0000000..7788590
--- /dev/null
+++ b/src/modules/sso/infrastructure/providers/LDAPProvider.ts
@@ -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 {
+ 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 ?? [],
+ });
+ });
+ });
+ }
+ );
+ });
+ });
+ }
+}
diff --git a/src/modules/sso/infrastructure/providers/OIDCProvider.ts b/src/modules/sso/infrastructure/providers/OIDCProvider.ts
new file mode 100644
index 0000000..d4e17de
--- /dev/null
+++ b/src/modules/sso/infrastructure/providers/OIDCProvider.ts
@@ -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 {
+ 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,
+ };
+ }
+}
diff --git a/src/modules/sso/infrastructure/providers/SAMLProvider.ts b/src/modules/sso/infrastructure/providers/SAMLProvider.ts
new file mode 100644
index 0000000..976c5cc
--- /dev/null
+++ b/src/modules/sso/infrastructure/providers/SAMLProvider.ts
@@ -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 {
+ return this.saml.getAuthorizeUrlAsync(relayState ?? '', undefined, {});
+ }
+
+ async validateResponse(body: Record): Promise {
+ 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,
+ };
+ }
+}
diff --git a/src/modules/sso/infrastructure/providers/TOTPService.ts b/src/modules/sso/infrastructure/providers/TOTPService.ts
new file mode 100644
index 0000000..6990fce
--- /dev/null
+++ b/src/modules/sso/infrastructure/providers/TOTPService.ts
@@ -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;
+ }
+}
diff --git a/src/modules/sso/infrastructure/repositories/KyselySSOConfigRepository.ts b/src/modules/sso/infrastructure/repositories/KyselySSOConfigRepository.ts
new file mode 100644
index 0000000..e0fbb53
--- /dev/null
+++ b/src/modules/sso/infrastructure/repositories/KyselySSOConfigRepository.ts
@@ -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) {}
+
+ async save(config: SSOConfig): Promise {
+ 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 {
+ 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 {
+ 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,
+ createdAt: new Date(row.created_at),
+ },
+ UniqueId.from(row.id)
+ );
+ }
+}
diff --git a/src/modules/sso/infrastructure/repositories/KyselyTOTPRepository.ts b/src/modules/sso/infrastructure/repositories/KyselyTOTPRepository.ts
new file mode 100644
index 0000000..8f523f9
--- /dev/null
+++ b/src/modules/sso/infrastructure/repositories/KyselyTOTPRepository.ts
@@ -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) {}
+
+ async save(secret: TOTPSecret): Promise {
+ 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 {
+ 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 {
+ await this.db.deleteFrom('totp_secrets').where('user_id', '=', userId).execute();
+ }
+}
diff --git a/src/shared/infrastructure/DatabaseConnection.ts b/src/shared/infrastructure/DatabaseConnection.ts
index e40c4e4..298af60 100644
--- a/src/shared/infrastructure/DatabaseConnection.ts
+++ b/src/shared/infrastructure/DatabaseConnection.ts
@@ -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 {
diff --git a/tests/modules/auth.test.ts b/tests/modules/auth.test.ts
index e748828..abce0f0 100644
--- a/tests/modules/auth.test.ts
+++ b/tests/modules/auth.test.ts
@@ -61,6 +61,12 @@ class InMemorySessionRepository implements ISessionRepository {
if (session.expiresAt < now) this.store.delete(token);
}
}
+ async findByUserId(userId: string): Promise {
+ return Array.from(this.store.values()).filter(s => s.userId === userId);
+ }
+ async deleteById(id: string): Promise {
+ for (const [token, s] of this.store) { if (s.id === id) { this.store.delete(token); break; } }
+ }
}
// ─── Tests: Email Value Object ─────────────────────────────────────────────────