From 08011d22d56c1030fcaf9e4cc6e9329c3e3bd82d Mon Sep 17 00:00:00 2001 From: debian Date: Sun, 8 Mar 2026 13:38:25 -0400 Subject: [PATCH] fase(25-26): keyboard shortcuts, mobile responsive, enterprise SSO/audit - Phase 25.4: N shortcut for new exploration on dashboard (react-hotkeys-hook) - Phase 25.5: overflow-x-auto on tables, responsive padding (p-4 md:p-6) - Phase 26: SAML/OIDC/LDAP providers (build-fixed), TOTP/MFA service - Phase 26: KyselySSOConfigRepository + KyselyTOTPRepository - Phase 26: SSO HTTP controller (config CRUD + MFA setup/verify/disable) - Phase 26: Audit module index.ts + SSO module index.ts - Phase 26: Session management endpoints (findByUserId, deleteById, list/revoke) - Phase 26: SSO and audit routes feature-gated (auth:sso, audit:logs) - Phase 26: Frontend SSOSection (SAML/OIDC/LDAP config + TOTP setup) - Phase 26: Frontend SessionsSection (list/revoke active sessions) - Phase 26: Settings navigation updated with SSO & Sessions sections Co-Authored-By: Claude Sonnet 4.6 --- .ralph/.loop_start_sha | 2 +- .ralph/progress.json | 2 +- dist/api/router.js | 6 + dist/db/migrations/007_enterprise_tables.js | 50 ++ dist/main.js | 12 + .../modules/audit/domain/entities/AuditLog.js | 23 + dist/modules/audit/index.js | 9 + .../infrastructure/http/AuditController.js | 39 ++ .../repositories/KyselyAuditRepository.js | 55 ++ .../infrastructure/http/AuthController.js | 22 + .../repositories/KyselySessionRepository.js | 19 + dist/modules/sso/domain/entities/SSOConfig.js | 21 + .../modules/sso/domain/entities/TOTPSecret.js | 19 + .../sso/domain/ports/ISSOConfigRepository.js | 2 + .../sso/domain/ports/ITOTPRepository.js | 2 + dist/modules/sso/index.js | 21 + .../sso/infrastructure/http/SSOController.js | 147 ++++ .../infrastructure/providers/LDAPProvider.js | 81 +++ .../infrastructure/providers/OIDCProvider.js | 45 ++ .../infrastructure/providers/SAMLProvider.js | 35 + .../infrastructure/providers/TOTPService.js | 39 ++ .../repositories/KyselySSOConfigRepository.js | 53 ++ .../repositories/KyselyTOTPRepository.js | 45 ++ frontend/src/App.tsx | 4 + frontend/src/components/layout/AppLayout.tsx | 2 +- frontend/src/pages/Dashboard.tsx | 6 + frontend/src/pages/findings/FindingsList.tsx | 2 +- frontend/src/pages/sessions/SessionList.tsx | 2 +- frontend/src/pages/settings/SSOSection.tsx | 226 ++++++ .../src/pages/settings/SessionsSection.tsx | 100 +++ .../src/pages/settings/SettingsLayout.tsx | 4 +- package-lock.json | 643 +++++++++++++++++- package.json | 9 + src/api/router.ts | 16 + src/api/server.ts | 12 + src/db/migrations/007_enterprise_tables.ts | 51 ++ src/main.ts | 14 + src/modules/audit/domain/entities/AuditLog.ts | 34 + src/modules/audit/index.ts | 5 + .../infrastructure/http/AuditController.ts | 39 ++ .../repositories/KyselyAuditRepository.ts | 66 ++ .../auth/domain/ports/ISessionRepository.ts | 2 + .../infrastructure/http/AuthController.ts | 26 + .../repositories/KyselySessionRepository.ts | 21 + src/modules/sso/domain/entities/SSOConfig.ts | 31 + src/modules/sso/domain/entities/TOTPSecret.ts | 26 + .../sso/domain/ports/ISSOConfigRepository.ts | 7 + .../sso/domain/ports/ITOTPRepository.ts | 7 + src/modules/sso/index.ts | 12 + .../sso/infrastructure/http/SSOController.ts | 169 +++++ .../infrastructure/providers/LDAPProvider.ts | 107 +++ .../infrastructure/providers/OIDCProvider.ts | 68 ++ .../infrastructure/providers/SAMLProvider.ts | 49 ++ .../infrastructure/providers/TOTPService.ts | 47 ++ .../repositories/KyselySSOConfigRepository.ts | 67 ++ .../repositories/KyselyTOTPRepository.ts | 50 ++ .../infrastructure/DatabaseConnection.ts | 33 + tests/modules/auth.test.ts | 6 + 58 files changed, 2689 insertions(+), 23 deletions(-) create mode 100644 dist/db/migrations/007_enterprise_tables.js create mode 100644 dist/modules/audit/domain/entities/AuditLog.js create mode 100644 dist/modules/audit/index.js create mode 100644 dist/modules/audit/infrastructure/http/AuditController.js create mode 100644 dist/modules/audit/infrastructure/repositories/KyselyAuditRepository.js create mode 100644 dist/modules/sso/domain/entities/SSOConfig.js create mode 100644 dist/modules/sso/domain/entities/TOTPSecret.js create mode 100644 dist/modules/sso/domain/ports/ISSOConfigRepository.js create mode 100644 dist/modules/sso/domain/ports/ITOTPRepository.js create mode 100644 dist/modules/sso/index.js create mode 100644 dist/modules/sso/infrastructure/http/SSOController.js create mode 100644 dist/modules/sso/infrastructure/providers/LDAPProvider.js create mode 100644 dist/modules/sso/infrastructure/providers/OIDCProvider.js create mode 100644 dist/modules/sso/infrastructure/providers/SAMLProvider.js create mode 100644 dist/modules/sso/infrastructure/providers/TOTPService.js create mode 100644 dist/modules/sso/infrastructure/repositories/KyselySSOConfigRepository.js create mode 100644 dist/modules/sso/infrastructure/repositories/KyselyTOTPRepository.js create mode 100644 frontend/src/pages/settings/SSOSection.tsx create mode 100644 frontend/src/pages/settings/SessionsSection.tsx create mode 100644 src/db/migrations/007_enterprise_tables.ts create mode 100644 src/modules/audit/domain/entities/AuditLog.ts create mode 100644 src/modules/audit/index.ts create mode 100644 src/modules/audit/infrastructure/http/AuditController.ts create mode 100644 src/modules/audit/infrastructure/repositories/KyselyAuditRepository.ts create mode 100644 src/modules/sso/domain/entities/SSOConfig.ts create mode 100644 src/modules/sso/domain/entities/TOTPSecret.ts create mode 100644 src/modules/sso/domain/ports/ISSOConfigRepository.ts create mode 100644 src/modules/sso/domain/ports/ITOTPRepository.ts create mode 100644 src/modules/sso/index.ts create mode 100644 src/modules/sso/infrastructure/http/SSOController.ts create mode 100644 src/modules/sso/infrastructure/providers/LDAPProvider.ts create mode 100644 src/modules/sso/infrastructure/providers/OIDCProvider.ts create mode 100644 src/modules/sso/infrastructure/providers/SAMLProvider.ts create mode 100644 src/modules/sso/infrastructure/providers/TOTPService.ts create mode 100644 src/modules/sso/infrastructure/repositories/KyselySSOConfigRepository.ts create mode 100644 src/modules/sso/infrastructure/repositories/KyselyTOTPRepository.ts 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:

+
    +
  1. Open your authenticator app (Google Authenticator, Authy, etc.)
  2. +
  3. Scan the QR code or enter the key URL manually into your app
  4. +
  5. Enter the 6-digit code below to verify
  6. +
+

{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 ─────────────────────────────────────────────────