@checkstack/auth-ldap-backend 0.0.2

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 ADDED
@@ -0,0 +1,91 @@
1
+ # @checkstack/auth-ldap-backend
2
+
3
+ ## 0.0.2
4
+
5
+ ### Patch Changes
6
+
7
+ - d20d274: Initial release of all @checkstack packages. Rebranded from Checkmate to Checkstack with new npm organization @checkstack and domain checkstack.dev.
8
+ - Updated dependencies [d20d274]
9
+ - @checkstack/auth-backend@0.0.2
10
+ - @checkstack/auth-common@0.0.2
11
+ - @checkstack/backend-api@0.0.2
12
+ - @checkstack/common@0.0.2
13
+
14
+ ## 0.0.4
15
+
16
+ ### Patch Changes
17
+
18
+ - a65e002: Add compile-time type safety for Lucide icon names
19
+
20
+ - Add `LucideIconName` type and `lucideIconSchema` Zod schema to `@checkstack/common`
21
+ - Update backend interfaces (`AuthStrategy`, `NotificationStrategy`, `IntegrationProvider`, `CommandDefinition`) to use `LucideIconName`
22
+ - Update RPC contracts to use `lucideIconSchema` for proper type inference across RPC boundaries
23
+ - Simplify `SocialProviderButton` to use `DynamicIcon` directly (removes 30+ lines of pascalCase conversion)
24
+ - Replace static `iconMap` in `SearchDialog` with `DynamicIcon` for dynamic icon rendering
25
+ - Add fallback handling in `DynamicIcon` when icon name isn't found
26
+ - Fix legacy kebab-case icon names to PascalCase: `mail`→`Mail`, `send`→`Send`, `github`→`Github`, `key-round`→`KeyRound`, `network`→`Network`, `AlertCircle`→`CircleAlert`
27
+
28
+ - Updated dependencies [b4eb432]
29
+ - Updated dependencies [a65e002]
30
+ - Updated dependencies [a65e002]
31
+ - @checkstack/backend-api@1.1.0
32
+ - @checkstack/common@0.2.0
33
+ - @checkstack/auth-common@0.2.1
34
+ - @checkstack/auth-backend@1.1.0
35
+
36
+ ## 0.0.3
37
+
38
+ ### Patch Changes
39
+
40
+ - Updated dependencies [e26c08e]
41
+ - @checkstack/auth-common@0.2.0
42
+ - @checkstack/auth-backend@1.0.1
43
+
44
+ ## 0.0.2
45
+
46
+ ### Patch Changes
47
+
48
+ - b354ab3: # Strategy Instructions Support & Telegram Notification Plugin
49
+
50
+ ## Strategy Instructions Interface
51
+
52
+ Added `adminInstructions` and `userInstructions` optional fields to the `NotificationStrategy` interface. These allow strategies to export markdown-formatted setup guides that are displayed in the configuration UI:
53
+
54
+ - **`adminInstructions`**: Shown when admins configure platform-wide strategy settings (e.g., how to create API keys)
55
+ - **`userInstructions`**: Shown when users configure their personal settings (e.g., how to link their account)
56
+
57
+ ### Updated Components
58
+
59
+ - `StrategyConfigCard` now accepts an `instructions` prop and renders it before config sections
60
+ - `StrategyCard` passes `adminInstructions` to `StrategyConfigCard`
61
+ - `UserChannelCard` renders `userInstructions` when users need to connect
62
+
63
+ ## New Telegram Notification Plugin
64
+
65
+ Added `@checkstack/notification-telegram-backend` plugin for sending notifications via Telegram:
66
+
67
+ - Uses [grammY](https://grammy.dev/) framework for Telegram Bot API integration
68
+ - Sends messages with MarkdownV2 formatting and inline keyboard buttons for actions
69
+ - Includes comprehensive admin instructions for bot setup via @BotFather
70
+ - Includes user instructions for account linking
71
+
72
+ ### Configuration
73
+
74
+ Admins need to configure a Telegram Bot Token obtained from @BotFather.
75
+
76
+ ### User Linking
77
+
78
+ The strategy uses `contactResolution: { type: "custom" }` for Telegram Login Widget integration. Full frontend integration for the Login Widget is pending future work.
79
+
80
+ - Updated dependencies [ffc28f6]
81
+ - Updated dependencies [71275dd]
82
+ - Updated dependencies [ae19ff6]
83
+ - Updated dependencies [32f2535]
84
+ - Updated dependencies [b55fae6]
85
+ - Updated dependencies [b354ab3]
86
+ - Updated dependencies [8e889b4]
87
+ - Updated dependencies [81f3f85]
88
+ - @checkstack/common@0.1.0
89
+ - @checkstack/backend-api@1.0.0
90
+ - @checkstack/auth-backend@1.0.0
91
+ - @checkstack/auth-common@0.1.0
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@checkstack/auth-ldap-backend",
3
+ "version": "0.0.2",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@checkstack/backend-api": "workspace:*",
14
+ "@checkstack/auth-backend": "workspace:*",
15
+ "@checkstack/auth-common": "workspace:*",
16
+ "better-auth": "^1.4.9",
17
+ "drizzle-orm": "^0.45.1",
18
+ "ldapts": "^8.0.0",
19
+ "zod": "^4.0.0",
20
+ "@checkstack/common": "workspace:*"
21
+ },
22
+ "devDependencies": {
23
+ "@checkstack/tsconfig": "workspace:*",
24
+ "@checkstack/test-utils-backend": "workspace:*",
25
+ "typescript": "^5.7.2"
26
+ },
27
+ "plugin": {
28
+ "id": "auth-ldap-backend",
29
+ "name": "@checkstack/auth-ldap-backend",
30
+ "type": "backend",
31
+ "displayName": "LDAP Authentication (Backend)"
32
+ }
33
+ }
@@ -0,0 +1,272 @@
1
+ import { describe, expect, it, beforeEach, mock } from "bun:test";
2
+ import type { Client as LdapClient } from "ldapts";
3
+
4
+ describe("LDAP Authentication Strategy", () => {
5
+ // Mock LDAP client
6
+ let mockLdapClient: {
7
+ bind: ReturnType<typeof mock>;
8
+ search: ReturnType<typeof mock>;
9
+ unbind: ReturnType<typeof mock>;
10
+ };
11
+
12
+ beforeEach(() => {
13
+ mockLdapClient = {
14
+ bind: mock(() => Promise.resolve()),
15
+ search: mock(() =>
16
+ Promise.resolve({
17
+ searchEntries: [
18
+ {
19
+ dn: "uid=testuser,ou=users,dc=example,dc=com",
20
+ uid: "testuser",
21
+ mail: "testuser@example.com",
22
+ displayName: "Test User",
23
+ givenName: "Test",
24
+ sn: "User",
25
+ },
26
+ ],
27
+ })
28
+ ),
29
+ unbind: mock(() => Promise.resolve()),
30
+ };
31
+ });
32
+
33
+ describe("LDAP Client Authentication", () => {
34
+ it("should successfully authenticate with valid credentials", async () => {
35
+ // Simulate successful bind
36
+ mockLdapClient.bind.mockResolvedValue(undefined);
37
+
38
+ await mockLdapClient.bind("cn=admin,dc=example,dc=com", "adminPassword");
39
+ expect(mockLdapClient.bind).toHaveBeenCalledWith(
40
+ "cn=admin,dc=example,dc=com",
41
+ "adminPassword"
42
+ );
43
+ });
44
+
45
+ it("should fail authentication with invalid credentials", async () => {
46
+ // Simulate failed bind
47
+ mockLdapClient.bind.mockRejectedValue(new Error("Invalid credentials"));
48
+
49
+ await expect(
50
+ mockLdapClient.bind("uid=testuser,ou=users,dc=example,dc=com", "wrong")
51
+ ).rejects.toThrow("Invalid credentials");
52
+ });
53
+
54
+ it("should search for user in LDAP directory", async () => {
55
+ const searchResult = await mockLdapClient.search();
56
+
57
+ expect(mockLdapClient.search).toHaveBeenCalled();
58
+ expect(searchResult.searchEntries).toHaveLength(1);
59
+ expect(searchResult.searchEntries[0].uid).toBe("testuser");
60
+ expect(searchResult.searchEntries[0].mail).toBe("testuser@example.com");
61
+ });
62
+
63
+ it("should return empty result when user not found", async () => {
64
+ mockLdapClient.search.mockResolvedValue({
65
+ searchEntries: [],
66
+ });
67
+
68
+ const searchResult = await mockLdapClient.search();
69
+ expect(searchResult.searchEntries).toHaveLength(0);
70
+ });
71
+
72
+ it("should extract user attributes from LDAP entry", async () => {
73
+ const searchResult = await mockLdapClient.search();
74
+ const userEntry = searchResult.searchEntries[0];
75
+
76
+ expect(userEntry.mail).toBe("testuser@example.com");
77
+ expect(userEntry.displayName).toBe("Test User");
78
+ expect(userEntry.givenName).toBe("Test");
79
+ expect(userEntry.sn).toBe("User");
80
+ });
81
+ });
82
+
83
+ describe("HTTP Login Endpoint", () => {
84
+ it("should return 400 when username is missing", async () => {
85
+ const request = new Request("http://localhost/api/auth-ldap/", {
86
+ method: "POST",
87
+ headers: { "Content-Type": "application/json" },
88
+ body: JSON.stringify({ password: "test123" }),
89
+ });
90
+
91
+ // We'll test this with the actual handler in integration tests
92
+ const body = await request.json();
93
+ expect(body.password).toBe("test123");
94
+ expect(body.username).toBeUndefined();
95
+ });
96
+
97
+ it("should return 400 when password is missing", async () => {
98
+ const request = new Request("http://localhost/api/auth-ldap/", {
99
+ method: "POST",
100
+ headers: { "Content-Type": "application/json" },
101
+ body: JSON.stringify({ username: "testuser" }),
102
+ });
103
+
104
+ const body = await request.json();
105
+ expect(body.username).toBe("testuser");
106
+ expect(body.password).toBeUndefined();
107
+ });
108
+
109
+ it("should parse valid login request body", async () => {
110
+ const request = new Request("http://localhost/api/auth-ldap/", {
111
+ method: "POST",
112
+ headers: { "Content-Type": "application/json" },
113
+ body: JSON.stringify({
114
+ username: "testuser",
115
+ password: "test123",
116
+ }),
117
+ });
118
+
119
+ const body = await request.json();
120
+ expect(body.username).toBe("testuser");
121
+ expect(body.password).toBe("test123");
122
+ });
123
+ });
124
+
125
+ describe("User Attribute Mapping", () => {
126
+ it("should map email attribute correctly", () => {
127
+ const ldapAttributes = {
128
+ mail: "user@example.com",
129
+ userPrincipalName: "user@domain.com",
130
+ };
131
+
132
+ const emailMapping = "mail";
133
+ const email = ldapAttributes[emailMapping];
134
+ expect(email).toBe("user@example.com");
135
+ });
136
+
137
+ it("should map displayName attribute correctly", () => {
138
+ const ldapAttributes = {
139
+ displayName: "John Doe",
140
+ cn: "johndoe",
141
+ };
142
+
143
+ const nameMapping = "displayName";
144
+ const name = ldapAttributes[nameMapping];
145
+ expect(name).toBe("John Doe");
146
+ });
147
+
148
+ it("should build name from firstName and lastName", () => {
149
+ const ldapAttributes = {
150
+ givenName: "John",
151
+ sn: "Doe",
152
+ };
153
+
154
+ const firstName = ldapAttributes.givenName;
155
+ const lastName = ldapAttributes.sn;
156
+ const fullName = `${firstName} ${lastName}`;
157
+ expect(fullName).toBe("John Doe");
158
+ });
159
+
160
+ it("should handle missing optional attributes gracefully", () => {
161
+ const ldapAttributes: Record<string, string | undefined> = {
162
+ uid: "testuser",
163
+ mail: "test@example.com",
164
+ // displayName is missing
165
+ };
166
+
167
+ const displayName =
168
+ ldapAttributes["displayName"] || ldapAttributes["uid"];
169
+ expect(displayName).toBe("testuser");
170
+ });
171
+ });
172
+
173
+ describe("Search Filter Templates", () => {
174
+ it("should replace {0} placeholder with username", () => {
175
+ const searchFilter = "(uid={0})";
176
+ const username = "testuser";
177
+ const result = searchFilter.replace("{0}", username);
178
+ expect(result).toBe("(uid=testuser)");
179
+ });
180
+
181
+ it("should work with sAMAccountName filter for Active Directory", () => {
182
+ const searchFilter = "(sAMAccountName={0})";
183
+ const username = "jdoe";
184
+ const result = searchFilter.replace("{0}", username);
185
+ expect(result).toBe("(sAMAccountName=jdoe)");
186
+ });
187
+
188
+ it("should work with email filter", () => {
189
+ const searchFilter = "(mail={0})";
190
+ const email = "user@example.com";
191
+ const result = searchFilter.replace("{0}", email);
192
+ expect(result).toBe("(mail=user@example.com)");
193
+ });
194
+
195
+ it("should work with complex AND filter", () => {
196
+ const searchFilter = "(&(uid={0})(objectClass=person))";
197
+ const username = "testuser";
198
+ const result = searchFilter.replace("{0}", username);
199
+ expect(result).toBe("(&(uid=testuser)(objectClass=person))");
200
+ });
201
+ });
202
+
203
+ describe("Configuration Validation", () => {
204
+ it("should validate required URL field", () => {
205
+ const config = {
206
+ enabled: true,
207
+ url: "",
208
+ baseDN: "ou=users,dc=example,dc=com",
209
+ };
210
+
211
+ expect(config.url).toBe("");
212
+ // URL validation would happen in Zod schema
213
+ });
214
+
215
+ it("should validate baseDN format", () => {
216
+ const validBaseDN = "ou=users,dc=example,dc=com";
217
+ expect(validBaseDN).toContain("dc=");
218
+ expect(validBaseDN).toContain("ou=");
219
+ });
220
+
221
+ it("should have sensible default for timeout", () => {
222
+ const defaultTimeout = 5000;
223
+ expect(defaultTimeout).toBe(5000);
224
+ expect(defaultTimeout).toBeGreaterThan(0);
225
+ });
226
+
227
+ it("should default autoCreateUsers to true", () => {
228
+ const defaultAutoCreate = true;
229
+ expect(defaultAutoCreate).toBe(true);
230
+ });
231
+
232
+ it("should default autoUpdateUsers to true", () => {
233
+ const defaultAutoUpdate = true;
234
+ expect(defaultAutoUpdate).toBe(true);
235
+ });
236
+ });
237
+
238
+ describe("Array Attribute Handling", () => {
239
+ it("should take first value from array attributes", () => {
240
+ const ldapEntry = {
241
+ mail: ["primary@example.com", "secondary@example.com"],
242
+ cn: ["John Doe"],
243
+ };
244
+
245
+ // Simulate taking first value
246
+ const email = Array.isArray(ldapEntry.mail)
247
+ ? ldapEntry.mail[0]
248
+ : ldapEntry.mail;
249
+ const name = Array.isArray(ldapEntry.cn) ? ldapEntry.cn[0] : ldapEntry.cn;
250
+
251
+ expect(email).toBe("primary@example.com");
252
+ expect(name).toBe("John Doe");
253
+ });
254
+
255
+ it("should handle string attributes without modification", () => {
256
+ const ldapEntry = {
257
+ uid: "testuser",
258
+ mail: "test@example.com",
259
+ };
260
+
261
+ const uid = Array.isArray(ldapEntry.uid)
262
+ ? ldapEntry.uid[0]
263
+ : ldapEntry.uid;
264
+ const mail = Array.isArray(ldapEntry.mail)
265
+ ? ldapEntry.mail[0]
266
+ : ldapEntry.mail;
267
+
268
+ expect(uid).toBe("testuser");
269
+ expect(mail).toBe("test@example.com");
270
+ });
271
+ });
272
+ });
package/src/index.ts ADDED
@@ -0,0 +1,471 @@
1
+ import {
2
+ createBackendPlugin,
3
+ type AuthStrategy,
4
+ configString,
5
+ coreServices,
6
+ configBoolean,
7
+ configNumber,
8
+ } from "@checkstack/backend-api";
9
+ import { pluginMetadata } from "./plugin-metadata";
10
+ import {
11
+ betterAuthExtensionPoint,
12
+ redirectToAuthError,
13
+ } from "@checkstack/auth-backend";
14
+ import { AuthApi } from "@checkstack/auth-common";
15
+ import { z } from "zod";
16
+ import { Client as LdapClient } from "ldapts";
17
+ import { hashPassword } from "better-auth/crypto";
18
+
19
+ // LDAP Configuration Schema V1
20
+ const _ldapConfigV1 = z.object({
21
+ enabled: configBoolean({})
22
+ .default(false)
23
+ .describe("Enable LDAP authentication"),
24
+ url: configString({})
25
+ .url()
26
+ .default("ldaps://ldap.example.com:636")
27
+ .describe("LDAP server URL (e.g., ldaps://ldap.example.com:636)"),
28
+ bindDN: configString({})
29
+ .optional()
30
+ .describe(
31
+ "Service account DN for searching (e.g., cn=admin,dc=example,dc=com)"
32
+ ),
33
+ bindPassword: configString({ "x-secret": true })
34
+ .describe("Service account password")
35
+ .optional(),
36
+ baseDN: configString({})
37
+ .default("ou=users,dc=example,dc=com")
38
+ .describe("Base DN for user searches (e.g., ou=users,dc=example,dc=com)"),
39
+ searchFilter: configString({})
40
+ .default("(uid={0})")
41
+ .describe("LDAP search filter, {0} will be replaced with username"),
42
+ usernameAttribute: configString({})
43
+ .default("uid")
44
+ .describe("LDAP attribute to match against login username"),
45
+ attributeMapping: z
46
+ .object({
47
+ email: configString({})
48
+ .default("mail")
49
+ .describe("LDAP attribute for email address"),
50
+ name: configString({})
51
+ .default("displayName")
52
+ .describe("LDAP attribute for display name"),
53
+ firstName: configString({})
54
+ .default("givenName")
55
+ .describe("LDAP attribute for first name")
56
+ .optional(),
57
+ lastName: configString({})
58
+ .default("sn")
59
+ .describe("LDAP attribute for last name")
60
+ .optional(),
61
+ })
62
+ .default({
63
+ email: "mail",
64
+ name: "displayName",
65
+ })
66
+ .describe("Map LDAP attributes to user fields"),
67
+ tlsOptions: z
68
+ .object({
69
+ rejectUnauthorized: configBoolean({})
70
+ .default(true)
71
+ .describe("Reject unauthorized SSL certificates"),
72
+ ca: configString({ "x-secret": true })
73
+ .describe("Custom CA certificate (PEM format)")
74
+ .optional(),
75
+ })
76
+ .default({ rejectUnauthorized: true })
77
+ .describe("TLS/SSL configuration"),
78
+ timeout: configNumber({})
79
+ .default(5000)
80
+ .describe("Connection timeout in milliseconds"),
81
+ autoCreateUsers: configBoolean({})
82
+ .default(true)
83
+ .describe("Automatically create users on first login"),
84
+ autoUpdateUsers: configBoolean({})
85
+ .default(true)
86
+ .describe("Update user attributes on each login"),
87
+ });
88
+
89
+ // LDAP Configuration Schema V1
90
+ const ldapConfigV2 = z.object({
91
+ url: configString({})
92
+ .url()
93
+ .default("ldaps://ldap.example.com:636")
94
+ .describe("LDAP server URL (e.g., ldaps://ldap.example.com:636)"),
95
+ bindDN: configString({})
96
+ .optional()
97
+ .describe(
98
+ "Service account DN for searching (e.g., cn=admin,dc=example,dc=com)"
99
+ ),
100
+ bindPassword: configString({ "x-secret": true })
101
+ .describe("Service account password")
102
+ .optional(),
103
+ baseDN: configString({})
104
+ .default("ou=users,dc=example,dc=com")
105
+ .describe("Base DN for user searches (e.g., ou=users,dc=example,dc=com)"),
106
+ searchFilter: configString({})
107
+ .default("(uid={0})")
108
+ .describe("LDAP search filter, {0} will be replaced with username"),
109
+ usernameAttribute: configString({})
110
+ .default("uid")
111
+ .describe("LDAP attribute to match against login username"),
112
+ attributeMapping: z
113
+ .object({
114
+ email: configString({})
115
+ .default("mail")
116
+ .describe("LDAP attribute for email address"),
117
+ name: configString({})
118
+ .default("displayName")
119
+ .describe("LDAP attribute for display name"),
120
+ firstName: configString({})
121
+ .default("givenName")
122
+ .describe("LDAP attribute for first name")
123
+ .optional(),
124
+ lastName: configString({})
125
+ .default("sn")
126
+ .describe("LDAP attribute for last name")
127
+ .optional(),
128
+ })
129
+ .default({
130
+ email: "mail",
131
+ name: "displayName",
132
+ })
133
+ .describe("Map LDAP attributes to user fields"),
134
+ tlsOptions: z
135
+ .object({
136
+ rejectUnauthorized: configBoolean({})
137
+ .default(true)
138
+ .describe("Reject unauthorized SSL certificates"),
139
+ ca: configString({ "x-secret": true })
140
+ .describe("Custom CA certificate (PEM format)")
141
+ .optional(),
142
+ })
143
+ .default({ rejectUnauthorized: true })
144
+ .describe("TLS/SSL configuration"),
145
+ timeout: configNumber({})
146
+ .default(5000)
147
+ .describe("Connection timeout in milliseconds"),
148
+ autoCreateUsers: configBoolean({})
149
+ .default(true)
150
+ .describe("Automatically create users on first login"),
151
+ autoUpdateUsers: configBoolean({})
152
+ .default(true)
153
+ .describe("Update user attributes on each login"),
154
+ });
155
+
156
+ type LdapConfig = z.infer<typeof ldapConfigV2>;
157
+
158
+ // LDAP Strategy Definition
159
+ const ldapStrategy: AuthStrategy<LdapConfig> = {
160
+ id: "ldap",
161
+ displayName: "LDAP",
162
+ description: "Authenticate using LDAP directory",
163
+ icon: "Network",
164
+ configVersion: 2,
165
+ configSchema: ldapConfigV2,
166
+ requiresManualRegistration: false,
167
+ adminInstructions: `
168
+ ## LDAP Configuration
169
+
170
+ Configure LDAP authentication to allow users from your directory to sign in:
171
+
172
+ 1. Enter your LDAP server **URL** (e.g., \`ldaps://ldap.example.com:636\`)
173
+ 2. If your server requires bind authentication, provide **Bind DN** and **password**
174
+ 3. Set the **Base DN** where user accounts are located
175
+ 4. Configure the **search filter** to match usernames (e.g., \`(uid={0})\` or \`(sAMAccountName={0})\`)
176
+ 5. Map **LDAP attributes** to user fields (email, name)
177
+
178
+ > **Active Directory**: Use \`ldaps://\` with port 636, \`sAMAccountName\` for username, and \`userPrincipalName\` for email.
179
+ `.trim(),
180
+ migrations: [
181
+ {
182
+ description: "Migrate LDAP configuration to version 2",
183
+ fromVersion: 1,
184
+ toVersion: 2,
185
+ migrate: (oldConfig: z.infer<typeof _ldapConfigV1>) => {
186
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
187
+ const { enabled, ...rest } = oldConfig;
188
+ return rest;
189
+ },
190
+ },
191
+ ],
192
+ };
193
+
194
+ export default createBackendPlugin({
195
+ metadata: pluginMetadata,
196
+ register(env) {
197
+ // Register the LDAP strategy
198
+ const extensionPoint = env.getExtensionPoint(betterAuthExtensionPoint);
199
+ extensionPoint.addStrategy(ldapStrategy);
200
+
201
+ // Register init logic for custom login endpoint
202
+ env.registerInit({
203
+ deps: {
204
+ rpc: coreServices.rpc,
205
+ logger: coreServices.logger,
206
+ config: coreServices.config,
207
+ rpcClient: coreServices.rpcClient,
208
+ },
209
+ init: async ({ rpc, logger, config, rpcClient }) => {
210
+ logger.debug("[auth-ldap-backend] Initializing LDAP authentication...");
211
+
212
+ // Create auth client once for reuse
213
+ const authClient = rpcClient.forPlugin(AuthApi);
214
+
215
+ // Helper function to authenticate against LDAP
216
+ const authenticateLdap = async (
217
+ username: string,
218
+ password: string
219
+ ): Promise<{
220
+ success: boolean;
221
+ userAttributes?: Record<string, unknown>;
222
+ error?: string;
223
+ }> => {
224
+ try {
225
+ // Load LDAP configuration
226
+ const ldapConfig = await config.get("ldap", ldapConfigV2, 2);
227
+
228
+ if (!ldapConfig) {
229
+ return {
230
+ success: false,
231
+ error: "LDAP authentication is not enabled",
232
+ };
233
+ }
234
+
235
+ // Create LDAP client
236
+ const client = new LdapClient({
237
+ url: ldapConfig.url,
238
+ timeout: ldapConfig.timeout,
239
+ tlsOptions: ldapConfig.tlsOptions.ca
240
+ ? {
241
+ rejectUnauthorized:
242
+ ldapConfig.tlsOptions.rejectUnauthorized,
243
+ ca: ldapConfig.tlsOptions.ca,
244
+ }
245
+ : {
246
+ rejectUnauthorized:
247
+ ldapConfig.tlsOptions.rejectUnauthorized,
248
+ },
249
+ });
250
+
251
+ try {
252
+ // Step 1: Bind with service account (if configured)
253
+ if (ldapConfig.bindDN && ldapConfig.bindPassword) {
254
+ await client.bind(ldapConfig.bindDN, ldapConfig.bindPassword);
255
+ }
256
+
257
+ // Step 2: Search for the user
258
+ const searchFilter = ldapConfig.searchFilter.replace(
259
+ "{0}",
260
+ username
261
+ );
262
+ const searchResult = await client.search(ldapConfig.baseDN, {
263
+ filter: searchFilter,
264
+ scope: "sub",
265
+ });
266
+
267
+ if (
268
+ !searchResult.searchEntries ||
269
+ searchResult.searchEntries.length === 0
270
+ ) {
271
+ return {
272
+ success: false,
273
+ error: "User not found in LDAP directory",
274
+ };
275
+ }
276
+
277
+ if (searchResult.searchEntries.length > 1) {
278
+ logger.warn(
279
+ `Multiple LDAP entries found for username: ${username}`
280
+ );
281
+ }
282
+
283
+ const userEntry = searchResult.searchEntries[0];
284
+ const userDN = userEntry.dn;
285
+
286
+ // Step 3: Try to bind as the user to verify password
287
+ const userClient = new LdapClient({
288
+ url: ldapConfig.url,
289
+ timeout: ldapConfig.timeout,
290
+ tlsOptions: ldapConfig.tlsOptions.ca
291
+ ? {
292
+ rejectUnauthorized:
293
+ ldapConfig.tlsOptions.rejectUnauthorized,
294
+ ca: ldapConfig.tlsOptions.ca,
295
+ }
296
+ : {
297
+ rejectUnauthorized:
298
+ ldapConfig.tlsOptions.rejectUnauthorized,
299
+ },
300
+ });
301
+
302
+ try {
303
+ await userClient.bind(userDN, password);
304
+ } catch (bindError) {
305
+ logger.debug(`LDAP bind failed for user ${userDN}:`, bindError);
306
+ return { success: false, error: "Invalid credentials" };
307
+ } finally {
308
+ await userClient.unbind();
309
+ }
310
+
311
+ // Step 4: Extract user attributes
312
+ const attributes: Record<string, unknown> = {};
313
+ for (const [key, value] of Object.entries(userEntry)) {
314
+ if (typeof value === "string" || typeof value === "number") {
315
+ attributes[key] = value;
316
+ } else if (Array.isArray(value) && value.length > 0) {
317
+ // Take first value for arrays
318
+ attributes[key] = value[0];
319
+ }
320
+ }
321
+
322
+ return { success: true, userAttributes: attributes };
323
+ } finally {
324
+ await client.unbind();
325
+ }
326
+ } catch (error) {
327
+ logger.error("LDAP authentication error:", error);
328
+ return {
329
+ success: false,
330
+ error: error instanceof Error ? error.message : "Unknown error",
331
+ };
332
+ }
333
+ };
334
+
335
+ // Helper function to create or update user via RPC
336
+ const syncUser = async (
337
+ username: string,
338
+ ldapAttributes: Record<string, unknown>
339
+ ): Promise<{ userId: string; email: string; name: string }> => {
340
+ const ldapConfig = await config.get("ldap", ldapConfigV2, 2);
341
+ if (!ldapConfig) {
342
+ throw new Error("LDAP configuration not found");
343
+ }
344
+
345
+ // Extract user info from LDAP attributes
346
+ const mapping = ldapConfig.attributeMapping;
347
+ const email =
348
+ (ldapAttributes[mapping.email] as string | undefined) ||
349
+ `${username}@ldap.local`;
350
+
351
+ // Build name from available attributes
352
+ let name: string;
353
+ if (ldapAttributes[mapping.name]) {
354
+ name = ldapAttributes[mapping.name] as string;
355
+ } else if (
356
+ mapping.firstName &&
357
+ mapping.lastName &&
358
+ ldapAttributes[mapping.firstName] &&
359
+ ldapAttributes[mapping.lastName]
360
+ ) {
361
+ name = `${ldapAttributes[mapping.firstName]} ${
362
+ ldapAttributes[mapping.lastName]
363
+ }`;
364
+ } else {
365
+ name = username;
366
+ }
367
+
368
+ // Check if auto-creation is disabled and user doesn't exist
369
+ if (!ldapConfig.autoCreateUsers) {
370
+ const existingUser = await authClient.findUserByEmail({ email });
371
+ if (!existingUser) {
372
+ throw new Error(
373
+ "User does not exist and auto-creation is disabled"
374
+ );
375
+ }
376
+ }
377
+
378
+ // Use RPC to upsert user (handles registration check, user/account creation)
379
+ const hashedPassword = await hashPassword(crypto.randomUUID()); // Random password, won't be used
380
+
381
+ const { userId, created } = await authClient.upsertExternalUser({
382
+ email,
383
+ name,
384
+ providerId: "ldap",
385
+ accountId: username,
386
+ password: hashedPassword,
387
+ autoUpdateUser: ldapConfig.autoUpdateUsers,
388
+ });
389
+
390
+ if (created) {
391
+ logger.info(`Created new user from LDAP: ${email}`);
392
+ } else if (ldapConfig.autoUpdateUsers) {
393
+ logger.debug(`Updated LDAP user: ${email}`);
394
+ }
395
+
396
+ return { userId, email, name };
397
+ };
398
+
399
+ // Register custom HTTP handler for LDAP login
400
+ // Path /ldap/login will resolve to /api/auth-ldap/ldap/login
401
+ rpc.registerHttpHandler(async (req: Request) => {
402
+ try {
403
+ const body = await req.json();
404
+ const { username, password } = body;
405
+
406
+ if (!username || !password) {
407
+ return redirectToAuthError("Username and password are required");
408
+ }
409
+
410
+ // Authenticate with LDAP
411
+ const authResult = await authenticateLdap(username, password);
412
+
413
+ if (!authResult.success) {
414
+ return redirectToAuthError(
415
+ authResult.error || "Authentication failed"
416
+ );
417
+ }
418
+
419
+ // Sync user to database
420
+ const { userId, email, name } = await syncUser(
421
+ username,
422
+ authResult.userAttributes!
423
+ );
424
+
425
+ // Create session via RPC
426
+ const sessionToken = crypto.randomUUID();
427
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
428
+
429
+ await authClient.createSession({
430
+ userId,
431
+ token: sessionToken,
432
+ expiresAt,
433
+ });
434
+
435
+ logger.info(`Created session for LDAP user: ${email}`);
436
+
437
+ // Return session token in cookie format
438
+ return Response.json(
439
+ {
440
+ success: true,
441
+ user: {
442
+ id: userId,
443
+ email,
444
+ name,
445
+ },
446
+ },
447
+ {
448
+ status: 200,
449
+ headers: {
450
+ "Content-Type": "application/json",
451
+ "Set-Cookie": `better-auth.session_token=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${
452
+ 7 * 24 * 60 * 60
453
+ }`,
454
+ },
455
+ }
456
+ );
457
+ } catch (error) {
458
+ logger.error("LDAP login error:", error);
459
+ const message =
460
+ error instanceof Error
461
+ ? error.message
462
+ : "Authentication failed. Please try again.";
463
+ return redirectToAuthError(message);
464
+ }
465
+ });
466
+
467
+ logger.debug("✅ LDAP authentication initialized");
468
+ },
469
+ });
470
+ },
471
+ });
@@ -0,0 +1,251 @@
1
+ import { describe, expect, it, beforeEach, mock } from "bun:test";
2
+
3
+ describe("LDAP Authentication Integration Tests", () => {
4
+ // Mock LDAP client responses
5
+ const mockLdapBind = mock(() => Promise.resolve());
6
+ const mockLdapSearch = mock(() =>
7
+ Promise.resolve({
8
+ searchEntries: [
9
+ {
10
+ dn: "uid=testuser,ou=users,dc=example,dc=com",
11
+ uid: ["testuser"],
12
+ mail: ["testuser@example.com"],
13
+ displayName: ["Test User"],
14
+ givenName: ["Test"],
15
+ sn: ["User"],
16
+ },
17
+ ],
18
+ })
19
+ );
20
+ const mockLdapUnbind = mock(() => Promise.resolve());
21
+
22
+ beforeEach(() => {
23
+ // Reset mocks
24
+ mockLdapBind.mockClear();
25
+ mockLdapSearch.mockClear();
26
+ mockLdapUnbind.mockClear();
27
+
28
+ // Setup default successful responses
29
+ mockLdapBind.mockResolvedValue(undefined);
30
+ mockLdapSearch.mockResolvedValue({
31
+ searchEntries: [
32
+ {
33
+ dn: "uid=testuser,ou=users,dc=example,dc=com",
34
+ uid: ["testuser"],
35
+ mail: ["testuser@example.com"],
36
+ displayName: ["Test User"],
37
+ givenName: ["Test"],
38
+ sn: ["User"],
39
+ },
40
+ ],
41
+ });
42
+ });
43
+
44
+ describe("Full LDAP Authentication Flow", () => {
45
+ it("should authenticate user via LDAP", async () => {
46
+ // Simulate authentication
47
+ const username = "testuser";
48
+ const password = "correct-password";
49
+
50
+ // 1. Search for user
51
+ const searchResult = await mockLdapSearch();
52
+ expect(searchResult.searchEntries).toHaveLength(1);
53
+ const userEntry = searchResult.searchEntries[0];
54
+
55
+ // 2. Bind as user
56
+ await mockLdapBind();
57
+
58
+ // 3. Extract attributes
59
+ const email = Array.isArray(userEntry.mail)
60
+ ? userEntry.mail[0]
61
+ : userEntry.mail;
62
+ const name = Array.isArray(userEntry.displayName)
63
+ ? userEntry.displayName[0]
64
+ : userEntry.displayName;
65
+
66
+ expect(email).toBe("testuser@example.com");
67
+ expect(name).toBe("Test User");
68
+ });
69
+
70
+ it("should fail authentication with invalid LDAP credentials", async () => {
71
+ // Mock LDAP bind failure
72
+ mockLdapBind.mockRejectedValueOnce(new Error("Invalid Credentials"));
73
+
74
+ await expect(mockLdapBind()).rejects.toThrow("Invalid Credentials");
75
+ });
76
+
77
+ it("should fail when user not found in LDAP directory", async () => {
78
+ // Mock empty search result
79
+ mockLdapSearch.mockResolvedValueOnce({
80
+ searchEntries: [],
81
+ });
82
+
83
+ const searchResult = await mockLdapSearch();
84
+ expect(searchResult.searchEntries).toHaveLength(0);
85
+ });
86
+
87
+ it("should handle LDAP connection errors gracefully", async () => {
88
+ // Mock connection error
89
+ mockLdapBind.mockRejectedValueOnce(new Error("Connection refused"));
90
+
91
+ await expect(mockLdapBind()).rejects.toThrow("Connection refused");
92
+ });
93
+ });
94
+
95
+ describe("HTTP Login Endpoint Integration", () => {
96
+ it("should return session cookie format on successful authentication", async () => {
97
+ // Simulate successful login
98
+ const searchResult = await mockLdapSearch();
99
+ await mockLdapBind();
100
+
101
+ const sessionToken = "mock-session-token-12345";
102
+ const maxAge = 7 * 24 * 60 * 60; // 7 days in seconds
103
+ const expectedCookie = `better-auth.session_token=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`;
104
+
105
+ expect(expectedCookie).toContain("better-auth.session_token");
106
+ expect(expectedCookie).toContain("HttpOnly");
107
+ expect(expectedCookie).toContain("SameSite=Lax");
108
+ expect(expectedCookie).toContain(`Max-Age=${maxAge}`);
109
+ });
110
+
111
+ it("should return 401 on authentication failure", async () => {
112
+ mockLdapBind.mockRejectedValueOnce(new Error("Invalid Credentials"));
113
+
114
+ try {
115
+ await mockLdapBind();
116
+ expect(true).toBe(false); // Should not reach here
117
+ } catch (error) {
118
+ expect(error).toBeInstanceOf(Error);
119
+ expect((error as Error).message).toBe("Invalid Credentials");
120
+ }
121
+ });
122
+
123
+ it("should return user info structure on successful login", async () => {
124
+ const searchResult = await mockLdapSearch();
125
+ const userEntry = searchResult.searchEntries[0];
126
+
127
+ const email = Array.isArray(userEntry.mail)
128
+ ? userEntry.mail[0]
129
+ : userEntry.mail;
130
+ const name = Array.isArray(userEntry.displayName)
131
+ ? userEntry.displayName[0]
132
+ : userEntry.displayName;
133
+
134
+ const response = {
135
+ success: true,
136
+ user: {
137
+ id: "user-123",
138
+ email,
139
+ name,
140
+ },
141
+ };
142
+
143
+ expect(response.success).toBe(true);
144
+ expect(response.user.email).toBe("testuser@example.com");
145
+ expect(response.user.name).toBe("Test User");
146
+ });
147
+ });
148
+
149
+ describe("Attribute Mapping Configuration", () => {
150
+ it("should use custom email attribute mapping (userPrincipalName)", async () => {
151
+ // Mock LDAP entry with userPrincipalName instead of mail
152
+ type CustomLdapEntry = {
153
+ dn: string;
154
+ userPrincipalName: string[];
155
+ displayName: string[];
156
+ };
157
+
158
+ // @ts-expect-error - Mock data doesn't need to match full type
159
+ mockLdapSearch.mockResolvedValueOnce({
160
+ searchEntries: [
161
+ {
162
+ dn: "CN=Test User,OU=Users,DC=example,DC=com",
163
+ userPrincipalName: ["testuser@domain.com"],
164
+ displayName: ["Test User"],
165
+ } as unknown,
166
+ ],
167
+ } as unknown);
168
+
169
+ const searchResult = await mockLdapSearch();
170
+ const userEntry = searchResult.searchEntries[0] as Record<
171
+ string,
172
+ unknown
173
+ >;
174
+
175
+ // Simulate custom mapping
176
+ const emailMapping = "userPrincipalName";
177
+ const emailValue = userEntry[emailMapping] as string[] | string;
178
+ const email = Array.isArray(emailValue) ? emailValue[0] : emailValue;
179
+
180
+ expect(email).toBe("testuser@domain.com");
181
+ });
182
+
183
+ it("should use custom name attribute mapping (cn)", async () => {
184
+ type CustomLdapEntry = {
185
+ dn: string;
186
+ cn: string[];
187
+ mail: string[];
188
+ };
189
+
190
+ // @ts-expect-error - Mock data doesn't need to match full type
191
+ mockLdapSearch.mockResolvedValueOnce({
192
+ searchEntries: [
193
+ {
194
+ dn: "uid=testuser,ou=users,dc=example,dc=com",
195
+ cn: ["Test User CN"],
196
+ mail: ["test@example.com"],
197
+ } as unknown,
198
+ ],
199
+ } as unknown);
200
+
201
+ const searchResult = await mockLdapSearch();
202
+ const userEntry = searchResult.searchEntries[0] as Record<
203
+ string,
204
+ unknown
205
+ >;
206
+
207
+ // Simulate custom mapping to cn instead of displayName
208
+ const nameMapping = "cn";
209
+ const nameValue = userEntry[nameMapping] as string[] | string;
210
+ const name = Array.isArray(nameValue) ? nameValue[0] : nameValue;
211
+
212
+ expect(name).toBe("Test User CN");
213
+ });
214
+
215
+ it("should construct name from firstName and lastName attributes", async () => {
216
+ const searchResult = await mockLdapSearch();
217
+ const userEntry = searchResult.searchEntries[0];
218
+
219
+ const firstNameValue = userEntry.givenName;
220
+ const lastNameValue = userEntry.sn;
221
+
222
+ const firstName = Array.isArray(firstNameValue)
223
+ ? firstNameValue[0]
224
+ : firstNameValue;
225
+ const lastName = Array.isArray(lastNameValue)
226
+ ? lastNameValue[0]
227
+ : lastNameValue;
228
+ const fullName = `${firstName} ${lastName}`.trim();
229
+
230
+ expect(fullName).toBe("Test User");
231
+ });
232
+ });
233
+
234
+ describe("TLS/SSL Configuration", () => {
235
+ it("should connect with TLS when using ldaps:// URL", () => {
236
+ const url = "ldaps://ldap.example.com:636";
237
+ expect(url).toContain("ldaps://");
238
+ expect(url).toContain(":636");
239
+ });
240
+
241
+ it("should allow custom CA certificate", () => {
242
+ const caCertificate = "-----BEGIN CERTIFICATE-----\\nMIID...";
243
+ expect(caCertificate).toContain("BEGIN CERTIFICATE");
244
+ });
245
+
246
+ it("should respect rejectUnauthorized setting", () => {
247
+ const rejectUnauthorized = false;
248
+ expect(rejectUnauthorized).toBe(false);
249
+ });
250
+ });
251
+ });
@@ -0,0 +1,9 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the Auth LDAP backend.
5
+ * This is the single source of truth for the plugin ID.
6
+ */
7
+ export const pluginMetadata = definePluginMetadata({
8
+ pluginId: "auth-ldap",
9
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }