@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.
Files changed (317) hide show
  1. package/dist/agent/debrief-retention.d.ts +12 -0
  2. package/dist/agent/debrief-retention.d.ts.map +1 -0
  3. package/dist/agent/debrief-retention.js +27 -0
  4. package/dist/agent/debrief-retention.js.map +1 -0
  5. package/dist/agent/envoy-client.d.ts +216 -0
  6. package/dist/agent/envoy-client.d.ts.map +1 -0
  7. package/dist/agent/envoy-client.js +266 -0
  8. package/dist/agent/envoy-client.js.map +1 -0
  9. package/dist/agent/envoy-registry.d.ts +102 -0
  10. package/dist/agent/envoy-registry.d.ts.map +1 -0
  11. package/dist/agent/envoy-registry.js +319 -0
  12. package/dist/agent/envoy-registry.js.map +1 -0
  13. package/dist/agent/health-checker.d.ts +39 -0
  14. package/dist/agent/health-checker.d.ts.map +1 -0
  15. package/dist/agent/health-checker.js +49 -0
  16. package/dist/agent/health-checker.js.map +1 -0
  17. package/dist/agent/mcp-client-manager.d.ts +36 -0
  18. package/dist/agent/mcp-client-manager.d.ts.map +1 -0
  19. package/dist/agent/mcp-client-manager.js +106 -0
  20. package/dist/agent/mcp-client-manager.js.map +1 -0
  21. package/dist/agent/stale-deployment-detector.d.ts +15 -0
  22. package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
  23. package/dist/agent/stale-deployment-detector.js +50 -0
  24. package/dist/agent/stale-deployment-detector.js.map +1 -0
  25. package/dist/agent/step-runner.d.ts +31 -0
  26. package/dist/agent/step-runner.d.ts.map +1 -0
  27. package/dist/agent/step-runner.js +80 -0
  28. package/dist/agent/step-runner.js.map +1 -0
  29. package/dist/agent/synth-agent.d.ts +168 -0
  30. package/dist/agent/synth-agent.d.ts.map +1 -0
  31. package/dist/agent/synth-agent.js +1195 -0
  32. package/dist/agent/synth-agent.js.map +1 -0
  33. package/dist/api/agent.d.ts +36 -0
  34. package/dist/api/agent.d.ts.map +1 -0
  35. package/dist/api/agent.js +867 -0
  36. package/dist/api/agent.js.map +1 -0
  37. package/dist/api/api-keys.d.ts +4 -0
  38. package/dist/api/api-keys.d.ts.map +1 -0
  39. package/dist/api/api-keys.js +118 -0
  40. package/dist/api/api-keys.js.map +1 -0
  41. package/dist/api/artifacts.d.ts +5 -0
  42. package/dist/api/artifacts.d.ts.map +1 -0
  43. package/dist/api/artifacts.js +142 -0
  44. package/dist/api/artifacts.js.map +1 -0
  45. package/dist/api/auth.d.ts +4 -0
  46. package/dist/api/auth.d.ts.map +1 -0
  47. package/dist/api/auth.js +280 -0
  48. package/dist/api/auth.js.map +1 -0
  49. package/dist/api/deployments.d.ts +11 -0
  50. package/dist/api/deployments.d.ts.map +1 -0
  51. package/dist/api/deployments.js +1098 -0
  52. package/dist/api/deployments.js.map +1 -0
  53. package/dist/api/environments.d.ts +5 -0
  54. package/dist/api/environments.d.ts.map +1 -0
  55. package/dist/api/environments.js +69 -0
  56. package/dist/api/environments.js.map +1 -0
  57. package/dist/api/envoy-reports.d.ts +17 -0
  58. package/dist/api/envoy-reports.d.ts.map +1 -0
  59. package/dist/api/envoy-reports.js +138 -0
  60. package/dist/api/envoy-reports.js.map +1 -0
  61. package/dist/api/envoys.d.ts +5 -0
  62. package/dist/api/envoys.d.ts.map +1 -0
  63. package/dist/api/envoys.js +192 -0
  64. package/dist/api/envoys.js.map +1 -0
  65. package/dist/api/fleet.d.ts +11 -0
  66. package/dist/api/fleet.d.ts.map +1 -0
  67. package/dist/api/fleet.js +394 -0
  68. package/dist/api/fleet.js.map +1 -0
  69. package/dist/api/graph.d.ts +8 -0
  70. package/dist/api/graph.d.ts.map +1 -0
  71. package/dist/api/graph.js +355 -0
  72. package/dist/api/graph.js.map +1 -0
  73. package/dist/api/health.d.ts +20 -0
  74. package/dist/api/health.d.ts.map +1 -0
  75. package/dist/api/health.js +248 -0
  76. package/dist/api/health.js.map +1 -0
  77. package/dist/api/idp-schemas.d.ts +41 -0
  78. package/dist/api/idp-schemas.d.ts.map +1 -0
  79. package/dist/api/idp-schemas.js +17 -0
  80. package/dist/api/idp-schemas.js.map +1 -0
  81. package/dist/api/idp.d.ts +6 -0
  82. package/dist/api/idp.d.ts.map +1 -0
  83. package/dist/api/idp.js +620 -0
  84. package/dist/api/idp.js.map +1 -0
  85. package/dist/api/intake.d.ts +10 -0
  86. package/dist/api/intake.d.ts.map +1 -0
  87. package/dist/api/intake.js +418 -0
  88. package/dist/api/intake.js.map +1 -0
  89. package/dist/api/partitions.d.ts +5 -0
  90. package/dist/api/partitions.d.ts.map +1 -0
  91. package/dist/api/partitions.js +113 -0
  92. package/dist/api/partitions.js.map +1 -0
  93. package/dist/api/progress-event-store.d.ts +62 -0
  94. package/dist/api/progress-event-store.d.ts.map +1 -0
  95. package/dist/api/progress-event-store.js +118 -0
  96. package/dist/api/progress-event-store.js.map +1 -0
  97. package/dist/api/schemas.d.ts +1000 -0
  98. package/dist/api/schemas.d.ts.map +1 -0
  99. package/dist/api/schemas.js +328 -0
  100. package/dist/api/schemas.js.map +1 -0
  101. package/dist/api/security-boundaries.d.ts +4 -0
  102. package/dist/api/security-boundaries.d.ts.map +1 -0
  103. package/dist/api/security-boundaries.js +32 -0
  104. package/dist/api/security-boundaries.js.map +1 -0
  105. package/dist/api/settings.d.ts +4 -0
  106. package/dist/api/settings.d.ts.map +1 -0
  107. package/dist/api/settings.js +99 -0
  108. package/dist/api/settings.js.map +1 -0
  109. package/dist/api/system.d.ts +75 -0
  110. package/dist/api/system.d.ts.map +1 -0
  111. package/dist/api/system.js +558 -0
  112. package/dist/api/system.js.map +1 -0
  113. package/dist/api/telemetry.d.ts +4 -0
  114. package/dist/api/telemetry.d.ts.map +1 -0
  115. package/dist/api/telemetry.js +24 -0
  116. package/dist/api/telemetry.js.map +1 -0
  117. package/dist/api/users.d.ts +4 -0
  118. package/dist/api/users.d.ts.map +1 -0
  119. package/dist/api/users.js +173 -0
  120. package/dist/api/users.js.map +1 -0
  121. package/dist/archive-unpacker.d.ts +24 -0
  122. package/dist/archive-unpacker.d.ts.map +1 -0
  123. package/dist/archive-unpacker.js +239 -0
  124. package/dist/archive-unpacker.js.map +1 -0
  125. package/dist/artifact-analyzer.d.ts +59 -0
  126. package/dist/artifact-analyzer.d.ts.map +1 -0
  127. package/dist/artifact-analyzer.js +334 -0
  128. package/dist/artifact-analyzer.js.map +1 -0
  129. package/dist/auth/idp/index.d.ts +9 -0
  130. package/dist/auth/idp/index.d.ts.map +1 -0
  131. package/dist/auth/idp/index.js +5 -0
  132. package/dist/auth/idp/index.js.map +1 -0
  133. package/dist/auth/idp/ldap.d.ts +56 -0
  134. package/dist/auth/idp/ldap.d.ts.map +1 -0
  135. package/dist/auth/idp/ldap.js +276 -0
  136. package/dist/auth/idp/ldap.js.map +1 -0
  137. package/dist/auth/idp/oidc.d.ts +27 -0
  138. package/dist/auth/idp/oidc.d.ts.map +1 -0
  139. package/dist/auth/idp/oidc.js +97 -0
  140. package/dist/auth/idp/oidc.js.map +1 -0
  141. package/dist/auth/idp/role-mapping.d.ts +9 -0
  142. package/dist/auth/idp/role-mapping.d.ts.map +1 -0
  143. package/dist/auth/idp/role-mapping.js +16 -0
  144. package/dist/auth/idp/role-mapping.js.map +1 -0
  145. package/dist/auth/idp/saml.d.ts +40 -0
  146. package/dist/auth/idp/saml.d.ts.map +1 -0
  147. package/dist/auth/idp/saml.js +117 -0
  148. package/dist/auth/idp/saml.js.map +1 -0
  149. package/dist/auth/idp/types.d.ts +23 -0
  150. package/dist/auth/idp/types.d.ts.map +1 -0
  151. package/dist/auth/idp/types.js +2 -0
  152. package/dist/auth/idp/types.js.map +1 -0
  153. package/dist/fleet/fleet-executor.d.ts +35 -0
  154. package/dist/fleet/fleet-executor.d.ts.map +1 -0
  155. package/dist/fleet/fleet-executor.js +228 -0
  156. package/dist/fleet/fleet-executor.js.map +1 -0
  157. package/dist/fleet/fleet-store.d.ts +13 -0
  158. package/dist/fleet/fleet-store.d.ts.map +1 -0
  159. package/dist/fleet/fleet-store.js +13 -0
  160. package/dist/fleet/fleet-store.js.map +1 -0
  161. package/dist/fleet/index.d.ts +5 -0
  162. package/dist/fleet/index.d.ts.map +1 -0
  163. package/dist/fleet/index.js +4 -0
  164. package/dist/fleet/index.js.map +1 -0
  165. package/dist/fleet/representative-selector.d.ts +15 -0
  166. package/dist/fleet/representative-selector.d.ts.map +1 -0
  167. package/dist/fleet/representative-selector.js +71 -0
  168. package/dist/fleet/representative-selector.js.map +1 -0
  169. package/dist/graph/graph-executor.d.ts +36 -0
  170. package/dist/graph/graph-executor.d.ts.map +1 -0
  171. package/dist/graph/graph-executor.js +348 -0
  172. package/dist/graph/graph-executor.js.map +1 -0
  173. package/dist/graph/graph-inference.d.ts +22 -0
  174. package/dist/graph/graph-inference.d.ts.map +1 -0
  175. package/dist/graph/graph-inference.js +149 -0
  176. package/dist/graph/graph-inference.js.map +1 -0
  177. package/dist/graph/graph-store.d.ts +12 -0
  178. package/dist/graph/graph-store.d.ts.map +1 -0
  179. package/dist/graph/graph-store.js +61 -0
  180. package/dist/graph/graph-store.js.map +1 -0
  181. package/dist/graph/index.d.ts +5 -0
  182. package/dist/graph/index.d.ts.map +1 -0
  183. package/dist/graph/index.js +4 -0
  184. package/dist/graph/index.js.map +1 -0
  185. package/dist/index.d.ts +2 -0
  186. package/dist/index.d.ts.map +1 -0
  187. package/dist/index.js +837 -0
  188. package/dist/index.js.map +1 -0
  189. package/dist/intake/index.d.ts +6 -0
  190. package/dist/intake/index.d.ts.map +1 -0
  191. package/dist/intake/index.js +5 -0
  192. package/dist/intake/index.js.map +1 -0
  193. package/dist/intake/intake-processor.d.ts +17 -0
  194. package/dist/intake/intake-processor.d.ts.map +1 -0
  195. package/dist/intake/intake-processor.js +99 -0
  196. package/dist/intake/intake-processor.js.map +1 -0
  197. package/dist/intake/intake-store.d.ts +7 -0
  198. package/dist/intake/intake-store.d.ts.map +1 -0
  199. package/dist/intake/intake-store.js +7 -0
  200. package/dist/intake/intake-store.js.map +1 -0
  201. package/dist/intake/registry-poller.d.ts +41 -0
  202. package/dist/intake/registry-poller.d.ts.map +1 -0
  203. package/dist/intake/registry-poller.js +202 -0
  204. package/dist/intake/registry-poller.js.map +1 -0
  205. package/dist/intake/webhook-handlers.d.ts +37 -0
  206. package/dist/intake/webhook-handlers.d.ts.map +1 -0
  207. package/dist/intake/webhook-handlers.js +268 -0
  208. package/dist/intake/webhook-handlers.js.map +1 -0
  209. package/dist/logger.d.ts +5 -0
  210. package/dist/logger.d.ts.map +1 -0
  211. package/dist/logger.js +15 -0
  212. package/dist/logger.js.map +1 -0
  213. package/dist/mcp/resources.d.ts +9 -0
  214. package/dist/mcp/resources.d.ts.map +1 -0
  215. package/dist/mcp/resources.js +72 -0
  216. package/dist/mcp/resources.js.map +1 -0
  217. package/dist/mcp/server.d.ts +15 -0
  218. package/dist/mcp/server.d.ts.map +1 -0
  219. package/dist/mcp/server.js +20 -0
  220. package/dist/mcp/server.js.map +1 -0
  221. package/dist/mcp/tools.d.ts +9 -0
  222. package/dist/mcp/tools.d.ts.map +1 -0
  223. package/dist/mcp/tools.js +88 -0
  224. package/dist/mcp/tools.js.map +1 -0
  225. package/dist/middleware/auth.d.ts +29 -0
  226. package/dist/middleware/auth.d.ts.map +1 -0
  227. package/dist/middleware/auth.js +76 -0
  228. package/dist/middleware/auth.js.map +1 -0
  229. package/dist/middleware/permissions.d.ts +13 -0
  230. package/dist/middleware/permissions.d.ts.map +1 -0
  231. package/dist/middleware/permissions.js +32 -0
  232. package/dist/middleware/permissions.js.map +1 -0
  233. package/dist/pattern-store.d.ts +104 -0
  234. package/dist/pattern-store.d.ts.map +1 -0
  235. package/dist/pattern-store.js +299 -0
  236. package/dist/pattern-store.js.map +1 -0
  237. package/package.json +54 -0
  238. package/src/agent/debrief-retention.ts +44 -0
  239. package/src/agent/envoy-client.ts +474 -0
  240. package/src/agent/envoy-registry.ts +384 -0
  241. package/src/agent/health-checker.ts +70 -0
  242. package/src/agent/mcp-client-manager.ts +131 -0
  243. package/src/agent/stale-deployment-detector.ts +79 -0
  244. package/src/agent/step-runner.ts +124 -0
  245. package/src/agent/synth-agent.ts +1567 -0
  246. package/src/api/agent.ts +1075 -0
  247. package/src/api/api-keys.ts +129 -0
  248. package/src/api/artifacts.ts +194 -0
  249. package/src/api/auth.ts +320 -0
  250. package/src/api/deployments.ts +1347 -0
  251. package/src/api/environments.ts +97 -0
  252. package/src/api/envoy-reports.ts +159 -0
  253. package/src/api/envoys.ts +237 -0
  254. package/src/api/fleet.ts +510 -0
  255. package/src/api/graph.ts +516 -0
  256. package/src/api/health.ts +311 -0
  257. package/src/api/idp-schemas.ts +19 -0
  258. package/src/api/idp.ts +735 -0
  259. package/src/api/intake.ts +537 -0
  260. package/src/api/partitions.ts +147 -0
  261. package/src/api/progress-event-store.ts +153 -0
  262. package/src/api/schemas.ts +376 -0
  263. package/src/api/security-boundaries.ts +54 -0
  264. package/src/api/settings.ts +118 -0
  265. package/src/api/system.ts +704 -0
  266. package/src/api/telemetry.ts +32 -0
  267. package/src/api/users.ts +210 -0
  268. package/src/archive-unpacker.ts +271 -0
  269. package/src/artifact-analyzer.ts +438 -0
  270. package/src/auth/idp/index.ts +8 -0
  271. package/src/auth/idp/ldap.ts +340 -0
  272. package/src/auth/idp/oidc.ts +117 -0
  273. package/src/auth/idp/role-mapping.ts +22 -0
  274. package/src/auth/idp/saml.ts +148 -0
  275. package/src/auth/idp/types.ts +22 -0
  276. package/src/fleet/fleet-executor.ts +309 -0
  277. package/src/fleet/fleet-store.ts +13 -0
  278. package/src/fleet/index.ts +4 -0
  279. package/src/fleet/representative-selector.ts +83 -0
  280. package/src/graph/graph-executor.ts +446 -0
  281. package/src/graph/graph-inference.ts +184 -0
  282. package/src/graph/graph-store.ts +75 -0
  283. package/src/graph/index.ts +4 -0
  284. package/src/index.ts +916 -0
  285. package/src/intake/index.ts +5 -0
  286. package/src/intake/intake-processor.ts +111 -0
  287. package/src/intake/intake-store.ts +7 -0
  288. package/src/intake/registry-poller.ts +230 -0
  289. package/src/intake/webhook-handlers.ts +328 -0
  290. package/src/logger.ts +19 -0
  291. package/src/mcp/resources.ts +98 -0
  292. package/src/mcp/server.ts +34 -0
  293. package/src/mcp/tools.ts +117 -0
  294. package/src/middleware/auth.ts +103 -0
  295. package/src/middleware/permissions.ts +35 -0
  296. package/src/pattern-store.ts +409 -0
  297. package/tests/agent-mode.test.ts +536 -0
  298. package/tests/api-handlers.test.ts +1245 -0
  299. package/tests/archive-unpacker.test.ts +179 -0
  300. package/tests/artifact-analyzer.test.ts +240 -0
  301. package/tests/auth-middleware.test.ts +189 -0
  302. package/tests/decision-diary.test.ts +957 -0
  303. package/tests/diary-reader.test.ts +782 -0
  304. package/tests/envoy-client.test.ts +342 -0
  305. package/tests/envoy-reports.test.ts +156 -0
  306. package/tests/mcp-tools.test.ts +213 -0
  307. package/tests/orchestration.test.ts +536 -0
  308. package/tests/partition-deletion.test.ts +143 -0
  309. package/tests/partition-isolation.test.ts +830 -0
  310. package/tests/pattern-store.test.ts +371 -0
  311. package/tests/rbac-enforcement.test.ts +409 -0
  312. package/tests/ssrf-validation.test.ts +56 -0
  313. package/tests/stale-deployment.test.ts +85 -0
  314. package/tests/step-runner.test.ts +308 -0
  315. package/tests/ui-journey.test.ts +330 -0
  316. package/tsconfig.json +11 -0
  317. package/vitest.config.ts +27 -0
@@ -0,0 +1,340 @@
1
+ import ldap from "ldapjs";
2
+ import type { IdpUser } from "@synth-deploy/core";
3
+ import type { IdpAdapter } from "./types.js";
4
+
5
+ export interface LdapConfig {
6
+ url: string; // e.g. ldaps://dc.corp.example.com:636
7
+ bindDn: string; // service account DN
8
+ bindCredential: string; // service account password (encrypted at rest)
9
+ searchBase: string; // e.g. ou=Users,dc=corp,dc=example,dc=com
10
+ searchFilter: string; // e.g. (sAMAccountName={{username}})
11
+ groupSearchBase: string; // e.g. ou=Groups,dc=corp,dc=example,dc=com
12
+ groupSearchFilter: string; // e.g. (member={{dn}})
13
+ useTls: boolean;
14
+ tlsCaPath?: string;
15
+ }
16
+
17
+ export interface LdapAuthenticateParams {
18
+ username: string;
19
+ password: string;
20
+ config: LdapConfig;
21
+ }
22
+
23
+ interface LdapSearchEntry {
24
+ dn: string;
25
+ attributes: Array<{ type: string; values: string[] }>;
26
+ }
27
+
28
+ /**
29
+ * Creates an ldapjs client with optional TLS settings.
30
+ */
31
+ function createClient(config: LdapConfig): ldap.Client {
32
+ const clientOpts: ldap.ClientOptions = {
33
+ url: config.url,
34
+ connectTimeout: 10_000,
35
+ timeout: 10_000,
36
+ };
37
+
38
+ if (config.useTls && config.tlsCaPath) {
39
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
40
+ const fs = require("node:fs");
41
+ clientOpts.tlsOptions = {
42
+ ca: [fs.readFileSync(config.tlsCaPath)],
43
+ rejectUnauthorized: true,
44
+ };
45
+ }
46
+
47
+ return ldap.createClient(clientOpts);
48
+ }
49
+
50
+ /**
51
+ * Promisified bind operation.
52
+ */
53
+ function bindAsync(client: ldap.Client, dn: string, password: string): Promise<void> {
54
+ return new Promise((resolve, reject) => {
55
+ client.bind(dn, password, (err) => {
56
+ if (err) reject(err);
57
+ else resolve();
58
+ });
59
+ });
60
+ }
61
+
62
+ /**
63
+ * Promisified unbind operation.
64
+ */
65
+ function unbindAsync(client: ldap.Client): Promise<void> {
66
+ return new Promise((resolve) => {
67
+ client.unbind((err) => {
68
+ // Ignore unbind errors — best-effort cleanup
69
+ resolve();
70
+ });
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Promisified search operation. Returns an array of entry objects.
76
+ */
77
+ function searchAsync(
78
+ client: ldap.Client,
79
+ base: string,
80
+ opts: ldap.SearchOptions,
81
+ ): Promise<LdapSearchEntry[]> {
82
+ return new Promise((resolve, reject) => {
83
+ client.search(base, opts, (err, res) => {
84
+ if (err) return reject(err);
85
+
86
+ const entries: LdapSearchEntry[] = [];
87
+ res.on("searchEntry", (entry) => {
88
+ const obj = entry.pojo;
89
+ entries.push({
90
+ dn: obj.objectName,
91
+ attributes: obj.attributes.map((a) => ({
92
+ type: a.type,
93
+ values: a.values,
94
+ })),
95
+ });
96
+ });
97
+ res.on("error", (searchErr) => reject(searchErr));
98
+ res.on("end", () => resolve(entries));
99
+ });
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Extracts a named attribute value from an LDAP entry.
105
+ */
106
+ function getAttr(entry: LdapSearchEntry, name: string): string {
107
+ const attr = entry.attributes.find(
108
+ (a) => a.type.toLowerCase() === name.toLowerCase(),
109
+ );
110
+ return attr?.values[0] ?? "";
111
+ }
112
+
113
+ /**
114
+ * LDAP/Active Directory adapter -- implements IdpAdapter for LDAP-based identity providers.
115
+ *
116
+ * Authentication flow:
117
+ * 1. Bind with service account credentials
118
+ * 2. Search for the user by username
119
+ * 3. Bind with the user's DN + provided password
120
+ * 4. Query group memberships (supports AD nested groups via matching rule OID)
121
+ * 5. Return IdpUser
122
+ */
123
+ export class LdapAdapter implements IdpAdapter {
124
+ type = "ldap";
125
+
126
+ async authenticate(params: unknown): Promise<IdpUser> {
127
+ const { username, password, config } = params as LdapAuthenticateParams;
128
+
129
+ if (!username || !password) {
130
+ throw new Error("Username and password are required");
131
+ }
132
+
133
+ const client = createClient(config);
134
+
135
+ try {
136
+ // Step 1: Bind with service account
137
+ await bindAsync(client, config.bindDn, config.bindCredential);
138
+
139
+ // Step 2: Search for user by username
140
+ const filter = config.searchFilter.replace(/\{\{username\}\}/g, escapeFilterValue(username));
141
+
142
+ const userEntries = await searchAsync(client, config.searchBase, {
143
+ filter,
144
+ scope: "sub",
145
+ attributes: ["dn", "mail", "email", "displayName", "cn", "sAMAccountName", "userPrincipalName", "uid"],
146
+ });
147
+
148
+ if (userEntries.length === 0) {
149
+ throw new Error(`User not found: ${username}`);
150
+ }
151
+
152
+ const userEntry = userEntries[0];
153
+ const userDn = userEntry.dn;
154
+
155
+ // Step 3: Bind as the user to verify their password
156
+ const userClient = createClient(config);
157
+ try {
158
+ await bindAsync(userClient, userDn, password);
159
+ } catch (err) {
160
+ const message = err instanceof Error ? err.message : "Unknown error";
161
+ throw new Error(`Invalid credentials for user ${username}: ${message}`);
162
+ } finally {
163
+ await unbindAsync(userClient);
164
+ }
165
+
166
+ // Step 4: Query group memberships (re-bind as service account)
167
+ // Use AD's LDAP_MATCHING_RULE_IN_CHAIN (1.2.840.113556.1.4.1941)
168
+ // for nested group resolution when the filter contains {{dn}}
169
+ let groupFilter = config.groupSearchFilter.replace(/\{\{dn\}\}/g, escapeFilterValue(userDn));
170
+
171
+ // If the server is Active Directory (URL contains ldaps/ldap and filter uses member),
172
+ // enhance with the transitive membership matching rule for nested groups
173
+ const useNestedGroups = config.groupSearchFilter.includes("member={{dn}}");
174
+ if (useNestedGroups) {
175
+ groupFilter = config.groupSearchFilter.replace(
176
+ "member={{dn}}",
177
+ `member:1.2.840.113556.1.4.1941:=${escapeFilterValue(userDn)}`,
178
+ );
179
+ }
180
+
181
+ let groups: string[] = [];
182
+ try {
183
+ const groupEntries = await searchAsync(client, config.groupSearchBase, {
184
+ filter: groupFilter,
185
+ scope: "sub",
186
+ attributes: ["cn", "dn"],
187
+ });
188
+ groups = groupEntries.map((entry) => getAttr(entry, "cn") || entry.dn);
189
+ } catch {
190
+ // Group search may fail if groupSearchBase is misconfigured — return empty groups
191
+ groups = [];
192
+ }
193
+
194
+ // Step 5: Build IdpUser
195
+ const email =
196
+ getAttr(userEntry, "mail") ||
197
+ getAttr(userEntry, "email") ||
198
+ getAttr(userEntry, "userPrincipalName") ||
199
+ "";
200
+
201
+ const displayName =
202
+ getAttr(userEntry, "displayName") ||
203
+ getAttr(userEntry, "cn") ||
204
+ username;
205
+
206
+ return {
207
+ externalId: userDn,
208
+ email,
209
+ displayName,
210
+ groups,
211
+ provider: "ldap",
212
+ };
213
+ } finally {
214
+ await unbindAsync(client);
215
+ }
216
+ }
217
+
218
+ async validateConfig(config: unknown): Promise<{ valid: boolean; error?: string }> {
219
+ const c = config as Record<string, unknown>;
220
+
221
+ // Required fields
222
+ if (!c.url || typeof c.url !== "string") {
223
+ return { valid: false, error: "url is required and must be a string" };
224
+ }
225
+ if (!c.bindDn || typeof c.bindDn !== "string") {
226
+ return { valid: false, error: "bindDn is required and must be a string" };
227
+ }
228
+ if (!c.bindCredential || typeof c.bindCredential !== "string") {
229
+ return { valid: false, error: "bindCredential is required and must be a string" };
230
+ }
231
+ if (!c.searchBase || typeof c.searchBase !== "string") {
232
+ return { valid: false, error: "searchBase is required and must be a string" };
233
+ }
234
+ if (!c.searchFilter || typeof c.searchFilter !== "string") {
235
+ return { valid: false, error: "searchFilter is required and must be a string" };
236
+ }
237
+ if (!c.groupSearchBase || typeof c.groupSearchBase !== "string") {
238
+ return { valid: false, error: "groupSearchBase is required and must be a string" };
239
+ }
240
+ if (!c.groupSearchFilter || typeof c.groupSearchFilter !== "string") {
241
+ return { valid: false, error: "groupSearchFilter is required and must be a string" };
242
+ }
243
+
244
+ // Validate URL format (must be ldap:// or ldaps://)
245
+ try {
246
+ const url = new URL(c.url);
247
+ if (!["ldap:", "ldaps:"].includes(url.protocol)) {
248
+ return { valid: false, error: "url must use ldap:// or ldaps:// protocol" };
249
+ }
250
+ } catch {
251
+ return { valid: false, error: "url must be a valid URL (e.g. ldaps://dc.corp.example.com:636)" };
252
+ }
253
+
254
+ // Validate that searchFilter contains {{username}} placeholder
255
+ if (!(c.searchFilter as string).includes("{{username}}")) {
256
+ return { valid: false, error: "searchFilter must contain {{username}} placeholder" };
257
+ }
258
+
259
+ // Validate that groupSearchFilter contains {{dn}} placeholder
260
+ if (!(c.groupSearchFilter as string).includes("{{dn}}")) {
261
+ return { valid: false, error: "groupSearchFilter must contain {{dn}} placeholder" };
262
+ }
263
+
264
+ return { valid: true };
265
+ }
266
+
267
+ /**
268
+ * Tests the LDAP connection by attempting a service account bind.
269
+ * Returns success/error without authenticating any user.
270
+ */
271
+ async testConnection(config: LdapConfig): Promise<{ success: boolean; error?: string }> {
272
+ const validation = await this.validateConfig(config);
273
+ if (!validation.valid) {
274
+ return { success: false, error: validation.error };
275
+ }
276
+
277
+ const client = createClient(config);
278
+ try {
279
+ await bindAsync(client, config.bindDn, config.bindCredential);
280
+ return { success: true };
281
+ } catch (err) {
282
+ const message = err instanceof Error ? err.message : "Unknown error";
283
+ return { success: false, error: `Service account bind failed: ${message}` };
284
+ } finally {
285
+ await unbindAsync(client);
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Tests whether a specific user can be found in the LDAP directory.
291
+ * Uses the service account to search — does not authenticate the user.
292
+ */
293
+ async testUser(
294
+ config: LdapConfig,
295
+ username: string,
296
+ ): Promise<{ found: boolean; userDn?: string; email?: string; displayName?: string; error?: string }> {
297
+ const validation = await this.validateConfig(config);
298
+ if (!validation.valid) {
299
+ return { found: false, error: validation.error };
300
+ }
301
+
302
+ const client = createClient(config);
303
+ try {
304
+ await bindAsync(client, config.bindDn, config.bindCredential);
305
+
306
+ const filter = config.searchFilter.replace(/\{\{username\}\}/g, escapeFilterValue(username));
307
+ const entries = await searchAsync(client, config.searchBase, {
308
+ filter,
309
+ scope: "sub",
310
+ attributes: ["dn", "mail", "email", "displayName", "cn", "sAMAccountName", "userPrincipalName"],
311
+ });
312
+
313
+ if (entries.length === 0) {
314
+ return { found: false, error: `No user found matching: ${username}` };
315
+ }
316
+
317
+ const entry = entries[0];
318
+ return {
319
+ found: true,
320
+ userDn: entry.dn,
321
+ email: getAttr(entry, "mail") || getAttr(entry, "email") || getAttr(entry, "userPrincipalName") || undefined,
322
+ displayName: getAttr(entry, "displayName") || getAttr(entry, "cn") || undefined,
323
+ };
324
+ } catch (err) {
325
+ const message = err instanceof Error ? err.message : "Unknown error";
326
+ return { found: false, error: `LDAP search failed: ${message}` };
327
+ } finally {
328
+ await unbindAsync(client);
329
+ }
330
+ }
331
+ }
332
+
333
+ /**
334
+ * Escapes special characters in an LDAP filter value per RFC 4515.
335
+ */
336
+ function escapeFilterValue(value: string): string {
337
+ return value.replace(/[\\*()\/\x00]/g, (ch) => {
338
+ return "\\" + ch.charCodeAt(0).toString(16).padStart(2, "0");
339
+ });
340
+ }
@@ -0,0 +1,117 @@
1
+ import * as client from "openid-client";
2
+ import type { IdpUser, OidcConfig } from "@synth-deploy/core";
3
+ import type { IdpAdapter } from "./types.js";
4
+
5
+ export interface OidcAuthenticateParams {
6
+ code: string;
7
+ redirectUri: string;
8
+ config: OidcConfig;
9
+ }
10
+
11
+ /**
12
+ * OIDC adapter — implements IdpAdapter for OpenID Connect providers.
13
+ *
14
+ * Uses the openid-client library for OIDC discovery and code exchange.
15
+ * Stateless: each authenticate call performs discovery fresh (the library
16
+ * caches internally).
17
+ */
18
+ export class OidcAdapter implements IdpAdapter {
19
+ type = "oidc";
20
+
21
+ async authenticate(params: unknown): Promise<IdpUser> {
22
+ const { code, redirectUri, config } = params as OidcAuthenticateParams;
23
+
24
+ // Discover the OIDC provider configuration
25
+ const issuer = new URL(config.issuerUrl);
26
+ const oidcConfig = await client.discovery(issuer, config.clientId, config.clientSecret);
27
+
28
+ // Exchange authorization code for tokens
29
+ const tokens = await client.authorizationCodeGrant(oidcConfig, new URL(`${redirectUri}?code=${code}`), {
30
+ expectedState: undefined,
31
+ });
32
+
33
+ // Extract user info claims from the ID token or userinfo endpoint
34
+ const claims = tokens.claims();
35
+ let userInfo: Record<string, unknown> = {};
36
+ if (claims) {
37
+ userInfo = claims as unknown as Record<string, unknown>;
38
+ }
39
+
40
+ // If we need more claims, fetch from userinfo endpoint
41
+ try {
42
+ const fetchedInfo = await client.fetchUserInfo(oidcConfig, tokens.access_token!, claims?.sub as string);
43
+ userInfo = { ...userInfo, ...fetchedInfo };
44
+ } catch {
45
+ // UserInfo endpoint may not be available; rely on ID token claims
46
+ }
47
+
48
+ // Extract groups from the configured claim
49
+ const groupsClaim = config.groupsClaim || "groups";
50
+ const groups: string[] = Array.isArray(userInfo[groupsClaim])
51
+ ? (userInfo[groupsClaim] as string[])
52
+ : [];
53
+
54
+ return {
55
+ externalId: (userInfo.sub as string) ?? "",
56
+ email: (userInfo.email as string) ?? "",
57
+ displayName: (userInfo.name as string) ?? (userInfo.preferred_username as string) ?? "",
58
+ groups,
59
+ provider: "oidc",
60
+ };
61
+ }
62
+
63
+ async validateConfig(config: unknown): Promise<{ valid: boolean; error?: string }> {
64
+ const c = config as Record<string, unknown>;
65
+
66
+ if (!c.issuerUrl || typeof c.issuerUrl !== "string") {
67
+ return { valid: false, error: "issuerUrl is required and must be a string" };
68
+ }
69
+ if (!c.clientId || typeof c.clientId !== "string") {
70
+ return { valid: false, error: "clientId is required and must be a string" };
71
+ }
72
+ if (!c.clientSecret || typeof c.clientSecret !== "string") {
73
+ return { valid: false, error: "clientSecret is required and must be a string" };
74
+ }
75
+
76
+ // Validate URL format
77
+ try {
78
+ new URL(c.issuerUrl);
79
+ } catch {
80
+ return { valid: false, error: "issuerUrl must be a valid URL" };
81
+ }
82
+
83
+ // Attempt OIDC discovery to verify the issuer is reachable
84
+ try {
85
+ const issuer = new URL(c.issuerUrl);
86
+ await client.discovery(issuer, c.clientId, c.clientSecret);
87
+ return { valid: true };
88
+ } catch (err) {
89
+ const message = err instanceof Error ? err.message : "Unknown error";
90
+ return { valid: false, error: `OIDC discovery failed: ${message}` };
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Builds the authorization URL to redirect the user to the IdP.
96
+ */
97
+ async getAuthorizationUrl(config: OidcConfig, redirectUri: string, state: string): Promise<string> {
98
+ const issuer = new URL(config.issuerUrl);
99
+ const oidcConfig = await client.discovery(issuer, config.clientId, config.clientSecret);
100
+
101
+ const authEndpoint = oidcConfig.serverMetadata().authorization_endpoint;
102
+ if (!authEndpoint) {
103
+ throw new Error("OIDC provider does not expose an authorization_endpoint");
104
+ }
105
+
106
+ const scopes = config.scopes?.length > 0 ? config.scopes.join(" ") : "openid profile email";
107
+ const params = new URLSearchParams({
108
+ response_type: "code",
109
+ client_id: config.clientId,
110
+ redirect_uri: redirectUri,
111
+ scope: scopes,
112
+ state,
113
+ });
114
+
115
+ return `${authEndpoint}?${params.toString()}`;
116
+ }
117
+ }
@@ -0,0 +1,22 @@
1
+ import type { IdpUser, RoleMappingRule } from "@synth-deploy/core";
2
+
3
+ /**
4
+ * Applies role mapping rules to an IdP user's groups.
5
+ * Returns the set of Synth role names that should be assigned.
6
+ *
7
+ * If no mapping rules match any of the user's groups, returns an empty array.
8
+ */
9
+ export function applyRoleMappings(
10
+ idpUser: IdpUser,
11
+ rules: RoleMappingRule[],
12
+ ): string[] {
13
+ const matchedRoles = new Set<string>();
14
+
15
+ for (const rule of rules) {
16
+ if (idpUser.groups.includes(rule.idpGroup)) {
17
+ matchedRoles.add(rule.synthRole);
18
+ }
19
+ }
20
+
21
+ return [...matchedRoles];
22
+ }
@@ -0,0 +1,148 @@
1
+ import { SAML } from "@node-saml/node-saml";
2
+ import type { IdpUser } from "@synth-deploy/core";
3
+ import type { IdpAdapter } from "./types.js";
4
+
5
+ export interface SamlConfig {
6
+ entryPoint: string; // IdP SSO URL
7
+ issuer: string; // SP entity ID
8
+ cert: string; // IdP signing certificate (PEM)
9
+ callbackUrl: string; // ACS URL
10
+ signatureAlgorithm: "sha256" | "sha512";
11
+ groupsAttribute: string; // default: "memberOf"
12
+ }
13
+
14
+ export interface SamlAuthenticateParams {
15
+ samlResponse: string;
16
+ config: SamlConfig;
17
+ }
18
+
19
+ /**
20
+ * SAML 2.0 adapter — implements IdpAdapter for SAML identity providers.
21
+ *
22
+ * Uses @node-saml/node-saml for AuthnRequest generation, SAML Response
23
+ * validation, and assertion parsing. Stateless: each call constructs a
24
+ * fresh SAML instance from config.
25
+ */
26
+ export class SamlAdapter implements IdpAdapter {
27
+ type = "saml";
28
+
29
+ private buildSaml(config: SamlConfig): SAML {
30
+ return new SAML({
31
+ entryPoint: config.entryPoint,
32
+ issuer: config.issuer,
33
+ idpCert: config.cert,
34
+ callbackUrl: config.callbackUrl,
35
+ signatureAlgorithm: config.signatureAlgorithm === "sha512" ? "sha512" : "sha256",
36
+ wantAuthnResponseSigned: true,
37
+ wantAssertionsSigned: false,
38
+ });
39
+ }
40
+
41
+ async authenticate(params: unknown): Promise<IdpUser> {
42
+ const { samlResponse, config } = params as SamlAuthenticateParams;
43
+
44
+ const saml = this.buildSaml(config);
45
+
46
+ // Validate and parse the SAML Response
47
+ const { profile } = await saml.validatePostResponseAsync({
48
+ SAMLResponse: samlResponse,
49
+ });
50
+
51
+ if (!profile) {
52
+ throw new Error("SAML response did not contain a valid profile");
53
+ }
54
+
55
+ // Extract user attributes
56
+ const email = profile.nameID
57
+ || (profile as Record<string, unknown>)["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress"] as string
58
+ || (profile as Record<string, unknown>).email as string
59
+ || "";
60
+
61
+ const displayName =
62
+ (profile as Record<string, unknown>)["http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name"] as string
63
+ || (profile as Record<string, unknown>).displayName as string
64
+ || (profile as Record<string, unknown>)["http://schemas.microsoft.com/identity/claims/displayname"] as string
65
+ || email;
66
+
67
+ // Extract groups from the configured attribute
68
+ const groupsAttribute = config.groupsAttribute || "memberOf";
69
+ const rawGroups = (profile as Record<string, unknown>)[groupsAttribute];
70
+ let groups: string[] = [];
71
+ if (Array.isArray(rawGroups)) {
72
+ groups = rawGroups.map(String);
73
+ } else if (typeof rawGroups === "string") {
74
+ groups = [rawGroups];
75
+ }
76
+
77
+ return {
78
+ externalId: profile.nameID || "",
79
+ email,
80
+ displayName,
81
+ groups,
82
+ provider: "saml",
83
+ };
84
+ }
85
+
86
+ async validateConfig(config: unknown): Promise<{ valid: boolean; error?: string }> {
87
+ const c = config as Record<string, unknown>;
88
+
89
+ if (!c.entryPoint || typeof c.entryPoint !== "string") {
90
+ return { valid: false, error: "entryPoint is required and must be a string" };
91
+ }
92
+ if (!c.issuer || typeof c.issuer !== "string") {
93
+ return { valid: false, error: "issuer is required and must be a string" };
94
+ }
95
+ if (!c.cert || typeof c.cert !== "string") {
96
+ return { valid: false, error: "cert is required and must be a string" };
97
+ }
98
+ if (!c.callbackUrl || typeof c.callbackUrl !== "string") {
99
+ return { valid: false, error: "callbackUrl is required and must be a string" };
100
+ }
101
+
102
+ // Validate URL formats
103
+ try {
104
+ new URL(c.entryPoint);
105
+ } catch {
106
+ return { valid: false, error: "entryPoint must be a valid URL" };
107
+ }
108
+ try {
109
+ new URL(c.callbackUrl);
110
+ } catch {
111
+ return { valid: false, error: "callbackUrl must be a valid URL" };
112
+ }
113
+
114
+ // Validate signatureAlgorithm if provided
115
+ if (c.signatureAlgorithm && !["sha256", "sha512"].includes(c.signatureAlgorithm as string)) {
116
+ return { valid: false, error: "signatureAlgorithm must be 'sha256' or 'sha512'" };
117
+ }
118
+
119
+ // Validate PEM certificate format (basic check)
120
+ const cert = c.cert as string;
121
+ const pemPattern = /-----BEGIN CERTIFICATE-----[\s\S]+-----END CERTIFICATE-----/;
122
+ const isBase64Block = /^[A-Za-z0-9+/\s=]+$/.test(cert.trim());
123
+ if (!pemPattern.test(cert) && !isBase64Block) {
124
+ return { valid: false, error: "cert must be a valid PEM-encoded certificate or base64 certificate body" };
125
+ }
126
+
127
+ return { valid: true };
128
+ }
129
+
130
+ /**
131
+ * Generates the AuthnRequest URL to redirect the user to the SAML IdP.
132
+ */
133
+ async getAuthorizationUrl(config: SamlConfig, relayState?: string): Promise<string> {
134
+ const saml = this.buildSaml(config);
135
+ const url = await saml.getAuthorizeUrlAsync(relayState ?? "", undefined, {});
136
+ return url;
137
+ }
138
+
139
+ /**
140
+ * Generates SP metadata XML for the configured SAML service provider.
141
+ * This metadata can be provided to the IdP administrator for trust configuration.
142
+ */
143
+ generateMetadata(config: SamlConfig): string {
144
+ const saml = this.buildSaml(config);
145
+ const metadata = saml.generateServiceProviderMetadata(null, null);
146
+ return metadata;
147
+ }
148
+ }
@@ -0,0 +1,22 @@
1
+ import type { IdpUser } from "@synth-deploy/core";
2
+
3
+ /**
4
+ * Adapter interface for Identity Provider integrations.
5
+ * Each IdP type (OIDC, SAML, LDAP) implements this interface.
6
+ */
7
+ export interface IdpAdapter {
8
+ /** The IdP type this adapter handles */
9
+ type: string;
10
+
11
+ /**
12
+ * Authenticates a user via IdP-specific mechanism.
13
+ * For OIDC: exchanges an authorization code for tokens and extracts user info.
14
+ */
15
+ authenticate(params: unknown): Promise<IdpUser>;
16
+
17
+ /**
18
+ * Validates that a given config object is well-formed for this IdP type.
19
+ * Used when creating or updating an IdP provider configuration.
20
+ */
21
+ validateConfig(config: unknown): Promise<{ valid: boolean; error?: string }>;
22
+ }