@synth-deploy/server 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/debrief-retention.d.ts +12 -0
- package/dist/agent/debrief-retention.d.ts.map +1 -0
- package/dist/agent/debrief-retention.js +27 -0
- package/dist/agent/debrief-retention.js.map +1 -0
- package/dist/agent/envoy-client.d.ts +216 -0
- package/dist/agent/envoy-client.d.ts.map +1 -0
- package/dist/agent/envoy-client.js +266 -0
- package/dist/agent/envoy-client.js.map +1 -0
- package/dist/agent/envoy-registry.d.ts +102 -0
- package/dist/agent/envoy-registry.d.ts.map +1 -0
- package/dist/agent/envoy-registry.js +319 -0
- package/dist/agent/envoy-registry.js.map +1 -0
- package/dist/agent/health-checker.d.ts +39 -0
- package/dist/agent/health-checker.d.ts.map +1 -0
- package/dist/agent/health-checker.js +49 -0
- package/dist/agent/health-checker.js.map +1 -0
- package/dist/agent/mcp-client-manager.d.ts +36 -0
- package/dist/agent/mcp-client-manager.d.ts.map +1 -0
- package/dist/agent/mcp-client-manager.js +106 -0
- package/dist/agent/mcp-client-manager.js.map +1 -0
- package/dist/agent/stale-deployment-detector.d.ts +15 -0
- package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
- package/dist/agent/stale-deployment-detector.js +50 -0
- package/dist/agent/stale-deployment-detector.js.map +1 -0
- package/dist/agent/step-runner.d.ts +31 -0
- package/dist/agent/step-runner.d.ts.map +1 -0
- package/dist/agent/step-runner.js +80 -0
- package/dist/agent/step-runner.js.map +1 -0
- package/dist/agent/synth-agent.d.ts +168 -0
- package/dist/agent/synth-agent.d.ts.map +1 -0
- package/dist/agent/synth-agent.js +1195 -0
- package/dist/agent/synth-agent.js.map +1 -0
- package/dist/api/agent.d.ts +36 -0
- package/dist/api/agent.d.ts.map +1 -0
- package/dist/api/agent.js +867 -0
- package/dist/api/agent.js.map +1 -0
- package/dist/api/api-keys.d.ts +4 -0
- package/dist/api/api-keys.d.ts.map +1 -0
- package/dist/api/api-keys.js +118 -0
- package/dist/api/api-keys.js.map +1 -0
- package/dist/api/artifacts.d.ts +5 -0
- package/dist/api/artifacts.d.ts.map +1 -0
- package/dist/api/artifacts.js +142 -0
- package/dist/api/artifacts.js.map +1 -0
- package/dist/api/auth.d.ts +4 -0
- package/dist/api/auth.d.ts.map +1 -0
- package/dist/api/auth.js +280 -0
- package/dist/api/auth.js.map +1 -0
- package/dist/api/deployments.d.ts +11 -0
- package/dist/api/deployments.d.ts.map +1 -0
- package/dist/api/deployments.js +1098 -0
- package/dist/api/deployments.js.map +1 -0
- package/dist/api/environments.d.ts +5 -0
- package/dist/api/environments.d.ts.map +1 -0
- package/dist/api/environments.js +69 -0
- package/dist/api/environments.js.map +1 -0
- package/dist/api/envoy-reports.d.ts +17 -0
- package/dist/api/envoy-reports.d.ts.map +1 -0
- package/dist/api/envoy-reports.js +138 -0
- package/dist/api/envoy-reports.js.map +1 -0
- package/dist/api/envoys.d.ts +5 -0
- package/dist/api/envoys.d.ts.map +1 -0
- package/dist/api/envoys.js +192 -0
- package/dist/api/envoys.js.map +1 -0
- package/dist/api/fleet.d.ts +11 -0
- package/dist/api/fleet.d.ts.map +1 -0
- package/dist/api/fleet.js +394 -0
- package/dist/api/fleet.js.map +1 -0
- package/dist/api/graph.d.ts +8 -0
- package/dist/api/graph.d.ts.map +1 -0
- package/dist/api/graph.js +355 -0
- package/dist/api/graph.js.map +1 -0
- package/dist/api/health.d.ts +20 -0
- package/dist/api/health.d.ts.map +1 -0
- package/dist/api/health.js +248 -0
- package/dist/api/health.js.map +1 -0
- package/dist/api/idp-schemas.d.ts +41 -0
- package/dist/api/idp-schemas.d.ts.map +1 -0
- package/dist/api/idp-schemas.js +17 -0
- package/dist/api/idp-schemas.js.map +1 -0
- package/dist/api/idp.d.ts +6 -0
- package/dist/api/idp.d.ts.map +1 -0
- package/dist/api/idp.js +620 -0
- package/dist/api/idp.js.map +1 -0
- package/dist/api/intake.d.ts +10 -0
- package/dist/api/intake.d.ts.map +1 -0
- package/dist/api/intake.js +418 -0
- package/dist/api/intake.js.map +1 -0
- package/dist/api/partitions.d.ts +5 -0
- package/dist/api/partitions.d.ts.map +1 -0
- package/dist/api/partitions.js +113 -0
- package/dist/api/partitions.js.map +1 -0
- package/dist/api/progress-event-store.d.ts +62 -0
- package/dist/api/progress-event-store.d.ts.map +1 -0
- package/dist/api/progress-event-store.js +118 -0
- package/dist/api/progress-event-store.js.map +1 -0
- package/dist/api/schemas.d.ts +1000 -0
- package/dist/api/schemas.d.ts.map +1 -0
- package/dist/api/schemas.js +328 -0
- package/dist/api/schemas.js.map +1 -0
- package/dist/api/security-boundaries.d.ts +4 -0
- package/dist/api/security-boundaries.d.ts.map +1 -0
- package/dist/api/security-boundaries.js +32 -0
- package/dist/api/security-boundaries.js.map +1 -0
- package/dist/api/settings.d.ts +4 -0
- package/dist/api/settings.d.ts.map +1 -0
- package/dist/api/settings.js +99 -0
- package/dist/api/settings.js.map +1 -0
- package/dist/api/system.d.ts +75 -0
- package/dist/api/system.d.ts.map +1 -0
- package/dist/api/system.js +558 -0
- package/dist/api/system.js.map +1 -0
- package/dist/api/telemetry.d.ts +4 -0
- package/dist/api/telemetry.d.ts.map +1 -0
- package/dist/api/telemetry.js +24 -0
- package/dist/api/telemetry.js.map +1 -0
- package/dist/api/users.d.ts +4 -0
- package/dist/api/users.d.ts.map +1 -0
- package/dist/api/users.js +173 -0
- package/dist/api/users.js.map +1 -0
- package/dist/archive-unpacker.d.ts +24 -0
- package/dist/archive-unpacker.d.ts.map +1 -0
- package/dist/archive-unpacker.js +239 -0
- package/dist/archive-unpacker.js.map +1 -0
- package/dist/artifact-analyzer.d.ts +59 -0
- package/dist/artifact-analyzer.d.ts.map +1 -0
- package/dist/artifact-analyzer.js +334 -0
- package/dist/artifact-analyzer.js.map +1 -0
- package/dist/auth/idp/index.d.ts +9 -0
- package/dist/auth/idp/index.d.ts.map +1 -0
- package/dist/auth/idp/index.js +5 -0
- package/dist/auth/idp/index.js.map +1 -0
- package/dist/auth/idp/ldap.d.ts +56 -0
- package/dist/auth/idp/ldap.d.ts.map +1 -0
- package/dist/auth/idp/ldap.js +276 -0
- package/dist/auth/idp/ldap.js.map +1 -0
- package/dist/auth/idp/oidc.d.ts +27 -0
- package/dist/auth/idp/oidc.d.ts.map +1 -0
- package/dist/auth/idp/oidc.js +97 -0
- package/dist/auth/idp/oidc.js.map +1 -0
- package/dist/auth/idp/role-mapping.d.ts +9 -0
- package/dist/auth/idp/role-mapping.d.ts.map +1 -0
- package/dist/auth/idp/role-mapping.js +16 -0
- package/dist/auth/idp/role-mapping.js.map +1 -0
- package/dist/auth/idp/saml.d.ts +40 -0
- package/dist/auth/idp/saml.d.ts.map +1 -0
- package/dist/auth/idp/saml.js +117 -0
- package/dist/auth/idp/saml.js.map +1 -0
- package/dist/auth/idp/types.d.ts +23 -0
- package/dist/auth/idp/types.d.ts.map +1 -0
- package/dist/auth/idp/types.js +2 -0
- package/dist/auth/idp/types.js.map +1 -0
- package/dist/fleet/fleet-executor.d.ts +35 -0
- package/dist/fleet/fleet-executor.d.ts.map +1 -0
- package/dist/fleet/fleet-executor.js +228 -0
- package/dist/fleet/fleet-executor.js.map +1 -0
- package/dist/fleet/fleet-store.d.ts +13 -0
- package/dist/fleet/fleet-store.d.ts.map +1 -0
- package/dist/fleet/fleet-store.js +13 -0
- package/dist/fleet/fleet-store.js.map +1 -0
- package/dist/fleet/index.d.ts +5 -0
- package/dist/fleet/index.d.ts.map +1 -0
- package/dist/fleet/index.js +4 -0
- package/dist/fleet/index.js.map +1 -0
- package/dist/fleet/representative-selector.d.ts +15 -0
- package/dist/fleet/representative-selector.d.ts.map +1 -0
- package/dist/fleet/representative-selector.js +71 -0
- package/dist/fleet/representative-selector.js.map +1 -0
- package/dist/graph/graph-executor.d.ts +36 -0
- package/dist/graph/graph-executor.d.ts.map +1 -0
- package/dist/graph/graph-executor.js +348 -0
- package/dist/graph/graph-executor.js.map +1 -0
- package/dist/graph/graph-inference.d.ts +22 -0
- package/dist/graph/graph-inference.d.ts.map +1 -0
- package/dist/graph/graph-inference.js +149 -0
- package/dist/graph/graph-inference.js.map +1 -0
- package/dist/graph/graph-store.d.ts +12 -0
- package/dist/graph/graph-store.d.ts.map +1 -0
- package/dist/graph/graph-store.js +61 -0
- package/dist/graph/graph-store.js.map +1 -0
- package/dist/graph/index.d.ts +5 -0
- package/dist/graph/index.d.ts.map +1 -0
- package/dist/graph/index.js +4 -0
- package/dist/graph/index.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +837 -0
- package/dist/index.js.map +1 -0
- package/dist/intake/index.d.ts +6 -0
- package/dist/intake/index.d.ts.map +1 -0
- package/dist/intake/index.js +5 -0
- package/dist/intake/index.js.map +1 -0
- package/dist/intake/intake-processor.d.ts +17 -0
- package/dist/intake/intake-processor.d.ts.map +1 -0
- package/dist/intake/intake-processor.js +99 -0
- package/dist/intake/intake-processor.js.map +1 -0
- package/dist/intake/intake-store.d.ts +7 -0
- package/dist/intake/intake-store.d.ts.map +1 -0
- package/dist/intake/intake-store.js +7 -0
- package/dist/intake/intake-store.js.map +1 -0
- package/dist/intake/registry-poller.d.ts +41 -0
- package/dist/intake/registry-poller.d.ts.map +1 -0
- package/dist/intake/registry-poller.js +202 -0
- package/dist/intake/registry-poller.js.map +1 -0
- package/dist/intake/webhook-handlers.d.ts +37 -0
- package/dist/intake/webhook-handlers.d.ts.map +1 -0
- package/dist/intake/webhook-handlers.js +268 -0
- package/dist/intake/webhook-handlers.js.map +1 -0
- package/dist/logger.d.ts +5 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +15 -0
- package/dist/logger.js.map +1 -0
- package/dist/mcp/resources.d.ts +9 -0
- package/dist/mcp/resources.d.ts.map +1 -0
- package/dist/mcp/resources.js +72 -0
- package/dist/mcp/resources.js.map +1 -0
- package/dist/mcp/server.d.ts +15 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +20 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/mcp/tools.d.ts +9 -0
- package/dist/mcp/tools.d.ts.map +1 -0
- package/dist/mcp/tools.js +88 -0
- package/dist/mcp/tools.js.map +1 -0
- package/dist/middleware/auth.d.ts +29 -0
- package/dist/middleware/auth.d.ts.map +1 -0
- package/dist/middleware/auth.js +76 -0
- package/dist/middleware/auth.js.map +1 -0
- package/dist/middleware/permissions.d.ts +13 -0
- package/dist/middleware/permissions.d.ts.map +1 -0
- package/dist/middleware/permissions.js +32 -0
- package/dist/middleware/permissions.js.map +1 -0
- package/dist/pattern-store.d.ts +104 -0
- package/dist/pattern-store.d.ts.map +1 -0
- package/dist/pattern-store.js +299 -0
- package/dist/pattern-store.js.map +1 -0
- package/package.json +54 -0
- package/src/agent/debrief-retention.ts +44 -0
- package/src/agent/envoy-client.ts +474 -0
- package/src/agent/envoy-registry.ts +384 -0
- package/src/agent/health-checker.ts +70 -0
- package/src/agent/mcp-client-manager.ts +131 -0
- package/src/agent/stale-deployment-detector.ts +79 -0
- package/src/agent/step-runner.ts +124 -0
- package/src/agent/synth-agent.ts +1567 -0
- package/src/api/agent.ts +1075 -0
- package/src/api/api-keys.ts +129 -0
- package/src/api/artifacts.ts +194 -0
- package/src/api/auth.ts +320 -0
- package/src/api/deployments.ts +1347 -0
- package/src/api/environments.ts +97 -0
- package/src/api/envoy-reports.ts +159 -0
- package/src/api/envoys.ts +237 -0
- package/src/api/fleet.ts +510 -0
- package/src/api/graph.ts +516 -0
- package/src/api/health.ts +311 -0
- package/src/api/idp-schemas.ts +19 -0
- package/src/api/idp.ts +735 -0
- package/src/api/intake.ts +537 -0
- package/src/api/partitions.ts +147 -0
- package/src/api/progress-event-store.ts +153 -0
- package/src/api/schemas.ts +376 -0
- package/src/api/security-boundaries.ts +54 -0
- package/src/api/settings.ts +118 -0
- package/src/api/system.ts +704 -0
- package/src/api/telemetry.ts +32 -0
- package/src/api/users.ts +210 -0
- package/src/archive-unpacker.ts +271 -0
- package/src/artifact-analyzer.ts +438 -0
- package/src/auth/idp/index.ts +8 -0
- package/src/auth/idp/ldap.ts +340 -0
- package/src/auth/idp/oidc.ts +117 -0
- package/src/auth/idp/role-mapping.ts +22 -0
- package/src/auth/idp/saml.ts +148 -0
- package/src/auth/idp/types.ts +22 -0
- package/src/fleet/fleet-executor.ts +309 -0
- package/src/fleet/fleet-store.ts +13 -0
- package/src/fleet/index.ts +4 -0
- package/src/fleet/representative-selector.ts +83 -0
- package/src/graph/graph-executor.ts +446 -0
- package/src/graph/graph-inference.ts +184 -0
- package/src/graph/graph-store.ts +75 -0
- package/src/graph/index.ts +4 -0
- package/src/index.ts +916 -0
- package/src/intake/index.ts +5 -0
- package/src/intake/intake-processor.ts +111 -0
- package/src/intake/intake-store.ts +7 -0
- package/src/intake/registry-poller.ts +230 -0
- package/src/intake/webhook-handlers.ts +328 -0
- package/src/logger.ts +19 -0
- package/src/mcp/resources.ts +98 -0
- package/src/mcp/server.ts +34 -0
- package/src/mcp/tools.ts +117 -0
- package/src/middleware/auth.ts +103 -0
- package/src/middleware/permissions.ts +35 -0
- package/src/pattern-store.ts +409 -0
- package/tests/agent-mode.test.ts +536 -0
- package/tests/api-handlers.test.ts +1245 -0
- package/tests/archive-unpacker.test.ts +179 -0
- package/tests/artifact-analyzer.test.ts +240 -0
- package/tests/auth-middleware.test.ts +189 -0
- package/tests/decision-diary.test.ts +957 -0
- package/tests/diary-reader.test.ts +782 -0
- package/tests/envoy-client.test.ts +342 -0
- package/tests/envoy-reports.test.ts +156 -0
- package/tests/mcp-tools.test.ts +213 -0
- package/tests/orchestration.test.ts +536 -0
- package/tests/partition-deletion.test.ts +143 -0
- package/tests/partition-isolation.test.ts +830 -0
- package/tests/pattern-store.test.ts +371 -0
- package/tests/rbac-enforcement.test.ts +409 -0
- package/tests/ssrf-validation.test.ts +56 -0
- package/tests/stale-deployment.test.ts +85 -0
- package/tests/step-runner.test.ts +308 -0
- package/tests/ui-journey.test.ts +330 -0
- package/tsconfig.json +11 -0
- package/vitest.config.ts +27 -0
package/src/api/idp.ts
ADDED
|
@@ -0,0 +1,735 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import type { FastifyInstance } from "fastify";
|
|
3
|
+
import bcrypt from "bcryptjs";
|
|
4
|
+
import type {
|
|
5
|
+
IUserStore,
|
|
6
|
+
IRoleStore,
|
|
7
|
+
IUserRoleStore,
|
|
8
|
+
ISessionStore,
|
|
9
|
+
IIdpProviderStore,
|
|
10
|
+
IRoleMappingStore,
|
|
11
|
+
IdpProvider,
|
|
12
|
+
OidcConfig,
|
|
13
|
+
UserId,
|
|
14
|
+
RoleId,
|
|
15
|
+
} from "@synth-deploy/core";
|
|
16
|
+
import { requirePermission, requireEdition } from "../middleware/permissions.js";
|
|
17
|
+
import { generateTokens } from "../middleware/auth.js";
|
|
18
|
+
import { OidcAdapter } from "../auth/idp/oidc.js";
|
|
19
|
+
import { SamlAdapter } from "../auth/idp/saml.js";
|
|
20
|
+
import type { SamlConfig } from "../auth/idp/saml.js";
|
|
21
|
+
import { LdapAdapter } from "../auth/idp/ldap.js";
|
|
22
|
+
import type { LdapConfig } from "../auth/idp/ldap.js";
|
|
23
|
+
import { applyRoleMappings } from "../auth/idp/role-mapping.js";
|
|
24
|
+
import {
|
|
25
|
+
CreateIdpProviderSchema,
|
|
26
|
+
UpdateIdpProviderSchema,
|
|
27
|
+
CreateRoleMappingSchema,
|
|
28
|
+
} from "./idp-schemas.js";
|
|
29
|
+
|
|
30
|
+
/** Mask a client secret for API responses — never return the full value. */
|
|
31
|
+
function maskSecret(secret: string): string {
|
|
32
|
+
if (secret.length <= 8) return "****";
|
|
33
|
+
return secret.slice(0, 4) + "****" + secret.slice(-4);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Return a provider with secrets masked. */
|
|
37
|
+
function toPublicProvider(provider: IdpProvider): IdpProvider {
|
|
38
|
+
const config = { ...provider.config };
|
|
39
|
+
if (typeof config.clientSecret === "string") {
|
|
40
|
+
config.clientSecret = maskSecret(config.clientSecret);
|
|
41
|
+
}
|
|
42
|
+
if (typeof config.bindCredential === "string") {
|
|
43
|
+
config.bindCredential = maskSecret(config.bindCredential);
|
|
44
|
+
}
|
|
45
|
+
return { ...provider, config };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* OIDC state tokens — in-memory, short-lived.
|
|
50
|
+
* Maps state -> { providerId, createdAt }
|
|
51
|
+
*/
|
|
52
|
+
const pendingStates = new Map<string, { providerId: string; createdAt: number }>();
|
|
53
|
+
const STATE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
54
|
+
|
|
55
|
+
// Periodic cleanup of expired states
|
|
56
|
+
setInterval(() => {
|
|
57
|
+
const now = Date.now();
|
|
58
|
+
for (const [key, val] of pendingStates) {
|
|
59
|
+
if (now - val.createdAt > STATE_TTL_MS) {
|
|
60
|
+
pendingStates.delete(key);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}, 60_000);
|
|
64
|
+
|
|
65
|
+
export function registerIdpRoutes(
|
|
66
|
+
app: FastifyInstance,
|
|
67
|
+
idpProviderStore: IIdpProviderStore,
|
|
68
|
+
roleMappingStore: IRoleMappingStore,
|
|
69
|
+
userStore: IUserStore,
|
|
70
|
+
roleStore: IRoleStore,
|
|
71
|
+
userRoleStore: IUserRoleStore,
|
|
72
|
+
sessionStore: ISessionStore,
|
|
73
|
+
jwtSecret: Uint8Array,
|
|
74
|
+
options?: { hasDedicatedEncryptionKey?: boolean },
|
|
75
|
+
): void {
|
|
76
|
+
const hasDedicatedEncryptionKey = options?.hasDedicatedEncryptionKey ?? false;
|
|
77
|
+
const oidcAdapter = new OidcAdapter();
|
|
78
|
+
const samlAdapter = new SamlAdapter();
|
|
79
|
+
const ldapAdapter = new LdapAdapter();
|
|
80
|
+
|
|
81
|
+
// ─── IdP Provider CRUD (admin only) ───────────────────────────────
|
|
82
|
+
|
|
83
|
+
// GET /api/idp/providers — list configured IdPs
|
|
84
|
+
app.get("/api/idp/providers", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async () => {
|
|
85
|
+
const providers = idpProviderStore.list();
|
|
86
|
+
return { providers: providers.map(toPublicProvider) };
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// POST /api/idp/providers — create new IdP
|
|
90
|
+
app.post("/api/idp/providers", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
|
|
91
|
+
if (!hasDedicatedEncryptionKey) {
|
|
92
|
+
return reply.status(503).send({
|
|
93
|
+
error: "Encryption key not configured. Set SYNTH_ENCRYPTION_KEY environment variable before configuring identity providers.",
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const parsed = CreateIdpProviderSchema.safeParse(request.body);
|
|
98
|
+
if (!parsed.success) {
|
|
99
|
+
return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const { type, name, enabled, config } = parsed.data;
|
|
103
|
+
const now = new Date();
|
|
104
|
+
const provider: IdpProvider = {
|
|
105
|
+
id: crypto.randomUUID(),
|
|
106
|
+
type: type as IdpProvider["type"],
|
|
107
|
+
name,
|
|
108
|
+
enabled: enabled ?? true,
|
|
109
|
+
config: config as Record<string, unknown>,
|
|
110
|
+
createdAt: now,
|
|
111
|
+
updatedAt: now,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
idpProviderStore.create(provider);
|
|
115
|
+
return reply.status(201).send({ provider: toPublicProvider(provider) });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// PUT /api/idp/providers/:id — update IdP config
|
|
119
|
+
app.put<{ Params: { id: string } }>("/api/idp/providers/:id", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
|
|
120
|
+
if (!hasDedicatedEncryptionKey) {
|
|
121
|
+
return reply.status(503).send({
|
|
122
|
+
error: "Encryption key not configured. Set SYNTH_ENCRYPTION_KEY environment variable before configuring identity providers.",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const parsed = UpdateIdpProviderSchema.safeParse(request.body);
|
|
127
|
+
if (!parsed.success) {
|
|
128
|
+
return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const existing = idpProviderStore.getById(request.params.id);
|
|
132
|
+
if (!existing) {
|
|
133
|
+
return reply.status(404).send({ error: "IdP provider not found" });
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const updates: Record<string, unknown> = { updatedAt: new Date() };
|
|
137
|
+
if (parsed.data.name !== undefined) updates.name = parsed.data.name;
|
|
138
|
+
if (parsed.data.enabled !== undefined) updates.enabled = parsed.data.enabled;
|
|
139
|
+
if (parsed.data.config !== undefined) {
|
|
140
|
+
// If clientSecret or bindCredential is masked (contains ****), preserve the existing one
|
|
141
|
+
const newConfig = parsed.data.config as Record<string, unknown>;
|
|
142
|
+
if (typeof newConfig.clientSecret === "string" && newConfig.clientSecret.includes("****")) {
|
|
143
|
+
newConfig.clientSecret = (existing.config as Record<string, unknown>).clientSecret;
|
|
144
|
+
}
|
|
145
|
+
if (typeof newConfig.bindCredential === "string" && newConfig.bindCredential.includes("****")) {
|
|
146
|
+
newConfig.bindCredential = (existing.config as Record<string, unknown>).bindCredential;
|
|
147
|
+
}
|
|
148
|
+
updates.config = newConfig;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const provider = idpProviderStore.update(
|
|
152
|
+
request.params.id,
|
|
153
|
+
updates as Parameters<typeof idpProviderStore.update>[1],
|
|
154
|
+
);
|
|
155
|
+
return { provider: toPublicProvider(provider) };
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// DELETE /api/idp/providers/:id — remove IdP
|
|
159
|
+
app.delete<{ Params: { id: string } }>("/api/idp/providers/:id", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
|
|
160
|
+
const existing = idpProviderStore.getById(request.params.id);
|
|
161
|
+
if (!existing) {
|
|
162
|
+
return reply.status(404).send({ error: "IdP provider not found" });
|
|
163
|
+
}
|
|
164
|
+
idpProviderStore.delete(request.params.id);
|
|
165
|
+
return reply.status(204).send();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
// POST /api/idp/providers/:id/test — test connection
|
|
169
|
+
app.post<{ Params: { id: string } }>("/api/idp/providers/:id/test", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
|
|
170
|
+
const provider = idpProviderStore.getById(request.params.id);
|
|
171
|
+
if (!provider) {
|
|
172
|
+
return reply.status(404).send({ error: "IdP provider not found" });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (provider.type === "oidc") {
|
|
176
|
+
const result = await oidcAdapter.validateConfig(provider.config);
|
|
177
|
+
return { success: result.valid, error: result.error };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (provider.type === "saml") {
|
|
181
|
+
const result = await samlAdapter.validateConfig(provider.config);
|
|
182
|
+
return { success: result.valid, error: result.error };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (provider.type === "ldap") {
|
|
186
|
+
const result = await ldapAdapter.testConnection(provider.config as unknown as LdapConfig);
|
|
187
|
+
return { success: result.success, error: result.error };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return reply.status(400).send({ error: `Test not supported for provider type: ${provider.type}` });
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ─── Role Mapping CRUD ────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
// GET /api/idp/providers/:id/mappings — list role mapping rules
|
|
196
|
+
app.get<{ Params: { id: string } }>("/api/idp/providers/:id/mappings", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
|
|
197
|
+
const provider = idpProviderStore.getById(request.params.id);
|
|
198
|
+
if (!provider) {
|
|
199
|
+
return reply.status(404).send({ error: "IdP provider not found" });
|
|
200
|
+
}
|
|
201
|
+
const mappings = roleMappingStore.listByProvider(request.params.id);
|
|
202
|
+
return { mappings };
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// POST /api/idp/providers/:id/mappings — add role mapping rule
|
|
206
|
+
app.post<{ Params: { id: string } }>("/api/idp/providers/:id/mappings", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
|
|
207
|
+
const provider = idpProviderStore.getById(request.params.id);
|
|
208
|
+
if (!provider) {
|
|
209
|
+
return reply.status(404).send({ error: "IdP provider not found" });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const parsed = CreateRoleMappingSchema.safeParse(request.body);
|
|
213
|
+
if (!parsed.success) {
|
|
214
|
+
return reply.status(400).send({ error: "Validation failed", details: parsed.error.flatten() });
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const rule = roleMappingStore.create({
|
|
218
|
+
id: crypto.randomUUID(),
|
|
219
|
+
providerId: request.params.id,
|
|
220
|
+
idpGroup: parsed.data.idpGroup,
|
|
221
|
+
synthRole: parsed.data.synthRole,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return reply.status(201).send({ mapping: rule });
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// DELETE /api/idp/mappings/:id — remove mapping rule
|
|
228
|
+
app.delete<{ Params: { id: string } }>("/api/idp/mappings/:id", { preHandler: [requireEdition("sso"), requirePermission("settings.manage")] }, async (request, reply) => {
|
|
229
|
+
const existing = roleMappingStore.getById(request.params.id);
|
|
230
|
+
if (!existing) {
|
|
231
|
+
return reply.status(404).send({ error: "Role mapping not found" });
|
|
232
|
+
}
|
|
233
|
+
roleMappingStore.delete(request.params.id);
|
|
234
|
+
return reply.status(204).send();
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// ─── OIDC Auth Routes (exempt from auth middleware) ───────────────
|
|
238
|
+
|
|
239
|
+
// GET /api/auth/oidc/:providerId/authorize — redirect to IdP
|
|
240
|
+
app.get<{ Params: { providerId: string } }>("/api/auth/oidc/:providerId/authorize", async (request, reply) => {
|
|
241
|
+
const provider = idpProviderStore.getById(request.params.providerId);
|
|
242
|
+
if (!provider || !provider.enabled) {
|
|
243
|
+
return reply.status(404).send({ error: "Identity provider not found or disabled" });
|
|
244
|
+
}
|
|
245
|
+
if (provider.type !== "oidc") {
|
|
246
|
+
return reply.status(400).send({ error: "Provider is not an OIDC provider" });
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const config = provider.config as unknown as OidcConfig;
|
|
250
|
+
const state = crypto.randomUUID();
|
|
251
|
+
pendingStates.set(state, { providerId: provider.id, createdAt: Date.now() });
|
|
252
|
+
|
|
253
|
+
// Build redirect URI based on the current request
|
|
254
|
+
const proto = (request.headers["x-forwarded-proto"] as string) ?? "http";
|
|
255
|
+
const host = request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:9410";
|
|
256
|
+
const redirectUri = `${proto}://${host}/api/auth/callback/oidc/${provider.id}`;
|
|
257
|
+
|
|
258
|
+
const authUrl = await oidcAdapter.getAuthorizationUrl(config, redirectUri, state);
|
|
259
|
+
return reply.redirect(authUrl);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
// GET /api/auth/callback/oidc/:providerId — OIDC callback
|
|
263
|
+
app.get<{ Params: { providerId: string }; Querystring: { code?: string; state?: string; error?: string } }>(
|
|
264
|
+
"/api/auth/callback/oidc/:providerId",
|
|
265
|
+
async (request, reply) => {
|
|
266
|
+
const { code, state, error: oidcError } = request.query as { code?: string; state?: string; error?: string };
|
|
267
|
+
|
|
268
|
+
if (oidcError) {
|
|
269
|
+
return reply.status(400).send({ error: `OIDC error: ${oidcError}` });
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (!code || !state) {
|
|
273
|
+
return reply.status(400).send({ error: "Missing code or state parameter" });
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Validate state
|
|
277
|
+
const pendingState = pendingStates.get(state);
|
|
278
|
+
if (!pendingState || pendingState.providerId !== request.params.providerId) {
|
|
279
|
+
return reply.status(400).send({ error: "Invalid or expired state parameter" });
|
|
280
|
+
}
|
|
281
|
+
pendingStates.delete(state);
|
|
282
|
+
|
|
283
|
+
// Check if state has expired
|
|
284
|
+
if (Date.now() - pendingState.createdAt > STATE_TTL_MS) {
|
|
285
|
+
return reply.status(400).send({ error: "State parameter expired" });
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const provider = idpProviderStore.getById(request.params.providerId);
|
|
289
|
+
if (!provider || !provider.enabled) {
|
|
290
|
+
return reply.status(404).send({ error: "Identity provider not found or disabled" });
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const config = provider.config as unknown as OidcConfig;
|
|
294
|
+
const proto = (request.headers["x-forwarded-proto"] as string) ?? "http";
|
|
295
|
+
const host = request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:9410";
|
|
296
|
+
const redirectUri = `${proto}://${host}/api/auth/callback/oidc/${provider.id}`;
|
|
297
|
+
|
|
298
|
+
// Exchange code for user info
|
|
299
|
+
let idpUser;
|
|
300
|
+
try {
|
|
301
|
+
idpUser = await oidcAdapter.authenticate({ code, redirectUri, config });
|
|
302
|
+
} catch (err) {
|
|
303
|
+
const message = err instanceof Error ? err.message : "Unknown OIDC error";
|
|
304
|
+
return reply.status(500).send({ error: `OIDC authentication failed: ${message}` });
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (!idpUser.email) {
|
|
308
|
+
return reply.status(400).send({ error: "OIDC provider did not return an email address" });
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Provision or update user
|
|
312
|
+
let user = userStore.getByExternalId(idpUser.externalId, "oidc");
|
|
313
|
+
if (!user) {
|
|
314
|
+
// Check if a local user exists with this email
|
|
315
|
+
user = userStore.getByEmail(idpUser.email);
|
|
316
|
+
if (user && user.authSource === "local") {
|
|
317
|
+
// Link existing local user to OIDC
|
|
318
|
+
user = userStore.update(user.id, {
|
|
319
|
+
externalId: idpUser.externalId,
|
|
320
|
+
authSource: "oidc",
|
|
321
|
+
name: idpUser.displayName || user.name,
|
|
322
|
+
updatedAt: new Date(),
|
|
323
|
+
});
|
|
324
|
+
} else if (!user) {
|
|
325
|
+
// Create new user
|
|
326
|
+
const userId = crypto.randomUUID() as UserId;
|
|
327
|
+
const now = new Date();
|
|
328
|
+
user = userStore.create({
|
|
329
|
+
id: userId,
|
|
330
|
+
email: idpUser.email,
|
|
331
|
+
name: idpUser.displayName || idpUser.email,
|
|
332
|
+
passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random password — user authenticates via OIDC
|
|
333
|
+
authSource: "oidc",
|
|
334
|
+
externalId: idpUser.externalId,
|
|
335
|
+
createdAt: now,
|
|
336
|
+
updatedAt: now,
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
// Update existing OIDC user
|
|
341
|
+
user = userStore.update(user.id, {
|
|
342
|
+
email: idpUser.email,
|
|
343
|
+
name: idpUser.displayName || user.name,
|
|
344
|
+
updatedAt: new Date(),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Apply role mappings
|
|
349
|
+
const mappingRules = roleMappingStore.listByProvider(provider.id);
|
|
350
|
+
const mappedRoleNames = applyRoleMappings(idpUser, mappingRules);
|
|
351
|
+
if (mappedRoleNames.length > 0) {
|
|
352
|
+
const roleIds: RoleId[] = [];
|
|
353
|
+
for (const roleName of mappedRoleNames) {
|
|
354
|
+
const role = roleStore.getByName(roleName);
|
|
355
|
+
if (role) roleIds.push(role.id);
|
|
356
|
+
}
|
|
357
|
+
if (roleIds.length > 0) {
|
|
358
|
+
userRoleStore.setRoles(user.id, roleIds, user.id);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Create session and generate tokens
|
|
363
|
+
const tokens = await generateTokens(user.id, jwtSecret);
|
|
364
|
+
sessionStore.create({
|
|
365
|
+
id: crypto.randomUUID(),
|
|
366
|
+
userId: user.id,
|
|
367
|
+
token: tokens.token,
|
|
368
|
+
refreshToken: tokens.refreshToken,
|
|
369
|
+
expiresAt: tokens.expiresAt,
|
|
370
|
+
createdAt: new Date(),
|
|
371
|
+
userAgent: request.headers["user-agent"] ?? undefined,
|
|
372
|
+
ipAddress: request.ip ?? undefined,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// Redirect to UI with token (the UI picks this up and stores it)
|
|
376
|
+
const uiRedirect = `/?oidc_token=${encodeURIComponent(tokens.token)}&oidc_refresh=${encodeURIComponent(tokens.refreshToken)}`;
|
|
377
|
+
return reply.redirect(uiRedirect);
|
|
378
|
+
},
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// ─── SAML Auth Routes (exempt from auth middleware) ─────────────────
|
|
382
|
+
|
|
383
|
+
// GET /api/auth/saml/:providerId/authorize — generate AuthnRequest, redirect to IdP
|
|
384
|
+
app.get<{ Params: { providerId: string } }>("/api/auth/saml/:providerId/authorize", async (request, reply) => {
|
|
385
|
+
const provider = idpProviderStore.getById(request.params.providerId);
|
|
386
|
+
if (!provider || !provider.enabled) {
|
|
387
|
+
return reply.status(404).send({ error: "Identity provider not found or disabled" });
|
|
388
|
+
}
|
|
389
|
+
if (provider.type !== "saml") {
|
|
390
|
+
return reply.status(400).send({ error: "Provider is not a SAML provider" });
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const config = provider.config as unknown as SamlConfig;
|
|
394
|
+
|
|
395
|
+
// Build the ACS callback URL based on the current request
|
|
396
|
+
const proto = (request.headers["x-forwarded-proto"] as string) ?? "http";
|
|
397
|
+
const host = request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:9410";
|
|
398
|
+
const callbackUrl = `${proto}://${host}/api/auth/callback/saml/${provider.id}`;
|
|
399
|
+
|
|
400
|
+
// Ensure config has the correct callbackUrl for this request
|
|
401
|
+
const effectiveConfig: SamlConfig = {
|
|
402
|
+
...config,
|
|
403
|
+
callbackUrl,
|
|
404
|
+
groupsAttribute: config.groupsAttribute || "memberOf",
|
|
405
|
+
signatureAlgorithm: config.signatureAlgorithm || "sha256",
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
// Generate state for relay
|
|
409
|
+
const state = crypto.randomUUID();
|
|
410
|
+
pendingStates.set(state, { providerId: provider.id, createdAt: Date.now() });
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const authUrl = await samlAdapter.getAuthorizationUrl(effectiveConfig, state);
|
|
414
|
+
return reply.redirect(authUrl);
|
|
415
|
+
} catch (err) {
|
|
416
|
+
const message = err instanceof Error ? err.message : "Unknown SAML error";
|
|
417
|
+
return reply.status(500).send({ error: `Failed to generate SAML AuthnRequest: ${message}` });
|
|
418
|
+
}
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// POST /api/auth/callback/saml/:providerId — handle SAML Response (ACS endpoint)
|
|
422
|
+
app.post<{ Params: { providerId: string } }>(
|
|
423
|
+
"/api/auth/callback/saml/:providerId",
|
|
424
|
+
async (request, reply) => {
|
|
425
|
+
const body = request.body as Record<string, string> | undefined;
|
|
426
|
+
const samlResponse = body?.SAMLResponse;
|
|
427
|
+
const relayState = body?.RelayState;
|
|
428
|
+
|
|
429
|
+
if (!samlResponse) {
|
|
430
|
+
return reply.status(400).send({ error: "Missing SAMLResponse in request body" });
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// Validate relay state if present
|
|
434
|
+
if (relayState) {
|
|
435
|
+
const pendingState = pendingStates.get(relayState);
|
|
436
|
+
if (!pendingState || pendingState.providerId !== request.params.providerId) {
|
|
437
|
+
return reply.status(400).send({ error: "Invalid or expired relay state" });
|
|
438
|
+
}
|
|
439
|
+
pendingStates.delete(relayState);
|
|
440
|
+
|
|
441
|
+
if (Date.now() - pendingState.createdAt > STATE_TTL_MS) {
|
|
442
|
+
return reply.status(400).send({ error: "Relay state expired" });
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const provider = idpProviderStore.getById(request.params.providerId);
|
|
447
|
+
if (!provider || !provider.enabled) {
|
|
448
|
+
return reply.status(404).send({ error: "Identity provider not found or disabled" });
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const config = provider.config as unknown as SamlConfig;
|
|
452
|
+
const proto = (request.headers["x-forwarded-proto"] as string) ?? "http";
|
|
453
|
+
const host = request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:9410";
|
|
454
|
+
const callbackUrl = `${proto}://${host}/api/auth/callback/saml/${provider.id}`;
|
|
455
|
+
|
|
456
|
+
const effectiveConfig: SamlConfig = {
|
|
457
|
+
...config,
|
|
458
|
+
callbackUrl,
|
|
459
|
+
groupsAttribute: config.groupsAttribute || "memberOf",
|
|
460
|
+
signatureAlgorithm: config.signatureAlgorithm || "sha256",
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// Validate SAML Response and extract user
|
|
464
|
+
let idpUser;
|
|
465
|
+
try {
|
|
466
|
+
idpUser = await samlAdapter.authenticate({ samlResponse, config: effectiveConfig });
|
|
467
|
+
} catch (err) {
|
|
468
|
+
const message = err instanceof Error ? err.message : "Unknown SAML error";
|
|
469
|
+
return reply.status(500).send({ error: `SAML authentication failed: ${message}` });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (!idpUser.email) {
|
|
473
|
+
return reply.status(400).send({ error: "SAML provider did not return an email address" });
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// Provision or update user (same pattern as OIDC)
|
|
477
|
+
let user = userStore.getByExternalId(idpUser.externalId, "saml");
|
|
478
|
+
if (!user) {
|
|
479
|
+
user = userStore.getByEmail(idpUser.email);
|
|
480
|
+
if (user && user.authSource === "local") {
|
|
481
|
+
// Link existing local user to SAML
|
|
482
|
+
user = userStore.update(user.id, {
|
|
483
|
+
externalId: idpUser.externalId,
|
|
484
|
+
authSource: "saml",
|
|
485
|
+
name: idpUser.displayName || user.name,
|
|
486
|
+
updatedAt: new Date(),
|
|
487
|
+
});
|
|
488
|
+
} else if (!user) {
|
|
489
|
+
// Create new user
|
|
490
|
+
const userId = crypto.randomUUID() as UserId;
|
|
491
|
+
const now = new Date();
|
|
492
|
+
user = userStore.create({
|
|
493
|
+
id: userId,
|
|
494
|
+
email: idpUser.email,
|
|
495
|
+
name: idpUser.displayName || idpUser.email,
|
|
496
|
+
passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random password — user authenticates via SAML
|
|
497
|
+
authSource: "saml",
|
|
498
|
+
externalId: idpUser.externalId,
|
|
499
|
+
createdAt: now,
|
|
500
|
+
updatedAt: now,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
} else {
|
|
504
|
+
// Update existing SAML user
|
|
505
|
+
user = userStore.update(user.id, {
|
|
506
|
+
email: idpUser.email,
|
|
507
|
+
name: idpUser.displayName || user.name,
|
|
508
|
+
updatedAt: new Date(),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Apply role mappings
|
|
513
|
+
const mappingRules = roleMappingStore.listByProvider(provider.id);
|
|
514
|
+
const mappedRoleNames = applyRoleMappings(idpUser, mappingRules);
|
|
515
|
+
if (mappedRoleNames.length > 0) {
|
|
516
|
+
const roleIds: RoleId[] = [];
|
|
517
|
+
for (const roleName of mappedRoleNames) {
|
|
518
|
+
const role = roleStore.getByName(roleName);
|
|
519
|
+
if (role) roleIds.push(role.id);
|
|
520
|
+
}
|
|
521
|
+
if (roleIds.length > 0) {
|
|
522
|
+
userRoleStore.setRoles(user.id, roleIds, user.id);
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// Create session and generate tokens
|
|
527
|
+
const tokens = await generateTokens(user.id, jwtSecret);
|
|
528
|
+
sessionStore.create({
|
|
529
|
+
id: crypto.randomUUID(),
|
|
530
|
+
userId: user.id,
|
|
531
|
+
token: tokens.token,
|
|
532
|
+
refreshToken: tokens.refreshToken,
|
|
533
|
+
expiresAt: tokens.expiresAt,
|
|
534
|
+
createdAt: new Date(),
|
|
535
|
+
userAgent: request.headers["user-agent"] ?? undefined,
|
|
536
|
+
ipAddress: request.ip ?? undefined,
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
// Redirect to UI with token
|
|
540
|
+
const uiRedirect = `/?saml_token=${encodeURIComponent(tokens.token)}&saml_refresh=${encodeURIComponent(tokens.refreshToken)}`;
|
|
541
|
+
return reply.redirect(uiRedirect);
|
|
542
|
+
},
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
// GET /api/auth/saml/:providerId/metadata — return SP metadata XML
|
|
546
|
+
app.get<{ Params: { providerId: string } }>("/api/auth/saml/:providerId/metadata", async (request, reply) => {
|
|
547
|
+
const provider = idpProviderStore.getById(request.params.providerId);
|
|
548
|
+
if (!provider) {
|
|
549
|
+
return reply.status(404).send({ error: "Identity provider not found" });
|
|
550
|
+
}
|
|
551
|
+
if (provider.type !== "saml") {
|
|
552
|
+
return reply.status(400).send({ error: "Provider is not a SAML provider" });
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const config = provider.config as unknown as SamlConfig;
|
|
556
|
+
const proto = (request.headers["x-forwarded-proto"] as string) ?? "http";
|
|
557
|
+
const host = request.headers["x-forwarded-host"] ?? request.headers.host ?? "localhost:9410";
|
|
558
|
+
const callbackUrl = `${proto}://${host}/api/auth/callback/saml/${provider.id}`;
|
|
559
|
+
|
|
560
|
+
const effectiveConfig: SamlConfig = {
|
|
561
|
+
...config,
|
|
562
|
+
callbackUrl,
|
|
563
|
+
groupsAttribute: config.groupsAttribute || "memberOf",
|
|
564
|
+
signatureAlgorithm: config.signatureAlgorithm || "sha256",
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
try {
|
|
568
|
+
const metadata = samlAdapter.generateMetadata(effectiveConfig);
|
|
569
|
+
reply.type("application/xml");
|
|
570
|
+
return reply.send(metadata);
|
|
571
|
+
} catch (err) {
|
|
572
|
+
const message = err instanceof Error ? err.message : "Unknown error";
|
|
573
|
+
return reply.status(500).send({ error: `Failed to generate SP metadata: ${message}` });
|
|
574
|
+
}
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
// ─── LDAP Auth Routes ────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
// POST /api/auth/ldap/:providerId/login — username/password login via LDAP
|
|
580
|
+
app.post<{ Params: { providerId: string } }>(
|
|
581
|
+
"/api/auth/ldap/:providerId/login",
|
|
582
|
+
async (request, reply) => {
|
|
583
|
+
const provider = idpProviderStore.getById(request.params.providerId);
|
|
584
|
+
if (!provider || !provider.enabled) {
|
|
585
|
+
return reply.status(404).send({ error: "Identity provider not found or disabled" });
|
|
586
|
+
}
|
|
587
|
+
if (provider.type !== "ldap") {
|
|
588
|
+
return reply.status(400).send({ error: "Provider is not an LDAP provider" });
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const body = request.body as Record<string, string> | undefined;
|
|
592
|
+
const username = body?.username;
|
|
593
|
+
const password = body?.password;
|
|
594
|
+
|
|
595
|
+
if (!username || !password) {
|
|
596
|
+
return reply.status(400).send({ error: "Username and password are required" });
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const config = provider.config as unknown as LdapConfig;
|
|
600
|
+
|
|
601
|
+
// Authenticate against LDAP
|
|
602
|
+
let idpUser;
|
|
603
|
+
try {
|
|
604
|
+
idpUser = await ldapAdapter.authenticate({ username, password, config });
|
|
605
|
+
} catch (err) {
|
|
606
|
+
const message = err instanceof Error ? err.message : "Unknown LDAP error";
|
|
607
|
+
return reply.status(401).send({ error: `LDAP authentication failed: ${message}` });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
if (!idpUser.email) {
|
|
611
|
+
return reply.status(400).send({ error: "LDAP directory did not return an email address for this user" });
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Provision or update user (same pattern as OIDC/SAML)
|
|
615
|
+
let user = userStore.getByExternalId(idpUser.externalId, "ldap");
|
|
616
|
+
if (!user) {
|
|
617
|
+
// Check if a local user exists with this email
|
|
618
|
+
user = userStore.getByEmail(idpUser.email);
|
|
619
|
+
if (user && user.authSource === "local") {
|
|
620
|
+
// Link existing local user to LDAP
|
|
621
|
+
user = userStore.update(user.id, {
|
|
622
|
+
externalId: idpUser.externalId,
|
|
623
|
+
authSource: "ldap",
|
|
624
|
+
name: idpUser.displayName || user.name,
|
|
625
|
+
updatedAt: new Date(),
|
|
626
|
+
});
|
|
627
|
+
} else if (!user) {
|
|
628
|
+
// Create new user
|
|
629
|
+
const userId = crypto.randomUUID() as UserId;
|
|
630
|
+
const now = new Date();
|
|
631
|
+
user = userStore.create({
|
|
632
|
+
id: userId,
|
|
633
|
+
email: idpUser.email,
|
|
634
|
+
name: idpUser.displayName || idpUser.email,
|
|
635
|
+
passwordHash: await bcrypt.hash(crypto.randomUUID(), 10), // random password — user authenticates via LDAP
|
|
636
|
+
authSource: "ldap",
|
|
637
|
+
externalId: idpUser.externalId,
|
|
638
|
+
createdAt: now,
|
|
639
|
+
updatedAt: now,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
} else {
|
|
643
|
+
// Update existing LDAP user
|
|
644
|
+
user = userStore.update(user.id, {
|
|
645
|
+
email: idpUser.email,
|
|
646
|
+
name: idpUser.displayName || user.name,
|
|
647
|
+
updatedAt: new Date(),
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Apply role mappings
|
|
652
|
+
const mappingRules = roleMappingStore.listByProvider(provider.id);
|
|
653
|
+
const mappedRoleNames = applyRoleMappings(idpUser, mappingRules);
|
|
654
|
+
if (mappedRoleNames.length > 0) {
|
|
655
|
+
const roleIds: RoleId[] = [];
|
|
656
|
+
for (const roleName of mappedRoleNames) {
|
|
657
|
+
const role = roleStore.getByName(roleName);
|
|
658
|
+
if (role) roleIds.push(role.id);
|
|
659
|
+
}
|
|
660
|
+
if (roleIds.length > 0) {
|
|
661
|
+
userRoleStore.setRoles(user.id, roleIds, user.id);
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// Create session and generate tokens
|
|
666
|
+
const tokens = await generateTokens(user.id, jwtSecret);
|
|
667
|
+
const now = new Date();
|
|
668
|
+
sessionStore.create({
|
|
669
|
+
id: crypto.randomUUID(),
|
|
670
|
+
userId: user.id,
|
|
671
|
+
token: tokens.token,
|
|
672
|
+
refreshToken: tokens.refreshToken,
|
|
673
|
+
expiresAt: tokens.expiresAt,
|
|
674
|
+
createdAt: now,
|
|
675
|
+
userAgent: request.headers["user-agent"] ?? undefined,
|
|
676
|
+
ipAddress: request.ip ?? undefined,
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
const permissions = userRoleStore.getUserPermissions(user.id);
|
|
680
|
+
|
|
681
|
+
return {
|
|
682
|
+
user: {
|
|
683
|
+
id: user.id,
|
|
684
|
+
email: user.email,
|
|
685
|
+
name: user.name,
|
|
686
|
+
authSource: user.authSource ?? "ldap",
|
|
687
|
+
createdAt: user.createdAt,
|
|
688
|
+
updatedAt: user.updatedAt,
|
|
689
|
+
},
|
|
690
|
+
token: tokens.token,
|
|
691
|
+
refreshToken: tokens.refreshToken,
|
|
692
|
+
permissions,
|
|
693
|
+
};
|
|
694
|
+
},
|
|
695
|
+
);
|
|
696
|
+
|
|
697
|
+
// POST /api/idp/providers/:id/test-ldap-user — test user search (admin only)
|
|
698
|
+
app.post<{ Params: { id: string } }>(
|
|
699
|
+
"/api/idp/providers/:id/test-ldap-user",
|
|
700
|
+
{ preHandler: [requireEdition("sso"), requirePermission("settings.manage")] },
|
|
701
|
+
async (request, reply) => {
|
|
702
|
+
const provider = idpProviderStore.getById(request.params.id);
|
|
703
|
+
if (!provider) {
|
|
704
|
+
return reply.status(404).send({ error: "IdP provider not found" });
|
|
705
|
+
}
|
|
706
|
+
if (provider.type !== "ldap") {
|
|
707
|
+
return reply.status(400).send({ error: "Provider is not an LDAP provider" });
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
const body = request.body as Record<string, string> | undefined;
|
|
711
|
+
const username = body?.username;
|
|
712
|
+
if (!username) {
|
|
713
|
+
return reply.status(400).send({ error: "username is required in request body" });
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const config = provider.config as unknown as LdapConfig;
|
|
717
|
+
const result = await ldapAdapter.testUser(config, username);
|
|
718
|
+
return result;
|
|
719
|
+
},
|
|
720
|
+
);
|
|
721
|
+
|
|
722
|
+
// ─── Public: list enabled providers (for login page) ──────────────
|
|
723
|
+
|
|
724
|
+
// GET /api/auth/providers — list enabled IdPs (public, for login page)
|
|
725
|
+
app.get("/api/auth/providers", async () => {
|
|
726
|
+
const providers = idpProviderStore.list()
|
|
727
|
+
.filter((p) => p.enabled)
|
|
728
|
+
.map((p) => ({
|
|
729
|
+
id: p.id,
|
|
730
|
+
type: p.type,
|
|
731
|
+
name: p.name,
|
|
732
|
+
}));
|
|
733
|
+
return { providers };
|
|
734
|
+
});
|
|
735
|
+
}
|