@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 +91 -0
- package/package.json +33 -0
- package/src/index.test.ts +272 -0
- package/src/index.ts +471 -0
- package/src/integration.test.ts +251 -0
- package/src/plugin-metadata.ts +9 -0
- package/tsconfig.json +3 -0
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
|
+
});
|
package/tsconfig.json
ADDED