@checkstack/auth-ldap-backend 0.0.8 → 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/CHANGELOG.md CHANGED
@@ -1,5 +1,30 @@
1
1
  # @checkstack/auth-ldap-backend
2
2
 
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - d94121b: Add group-to-role mapping for SAML and LDAP authentication
8
+
9
+ **Features:**
10
+
11
+ - SAML and LDAP users can now be automatically assigned Checkstack roles based on their directory group memberships
12
+ - Configure group mappings in the authentication strategy settings with dynamic role dropdowns
13
+ - Managed role sync: roles configured in mappings are fully synchronized (added when user gains group, removed when user leaves group)
14
+ - Unmanaged roles (manually assigned, not in any mapping) are preserved during sync
15
+ - Optional default role for all users from a directory
16
+
17
+ **Bug Fix:**
18
+
19
+ - Fixed `x-options-resolver` not working for fields inside arrays with `.default([])` in DynamicForm schemas
20
+
21
+ ### Patch Changes
22
+
23
+ - Updated dependencies [d94121b]
24
+ - @checkstack/backend-api@0.3.3
25
+ - @checkstack/auth-backend@0.4.0
26
+ - @checkstack/auth-common@0.5.0
27
+
3
28
  ## 0.0.8
4
29
 
5
30
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/auth-ldap-backend",
3
- "version": "0.0.8",
3
+ "version": "0.1.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "exports": {
package/src/helpers.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Helper to extract groups from LDAP entry (multi-valued attribute)
3
+ * Returns all group DNs as an array of strings
4
+ */
5
+ export const extractGroups = ({
6
+ ldapEntry,
7
+ memberOfAttribute,
8
+ }: {
9
+ ldapEntry: Record<string, unknown>;
10
+ memberOfAttribute: string;
11
+ }): string[] => {
12
+ const value = ldapEntry[memberOfAttribute];
13
+ if (typeof value === "string") return [value];
14
+ if (Array.isArray(value)) return value.map(String);
15
+ return [];
16
+ };
package/src/index.test.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it, beforeEach, mock } from "bun:test";
2
2
  import type { Client as LdapClient } from "ldapts";
3
+ import { extractGroups } from "./helpers";
3
4
 
4
5
  describe("LDAP Authentication Strategy", () => {
5
6
  // Mock LDAP client
@@ -24,7 +25,7 @@ describe("LDAP Authentication Strategy", () => {
24
25
  sn: "User",
25
26
  },
26
27
  ],
27
- })
28
+ }),
28
29
  ),
29
30
  unbind: mock(() => Promise.resolve()),
30
31
  };
@@ -38,7 +39,7 @@ describe("LDAP Authentication Strategy", () => {
38
39
  await mockLdapClient.bind("cn=admin,dc=example,dc=com", "adminPassword");
39
40
  expect(mockLdapClient.bind).toHaveBeenCalledWith(
40
41
  "cn=admin,dc=example,dc=com",
41
- "adminPassword"
42
+ "adminPassword",
42
43
  );
43
44
  });
44
45
 
@@ -47,7 +48,7 @@ describe("LDAP Authentication Strategy", () => {
47
48
  mockLdapClient.bind.mockRejectedValue(new Error("Invalid credentials"));
48
49
 
49
50
  await expect(
50
- mockLdapClient.bind("uid=testuser,ou=users,dc=example,dc=com", "wrong")
51
+ mockLdapClient.bind("uid=testuser,ou=users,dc=example,dc=com", "wrong"),
51
52
  ).rejects.toThrow("Invalid credentials");
52
53
  });
53
54
 
@@ -270,3 +271,108 @@ describe("LDAP Authentication Strategy", () => {
270
271
  });
271
272
  });
272
273
  });
274
+
275
+ describe("extractGroups helper for LDAP", () => {
276
+ it("should extract single group as array", () => {
277
+ const ldapEntry = {
278
+ memberOf: "CN=Developers,OU=Groups,DC=example,DC=com",
279
+ };
280
+ const result = extractGroups({
281
+ ldapEntry,
282
+ memberOfAttribute: "memberOf",
283
+ });
284
+ expect(result).toEqual(["CN=Developers,OU=Groups,DC=example,DC=com"]);
285
+ });
286
+
287
+ it("should extract multiple groups from array", () => {
288
+ const ldapEntry = {
289
+ memberOf: [
290
+ "CN=Developers,OU=Groups,DC=example,DC=com",
291
+ "CN=All-Users,OU=Groups,DC=example,DC=com",
292
+ "CN=Admins,OU=Groups,DC=example,DC=com",
293
+ ],
294
+ };
295
+ const result = extractGroups({
296
+ ldapEntry,
297
+ memberOfAttribute: "memberOf",
298
+ });
299
+ expect(result).toEqual([
300
+ "CN=Developers,OU=Groups,DC=example,DC=com",
301
+ "CN=All-Users,OU=Groups,DC=example,DC=com",
302
+ "CN=Admins,OU=Groups,DC=example,DC=com",
303
+ ]);
304
+ });
305
+
306
+ it("should return empty array for missing memberOf attribute", () => {
307
+ const ldapEntry = {
308
+ uid: "testuser",
309
+ mail: "test@example.com",
310
+ };
311
+ const result = extractGroups({
312
+ ldapEntry,
313
+ memberOfAttribute: "memberOf",
314
+ });
315
+ expect(result).toEqual([]);
316
+ });
317
+
318
+ it("should return empty array for empty memberOf array", () => {
319
+ const ldapEntry = {
320
+ memberOf: [],
321
+ };
322
+ const result = extractGroups({
323
+ ldapEntry,
324
+ memberOfAttribute: "memberOf",
325
+ });
326
+ expect(result).toEqual([]);
327
+ });
328
+
329
+ it("should handle custom memberOf attribute names", () => {
330
+ const ldapEntry = {
331
+ isMemberOf: ["group1", "group2"],
332
+ };
333
+ const result = extractGroups({
334
+ ldapEntry,
335
+ memberOfAttribute: "isMemberOf",
336
+ });
337
+ expect(result).toEqual(["group1", "group2"]);
338
+ });
339
+
340
+ it("should handle undefined/null values gracefully", () => {
341
+ const ldapEntry = {
342
+ memberOf: undefined,
343
+ };
344
+ const result = extractGroups({
345
+ ldapEntry,
346
+ memberOfAttribute: "memberOf",
347
+ });
348
+ expect(result).toEqual([]);
349
+ });
350
+
351
+ it("should convert non-string group values to strings", () => {
352
+ const ldapEntry = {
353
+ memberOf: [123, "CN=Group,DC=test", true],
354
+ };
355
+ const result = extractGroups({
356
+ ldapEntry,
357
+ memberOfAttribute: "memberOf",
358
+ });
359
+ expect(result).toEqual(["123", "CN=Group,DC=test", "true"]);
360
+ });
361
+
362
+ it("should handle Active Directory group DNs", () => {
363
+ const ldapEntry = {
364
+ memberOf: [
365
+ "CN=Domain Users,CN=Users,DC=corp,DC=example,DC=com",
366
+ "CN=Engineering,OU=Security Groups,DC=corp,DC=example,DC=com",
367
+ ],
368
+ };
369
+ const result = extractGroups({
370
+ ldapEntry,
371
+ memberOfAttribute: "memberOf",
372
+ });
373
+ expect(result).toEqual([
374
+ "CN=Domain Users,CN=Users,DC=corp,DC=example,DC=com",
375
+ "CN=Engineering,OU=Security Groups,DC=corp,DC=example,DC=com",
376
+ ]);
377
+ });
378
+ });
package/src/index.ts CHANGED
@@ -15,6 +15,7 @@ import { AuthApi } from "@checkstack/auth-common";
15
15
  import { z } from "zod";
16
16
  import { Client as LdapClient } from "ldapts";
17
17
  import { hashPassword } from "better-auth/crypto";
18
+ import { extractGroups } from "./helpers";
18
19
 
19
20
  // LDAP Configuration Schema V1
20
21
  const _ldapConfigV1 = z.object({
@@ -28,7 +29,7 @@ const _ldapConfigV1 = z.object({
28
29
  bindDN: configString({})
29
30
  .optional()
30
31
  .describe(
31
- "Service account DN for searching (e.g., cn=admin,dc=example,dc=com)"
32
+ "Service account DN for searching (e.g., cn=admin,dc=example,dc=com)",
32
33
  ),
33
34
  bindPassword: configString({ "x-secret": true })
34
35
  .describe("Service account password")
@@ -86,8 +87,8 @@ const _ldapConfigV1 = z.object({
86
87
  .describe("Update user attributes on each login"),
87
88
  });
88
89
 
89
- // LDAP Configuration Schema V1
90
- const ldapConfigV2 = z.object({
90
+ // LDAP Configuration Schema V2 (kept for migration type reference)
91
+ const _ldapConfigV2 = z.object({
91
92
  url: configString({})
92
93
  .url()
93
94
  .default("ldaps://ldap.example.com:636")
@@ -95,7 +96,7 @@ const ldapConfigV2 = z.object({
95
96
  bindDN: configString({})
96
97
  .optional()
97
98
  .describe(
98
- "Service account DN for searching (e.g., cn=admin,dc=example,dc=com)"
99
+ "Service account DN for searching (e.g., cn=admin,dc=example,dc=com)",
99
100
  ),
100
101
  bindPassword: configString({ "x-secret": true })
101
102
  .describe("Service account password")
@@ -153,7 +154,108 @@ const ldapConfigV2 = z.object({
153
154
  .describe("Update user attributes on each login"),
154
155
  });
155
156
 
156
- type LdapConfig = z.infer<typeof ldapConfigV2>;
157
+ // LDAP Configuration Schema V3 - Adds group-to-role mapping
158
+ const ldapConfigV3 = z.object({
159
+ url: configString({})
160
+ .url()
161
+ .default("ldaps://ldap.example.com:636")
162
+ .describe("LDAP server URL (e.g., ldaps://ldap.example.com:636)"),
163
+ bindDN: configString({})
164
+ .optional()
165
+ .describe(
166
+ "Service account DN for searching (e.g., cn=admin,dc=example,dc=com)",
167
+ ),
168
+ bindPassword: configString({ "x-secret": true })
169
+ .describe("Service account password")
170
+ .optional(),
171
+ baseDN: configString({})
172
+ .default("ou=users,dc=example,dc=com")
173
+ .describe("Base DN for user searches (e.g., ou=users,dc=example,dc=com)"),
174
+ searchFilter: configString({})
175
+ .default("(uid={0})")
176
+ .describe("LDAP search filter, {0} will be replaced with username"),
177
+ usernameAttribute: configString({})
178
+ .default("uid")
179
+ .describe("LDAP attribute to match against login username"),
180
+ attributeMapping: z
181
+ .object({
182
+ email: configString({})
183
+ .default("mail")
184
+ .describe("LDAP attribute for email address"),
185
+ name: configString({})
186
+ .default("displayName")
187
+ .describe("LDAP attribute for display name"),
188
+ firstName: configString({})
189
+ .default("givenName")
190
+ .describe("LDAP attribute for first name")
191
+ .optional(),
192
+ lastName: configString({})
193
+ .default("sn")
194
+ .describe("LDAP attribute for last name")
195
+ .optional(),
196
+ })
197
+ .default({
198
+ email: "mail",
199
+ name: "displayName",
200
+ })
201
+ .describe("Map LDAP attributes to user fields"),
202
+ // Group to Role Mapping
203
+ groupMapping: z
204
+ .object({
205
+ enabled: configBoolean({})
206
+ .default(false)
207
+ .describe("Enable group-to-role mapping"),
208
+ memberOfAttribute: configString({})
209
+ .default("memberOf")
210
+ .describe("LDAP attribute containing group memberships"),
211
+ mappings: z
212
+ .array(
213
+ z.object({
214
+ directoryGroup: configString({}).describe(
215
+ "Directory group DN (e.g., cn=developers,ou=groups,dc=example,dc=com)",
216
+ ),
217
+ checkstackRole: configString({
218
+ "x-options-resolver": "roleOptions",
219
+ }).describe("Checkstack role ID to assign"),
220
+ }),
221
+ )
222
+ .default([])
223
+ .describe("Map directory groups to Checkstack roles"),
224
+ defaultRole: configString({
225
+ "x-options-resolver": "roleOptions",
226
+ })
227
+ .optional()
228
+ .describe("Default role assigned to all LDAP users (optional)"),
229
+ })
230
+ .default({
231
+ enabled: false,
232
+ memberOfAttribute: "memberOf",
233
+ mappings: [],
234
+ })
235
+ .describe("Map LDAP groups to Checkstack roles"),
236
+ tlsOptions: z
237
+ .object({
238
+ rejectUnauthorized: configBoolean({})
239
+ .default(true)
240
+ .describe("Reject unauthorized SSL certificates"),
241
+ ca: configString({ "x-secret": true })
242
+ .describe("Custom CA certificate (PEM format)")
243
+ .optional(),
244
+ })
245
+ .default({ rejectUnauthorized: true })
246
+ .describe("TLS/SSL configuration"),
247
+ timeout: configNumber({})
248
+ .default(5000)
249
+ .describe("Connection timeout in milliseconds"),
250
+ autoCreateUsers: configBoolean({})
251
+ .default(true)
252
+ .describe("Automatically create users on first login"),
253
+ autoUpdateUsers: configBoolean({})
254
+ .default(true)
255
+ .describe("Update user attributes on each login"),
256
+ });
257
+
258
+ type LdapConfig = z.infer<typeof ldapConfigV3>;
157
259
 
158
260
  // LDAP Strategy Definition
159
261
  const ldapStrategy: AuthStrategy<LdapConfig> = {
@@ -161,8 +263,8 @@ const ldapStrategy: AuthStrategy<LdapConfig> = {
161
263
  displayName: "LDAP",
162
264
  description: "Authenticate using LDAP directory",
163
265
  icon: "Network",
164
- configVersion: 2,
165
- configSchema: ldapConfigV2,
266
+ configVersion: 3,
267
+ configSchema: ldapConfigV3,
166
268
  requiresManualRegistration: false,
167
269
  adminInstructions: `
168
270
  ## LDAP Configuration
@@ -175,6 +277,13 @@ Configure LDAP authentication to allow users from your directory to sign in:
175
277
  4. Configure the **search filter** to match usernames (e.g., \`(uid={0})\` or \`(sAMAccountName={0})\`)
176
278
  5. Map **LDAP attributes** to user fields (email, name)
177
279
 
280
+ ### Group to Role Mapping
281
+ Map LDAP groups to Checkstack roles for automatic role assignment:
282
+ 1. Enable **Group to Role Mapping**
283
+ 2. Set the **Member Of Attribute** (usually \`memberOf\`)
284
+ 3. Add mappings from directory group DNs to Checkstack roles
285
+ 4. Optionally set a **Default Role** for all LDAP users
286
+
178
287
  > **Active Directory**: Use \`ldaps://\` with port 636, \`sAMAccountName\` for username, and \`userPrincipalName\` for email.
179
288
  `.trim(),
180
289
  migrations: [
@@ -188,6 +297,19 @@ Configure LDAP authentication to allow users from your directory to sign in:
188
297
  return rest;
189
298
  },
190
299
  },
300
+ {
301
+ description: "Add group-to-role mapping configuration",
302
+ fromVersion: 2,
303
+ toVersion: 3,
304
+ migrate: (oldConfig: z.infer<typeof _ldapConfigV2>) => ({
305
+ ...oldConfig,
306
+ groupMapping: {
307
+ enabled: false,
308
+ memberOfAttribute: "memberOf",
309
+ mappings: [],
310
+ },
311
+ }),
312
+ },
191
313
  ],
192
314
  };
193
315
 
@@ -215,7 +337,7 @@ export default createBackendPlugin({
215
337
  // Helper function to authenticate against LDAP
216
338
  const authenticateLdap = async (
217
339
  username: string,
218
- password: string
340
+ password: string,
219
341
  ): Promise<{
220
342
  success: boolean;
221
343
  userAttributes?: Record<string, unknown>;
@@ -223,7 +345,7 @@ export default createBackendPlugin({
223
345
  }> => {
224
346
  try {
225
347
  // Load LDAP configuration
226
- const ldapConfig = await config.get("ldap", ldapConfigV2, 2);
348
+ const ldapConfig = await config.get("ldap", ldapConfigV3, 3);
227
349
 
228
350
  if (!ldapConfig) {
229
351
  return {
@@ -257,7 +379,7 @@ export default createBackendPlugin({
257
379
  // Step 2: Search for the user
258
380
  const searchFilter = ldapConfig.searchFilter.replace(
259
381
  "{0}",
260
- username
382
+ username,
261
383
  );
262
384
  const searchResult = await client.search(ldapConfig.baseDN, {
263
385
  filter: searchFilter,
@@ -276,7 +398,7 @@ export default createBackendPlugin({
276
398
 
277
399
  if (searchResult.searchEntries.length > 1) {
278
400
  logger.warn(
279
- `Multiple LDAP entries found for username: ${username}`
401
+ `Multiple LDAP entries found for username: ${username}`,
280
402
  );
281
403
  }
282
404
 
@@ -335,9 +457,9 @@ export default createBackendPlugin({
335
457
  // Helper function to create or update user via RPC
336
458
  const syncUser = async (
337
459
  username: string,
338
- ldapAttributes: Record<string, unknown>
460
+ ldapAttributes: Record<string, unknown>,
339
461
  ): Promise<{ userId: string; email: string; name: string }> => {
340
- const ldapConfig = await config.get("ldap", ldapConfigV2, 2);
462
+ const ldapConfig = await config.get("ldap", ldapConfigV3, 3);
341
463
  if (!ldapConfig) {
342
464
  throw new Error("LDAP configuration not found");
343
465
  }
@@ -370,7 +492,46 @@ export default createBackendPlugin({
370
492
  const existingUser = await authClient.findUserByEmail({ email });
371
493
  if (!existingUser) {
372
494
  throw new Error(
373
- "User does not exist and auto-creation is disabled"
495
+ "User does not exist and auto-creation is disabled",
496
+ );
497
+ }
498
+ }
499
+
500
+ // Extract groups and map to roles if enabled
501
+ let syncRoles: string[] | undefined;
502
+ let managedRoleIds: string[] | undefined;
503
+ if (ldapConfig.groupMapping?.enabled) {
504
+ const groups = extractGroups({
505
+ ldapEntry: ldapAttributes,
506
+ memberOfAttribute: ldapConfig.groupMapping.memberOfAttribute,
507
+ });
508
+
509
+ // Map groups to roles
510
+ const mappedRoles = ldapConfig.groupMapping.mappings
511
+ .filter((m) => groups.includes(m.directoryGroup))
512
+ .map((m) => m.checkstackRole);
513
+
514
+ // Add default role if configured
515
+ if (ldapConfig.groupMapping.defaultRole) {
516
+ mappedRoles.push(ldapConfig.groupMapping.defaultRole);
517
+ }
518
+
519
+ // Deduplicate roles
520
+ syncRoles = [...new Set(mappedRoles)];
521
+
522
+ // Collect all managed role IDs (all roles in mappings + default)
523
+ // These are roles controlled by directory - will be removed if user leaves groups
524
+ const allManagedRoles = ldapConfig.groupMapping.mappings.map(
525
+ (m) => m.checkstackRole,
526
+ );
527
+ if (ldapConfig.groupMapping.defaultRole) {
528
+ allManagedRoles.push(ldapConfig.groupMapping.defaultRole);
529
+ }
530
+ managedRoleIds = [...new Set(allManagedRoles)];
531
+
532
+ if (syncRoles.length > 0) {
533
+ logger.debug(
534
+ `LDAP user ${email} will be assigned roles: ${syncRoles.join(", ")}`,
374
535
  );
375
536
  }
376
537
  }
@@ -385,6 +546,8 @@ export default createBackendPlugin({
385
546
  accountId: username,
386
547
  password: hashedPassword,
387
548
  autoUpdateUser: ldapConfig.autoUpdateUsers,
549
+ syncRoles,
550
+ managedRoleIds,
388
551
  });
389
552
 
390
553
  if (created) {
@@ -412,14 +575,14 @@ export default createBackendPlugin({
412
575
 
413
576
  if (!authResult.success) {
414
577
  return redirectToAuthError(
415
- authResult.error || "Authentication failed"
578
+ authResult.error || "Authentication failed",
416
579
  );
417
580
  }
418
581
 
419
582
  // Sync user to database
420
583
  const { userId, email, name } = await syncUser(
421
584
  username,
422
- authResult.userAttributes!
585
+ authResult.userAttributes!,
423
586
  );
424
587
 
425
588
  // Create session via RPC
@@ -452,7 +615,7 @@ export default createBackendPlugin({
452
615
  7 * 24 * 60 * 60
453
616
  }`,
454
617
  },
455
- }
618
+ },
456
619
  );
457
620
  } catch (error) {
458
621
  logger.error("LDAP login error:", error);