ldap-authentication 2.3.1 → 2.3.3

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.
@@ -0,0 +1,5 @@
1
+ FROM mcr.microsoft.com/devcontainers/typescript-node:18
2
+
3
+ RUN ln -s /usr/bin/gpg /usr/local/bin/gpg
4
+ RUN apt update && apt install ldap-utils -y
5
+
@@ -0,0 +1,24 @@
1
+ // For format details, see https://aka.ms/devcontainer.json. For config options, see the
2
+ // README at: https://github.com/devcontainers/templates/tree/main/src/postgres
3
+ {
4
+ "name": "ldap-authentication",
5
+ "dockerComposeFile": ["./docker-compose.yml"],
6
+ "service": "ldap-authentication",
7
+ "workspaceFolder": "/workspace",
8
+ // Features to add to the dev container. More info: https://containers.dev/features.
9
+ // "features": {},
10
+ // Use 'forwardPorts' to make a list of ports inside the container available locally.
11
+ // This can be used to network with other containers or the host.
12
+ // "forwardPorts": [5000, 5432],
13
+ // Use 'postCreateCommand' to run commands after the container is created.
14
+ "postCreateCommand": "npm install",
15
+ // Configure tool-specific properties.
16
+ // "customizations": {},
17
+ "customizations": {
18
+ "vscode": {
19
+ "extensions": ["esbenp.prettier-vscode", "ms-azuretools.vscode-docker"]
20
+ }
21
+ }
22
+ // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
23
+ // "remoteUser": "root"
24
+ }
@@ -0,0 +1,22 @@
1
+ version: "3"
2
+
3
+ services:
4
+ ldap-authentication:
5
+ build:
6
+ context: ..
7
+ dockerfile: .devcontainer/Dockerfile
8
+ volumes:
9
+ - ..:/workspace:cached
10
+ command: sleep infinity
11
+
12
+ ldap:
13
+ image: bitnami/openldap
14
+ ports:
15
+ - '1389:1389'
16
+ - '1636:1636'
17
+ environment:
18
+ - LDAP_ROOT=dc=example,dc=com
19
+ - LDAP_ADMIN_USERNAME=read-only-admin
20
+ - LDAP_ADMIN_PASSWORD=password
21
+ - LDAP_USERS=gauss,einstein
22
+ - LDAP_PASSWORDS=password,password
@@ -0,0 +1,54 @@
1
+ name: 'Integration Tests'
2
+
3
+ env:
4
+ INGITHUB: true
5
+
6
+ on:
7
+ push:
8
+ paths-ignore:
9
+ - 'README.md'
10
+ - 'LICENSE'
11
+ - 'SECURITY.md'
12
+ pull_request:
13
+
14
+ jobs:
15
+ integration:
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ matrix:
19
+ node-version: [12.x, 14.x, 15.x, 16.x, 17.x, 18.x]
20
+
21
+ name: 'Integration Node v${{ matrix.node-version }}'
22
+
23
+ steps:
24
+ - uses: actions/checkout@v3
25
+
26
+ - name: Install LDAP tools
27
+ run: sudo apt-get install -y ldap-utils
28
+
29
+ - name: Start containers
30
+ run: docker-compose -f "docker-compose.yml" up -d
31
+
32
+ - name: Wait for LDAP server to become available
33
+ uses: nick-fields/retry@v2
34
+ with:
35
+ timeout_minutes: 5
36
+ max_attempts: 10
37
+ polling_interval_seconds: 3
38
+ command: |
39
+ ldapsearch -LLL -o 'ldif-wrap=no' \
40
+ -H ldap://localhost:1389 \
41
+ -D 'cn=read-only-admin,dc=example,dc=com' \
42
+ -w password \
43
+ -b 'cn=einstein,ou=users,dc=example,dc=com' DN
44
+
45
+ - name: Use Node.js ${{ matrix.node-version }}
46
+ uses: actions/setup-node@v3
47
+ with:
48
+ node-version: ${{ matrix.node-version }}
49
+ - run: npm install
50
+ - run: npm run test
51
+
52
+ - name: Stop containers
53
+ if: always()
54
+ run: docker-compose -f "docker-compose.yml" down
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # A Simple node Library that Authenticates a User Against an LDAP/AD Server
2
2
 
3
- [![Build Status](https://travis-ci.org/shaozi/ldap-authentication.svg?branch=master)](https://travis-ci.org/shaozi/ldap-authentication)
3
+ [![Integration Tests](https://github.com/shaozi/ldap-authentication/actions/workflows/integration-test.yml/badge.svg)](https://github.com/shaozi/ldap-authentication/actions/workflows/integration-test.yml)
4
4
  [![Known Vulnerabilities](https://snyk.io/test/github/shaozi/ldap-authentication/badge.svg?targetFile=package.json)](https://snyk.io/test/github/shaozi/ldap-authentication?targetFile=package.json)
5
5
 
6
6
  ## Goal
@@ -176,3 +176,24 @@ auth()
176
176
  - `groupClass`: if specified with groupsSearchBase, will be used as objectClass in search filter for authenticated user groups
177
177
  - `groupMemberAttribute`: if specified with groupClass and groupsSearchBase, will be used as member name (if not specified this defaults to `member`) in search filter for authenticated user groups
178
178
  - `groupMemberUserAttribute`: if specified with groupClass and groupsSearchBase, will be used as the attribute on the user object (if not specified this defaults to `dn`) in search filter for authenticated user groups
179
+
180
+ ## Returns
181
+
182
+ The user object if `authenticate()` is success.
183
+
184
+ The user object has a `raw` field that has the raw data from the LDAP/AD server. It can be used to access buffer objects (profile pics for example).
185
+ Buffer data can now be accessed by `user.raw.profilePhoto`, etc, instead of `user.profilePhoto`.
186
+
187
+
188
+
189
+
190
+
191
+
192
+
193
+
194
+
195
+
196
+
197
+
198
+
199
+
@@ -0,0 +1,13 @@
1
+ version: "3"
2
+ services:
3
+ ldap:
4
+ image: bitnami/openldap:2.6.3
5
+ ports:
6
+ - '1389:1389'
7
+ - '1636:1636'
8
+ environment:
9
+ - LDAP_ROOT=dc=example,dc=com
10
+ - LDAP_ADMIN_USERNAME=read-only-admin
11
+ - LDAP_ADMIN_PASSWORD=password
12
+ - LDAP_USERS=gauss,einstein
13
+ - LDAP_PASSWORDS=password,password
package/index.d.ts CHANGED
@@ -1,13 +1,8 @@
1
+ import { ClientOptions } from 'ldapjs'
2
+
1
3
  declare module 'ldap-authentication' {
2
- type LDAPOpts = {
3
- url: string
4
- tlsOptions?: {
5
- rejectUnauthorized: boolean
6
- }
7
- connectTimeout?: number
8
- }
9
- export type AuthenticationOptions = {
10
- ldapOpts: LDAPOpts
4
+ export interface AuthenticationOptions {
5
+ ldapOpts: ClientOptions
11
6
  userDn?: string
12
7
  adminDn?: string
13
8
  adminPassword?: string
@@ -15,7 +10,7 @@ declare module 'ldap-authentication' {
15
10
  usernameAttribute?: string
16
11
  username?: string
17
12
  verifyUserExists?: boolean
18
- starttls?: Boolean
13
+ starttls?: boolean
19
14
  groupsSearchBase?: string
20
15
  groupClass?: string
21
16
  groupMemberAttribute?: string
package/index.js CHANGED
@@ -87,6 +87,7 @@ async function _searchUser(
87
87
  }
88
88
  res.on('searchEntry', function (entry) {
89
89
  user = entry.object
90
+ user.raw = entry.raw
90
91
  })
91
92
  res.on('searchReference', function (referral) {
92
93
  // TODO: we don't support reference yet
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ldap-authentication",
3
- "version": "2.3.1",
3
+ "version": "2.3.3",
4
4
  "description": "A simple async nodejs library for LDAP user authentication",
5
5
  "main": "index.js",
6
6
  "types": "./index.d.ts",
@@ -39,6 +39,6 @@
39
39
  "ldapjs": "^2.3.3"
40
40
  },
41
41
  "devDependencies": {
42
- "jasmine": "^4.3.0"
42
+ "jasmine": "^4.5.0"
43
43
  }
44
44
  }
package/test/test.js CHANGED
@@ -1,10 +1,12 @@
1
1
  const { authenticate, LdapAuthenticationError } = require('../index.js')
2
2
 
3
+ const url = process.env.INGITHUB ? 'ldap://localhost:1389' : 'ldap://ldap:1389'
4
+
3
5
  describe('ldap-authentication test', () => {
4
6
  it('Use an admin user to check if user exists', async () => {
5
7
  let options = {
6
8
  ldapOpts: {
7
- url: 'ldap://ldap.forumsys.com',
9
+ url: url,
8
10
  },
9
11
  adminDn: 'cn=read-only-admin,dc=example,dc=com',
10
12
  adminPassword: 'password',
@@ -13,6 +15,7 @@ describe('ldap-authentication test', () => {
13
15
  usernameAttribute: 'uid',
14
16
  username: 'gauss',
15
17
  }
18
+
16
19
  let user = await authenticate(options)
17
20
  expect(user).toBeTruthy()
18
21
  expect(user.uid).toEqual('gauss')
@@ -20,7 +23,7 @@ describe('ldap-authentication test', () => {
20
23
  it('Use an admin user to authenticate a regular user', async () => {
21
24
  let options = {
22
25
  ldapOpts: {
23
- url: 'ldap://ldap.forumsys.com',
26
+ url: url,
24
27
  },
25
28
  adminDn: 'cn=read-only-admin,dc=example,dc=com',
26
29
  adminPassword: 'password',
@@ -29,6 +32,7 @@ describe('ldap-authentication test', () => {
29
32
  usernameAttribute: 'uid',
30
33
  username: 'gauss',
31
34
  }
35
+
32
36
  let user = await authenticate(options)
33
37
  expect(user).toBeTruthy()
34
38
  expect(user.uid).toEqual('gauss')
@@ -36,7 +40,7 @@ describe('ldap-authentication test', () => {
36
40
  it('Use an admin user to authenticate a regular user and return attrubutes', async () => {
37
41
  let options = {
38
42
  ldapOpts: {
39
- url: 'ldap://ldap.forumsys.com',
43
+ url: url,
40
44
  },
41
45
  adminDn: 'cn=read-only-admin,dc=example,dc=com',
42
46
  adminPassword: 'password',
@@ -46,60 +50,65 @@ describe('ldap-authentication test', () => {
46
50
  username: 'gauss',
47
51
  attributes: ['uid', 'sn'],
48
52
  }
53
+
49
54
  let user = await authenticate(options)
50
55
  expect(user).toBeTruthy()
51
56
  expect(user.uid).toEqual('gauss')
52
- expect(user.sn).toEqual('Gauss')
57
+ expect(user.sn).toEqual('Bar1')
53
58
  expect(user.cn).toBeUndefined()
54
59
  })
55
60
  it('Use an regular user to authenticate iteself', async () => {
56
61
  let options = {
57
62
  ldapOpts: {
58
- url: 'ldap://ldap.forumsys.com',
63
+ url: url,
59
64
  },
60
- userDn: 'uid=einstein,dc=example,dc=com',
65
+ userDn: 'cn=einstein,ou=users,dc=example,dc=com',
61
66
  userPassword: 'password',
62
67
  userSearchBase: 'dc=example,dc=com',
63
68
  usernameAttribute: 'uid',
64
69
  username: 'einstein',
65
70
  }
71
+
66
72
  let user = await authenticate(options)
67
73
  expect(user).toBeTruthy()
68
74
  expect(user.uid).toEqual('einstein')
75
+ expect(user.raw).toBeTruthy()
69
76
  })
70
77
  it('Use an regular user to authenticate iteself and return attributes', async () => {
71
78
  let options = {
72
79
  ldapOpts: {
73
- url: 'ldap://ldap.forumsys.com',
80
+ url: url,
74
81
  },
75
- userDn: 'uid=einstein,dc=example,dc=com',
82
+ userDn: 'cn=einstein,ou=users,dc=example,dc=com',
76
83
  userPassword: 'password',
77
84
  userSearchBase: 'dc=example,dc=com',
78
85
  usernameAttribute: 'uid',
79
86
  username: 'einstein',
80
87
  attributes: ['uid', 'sn'],
81
88
  }
89
+
82
90
  let user = await authenticate(options)
83
91
  expect(user).toBeTruthy()
84
92
  expect(user.uid).toEqual('einstein')
85
- expect(user.sn).toEqual('Einstein')
93
+ expect(user.sn).toEqual('Bar2')
86
94
  expect(user.cn).toBeUndefined()
87
95
  })
88
96
  it('Use an regular user to authenticate iteself without search', async () => {
89
97
  let options = {
90
98
  ldapOpts: {
91
- url: 'ldap://ldap.forumsys.com',
99
+ url: url,
92
100
  },
93
- userDn: 'uid=einstein,dc=example,dc=com',
101
+ userDn: 'cn=einstein,ou=users,dc=example,dc=com',
94
102
  userPassword: 'password',
95
103
  }
104
+
96
105
  let user = await authenticate(options)
97
106
  expect(user).toBeTruthy()
98
107
  })
99
108
  it('Use an admin user to authenticate a regular user and fetch user group information', async () => {
100
109
  let options = {
101
110
  ldapOpts: {
102
- url: 'ldap://ldap.forumsys.com',
111
+ url: url,
103
112
  },
104
113
  adminDn: 'cn=read-only-admin,dc=example,dc=com',
105
114
  adminPassword: 'password',
@@ -108,10 +117,11 @@ describe('ldap-authentication test', () => {
108
117
  usernameAttribute: 'uid',
109
118
  username: 'gauss',
110
119
  groupsSearchBase: 'dc=example,dc=com',
111
- groupClass: 'groupOfUniqueNames',
112
- groupMemberAttribute: 'uniqueMember',
120
+ groupClass: 'groupOfNames',
121
+ groupMemberAttribute: 'member',
113
122
  groupMemberUserAttribute: 'dn',
114
123
  }
124
+
115
125
  let user = await authenticate(options)
116
126
  expect(user).toBeTruthy()
117
127
  expect(user.groups.length).toBeGreaterThan(0)
@@ -119,18 +129,19 @@ describe('ldap-authentication test', () => {
119
129
  it('Use regular user to authenticate and fetch user group information', async () => {
120
130
  let options = {
121
131
  ldapOpts: {
122
- url: 'ldap://ldap.forumsys.com',
132
+ url: url,
123
133
  },
124
- userDn: 'uid=gauss,dc=example,dc=com',
134
+ userDn: 'cn=gauss,ou=users,dc=example,dc=com',
125
135
  userPassword: 'password',
126
136
  userSearchBase: 'dc=example,dc=com',
127
137
  usernameAttribute: 'uid',
128
138
  username: 'gauss',
129
139
  groupsSearchBase: 'dc=example,dc=com',
130
- groupClass: 'groupOfUniqueNames',
131
- groupMemberAttribute: 'uniqueMember',
140
+ groupClass: 'groupOfNames',
141
+ groupMemberAttribute: 'member',
132
142
  groupMemberUserAttribute: 'dn',
133
143
  }
144
+
134
145
  let user = await authenticate(options)
135
146
  expect(user).toBeTruthy()
136
147
  expect(user.groups.length).toBeGreaterThan(0)
@@ -138,9 +149,9 @@ describe('ldap-authentication test', () => {
138
149
  it('Not specifying groupMemberAttribute or groupMemberUserAttribute should not cause an error and fallback to default values', async () => {
139
150
  let options = {
140
151
  ldapOpts: {
141
- url: 'ldap://ldap.forumsys.com',
152
+ url: url,
142
153
  },
143
- userDn: 'uid=gauss,dc=example,dc=com',
154
+ userDn: 'cn=gauss,ou=users,dc=example,dc=com',
144
155
  userPassword: 'password',
145
156
  userSearchBase: 'dc=example,dc=com',
146
157
  usernameAttribute: 'uid',
@@ -148,6 +159,7 @@ describe('ldap-authentication test', () => {
148
159
  groupsSearchBase: 'dc=example,dc=com',
149
160
  groupClass: 'groupOfUniqueNames',
150
161
  }
162
+
151
163
  let user = await authenticate(options)
152
164
  expect(user).toBeTruthy()
153
165
  expect(user.groups.length).toBeLessThan(1)
@@ -158,7 +170,7 @@ describe('ldap-authentication negative test', () => {
158
170
  it('wrong admin user should fail', async () => {
159
171
  let options = {
160
172
  ldapOpts: {
161
- url: 'ldap://ldap.forumsys.com',
173
+ url: url,
162
174
  },
163
175
  adminDn: 'cn=not-exist,dc=example,dc=com',
164
176
  adminPassword: 'password',
@@ -167,18 +179,20 @@ describe('ldap-authentication negative test', () => {
167
179
  usernameAttribute: 'uid',
168
180
  username: 'gauss',
169
181
  }
182
+
170
183
  let e = null
171
184
  try {
172
185
  await authenticate(options)
173
186
  } catch (error) {
174
187
  e = error
175
188
  }
189
+
176
190
  expect(e).toBeTruthy()
177
191
  })
178
192
  it('wrong admin password should fail', async () => {
179
193
  let options = {
180
194
  ldapOpts: {
181
- url: 'ldap://ldap.forumsys.com',
195
+ url: url,
182
196
  },
183
197
  adminDn: 'cn=read-only-admin,dc=example,dc=com',
184
198
  adminPassword: '',
@@ -187,18 +201,20 @@ describe('ldap-authentication negative test', () => {
187
201
  usernameAttribute: 'uid',
188
202
  username: 'gauss',
189
203
  }
204
+
190
205
  let e = null
191
206
  try {
192
207
  await authenticate(options)
193
208
  } catch (error) {
194
209
  e = error
195
210
  }
211
+
196
212
  expect(e).toBeTruthy()
197
213
  })
198
214
  it('admin auth wrong username should fail', async () => {
199
215
  let options = {
200
216
  ldapOpts: {
201
- url: 'ldap://ldap.forumsys.com',
217
+ url: url,
202
218
  },
203
219
  adminDn: 'cn=read-only-admin,dc=example,dc=com',
204
220
  adminPassword: 'password',
@@ -207,18 +223,20 @@ describe('ldap-authentication negative test', () => {
207
223
  usernameAttribute: 'uid',
208
224
  username: 'wrong',
209
225
  }
226
+
210
227
  let e = null
211
228
  try {
212
229
  await authenticate(options)
213
230
  } catch (error) {
214
231
  e = error
215
232
  }
233
+
216
234
  expect(e).toBeTruthy()
217
235
  })
218
236
  it('admin auth wrong user password should fail', async () => {
219
237
  let options = {
220
238
  ldapOpts: {
221
- url: 'ldap://ldap.forumsys.com',
239
+ url: url,
222
240
  },
223
241
  adminDn: 'cn=read-only-admin,dc=example,dc=com',
224
242
  adminPassword: 'password',
@@ -227,18 +245,20 @@ describe('ldap-authentication negative test', () => {
227
245
  usernameAttribute: 'uid',
228
246
  username: 'gauss',
229
247
  }
248
+
230
249
  let e = null
231
250
  try {
232
251
  await authenticate(options)
233
252
  } catch (error) {
234
253
  e = error
235
254
  }
255
+
236
256
  expect(e).toBeTruthy()
237
257
  })
238
258
  it('user auth wrong username should fail', async () => {
239
259
  let options = {
240
260
  ldapOpts: {
241
- url: 'ldap://ldap.forumsys.com',
261
+ url: url,
242
262
  },
243
263
  userDn: 'cn=not-exist,dc=example,dc=com',
244
264
  userPassword: 'password',
@@ -246,18 +266,20 @@ describe('ldap-authentication negative test', () => {
246
266
  usernameAttribute: 'uid',
247
267
  username: 'gauss',
248
268
  }
269
+
249
270
  let e = null
250
271
  try {
251
272
  await authenticate(options)
252
273
  } catch (error) {
253
274
  e = error
254
275
  }
276
+
255
277
  expect(e).toBeTruthy()
256
278
  })
257
279
  it('user auth wrong user password should fail', async () => {
258
280
  let options = {
259
281
  ldapOpts: {
260
- url: 'ldap://ldap.forumsys.com',
282
+ url: url,
261
283
  },
262
284
  userDn: 'cn=gauss,dc=example,dc=com',
263
285
  userPassword: 'wrongpassword',
@@ -265,45 +287,51 @@ describe('ldap-authentication negative test', () => {
265
287
  usernameAttribute: 'uid',
266
288
  username: 'gauss',
267
289
  }
290
+
268
291
  let e = null
269
292
  try {
270
293
  await authenticate(options)
271
294
  } catch (error) {
272
295
  e = error
273
296
  }
297
+
274
298
  expect(e).toBeTruthy()
275
299
  })
276
300
  it('Use an regular user to authenticate iteself without search with wrong password should fail', async () => {
277
301
  let options = {
278
302
  ldapOpts: {
279
- url: 'ldap://ldap.forumsys.com',
303
+ url: url,
280
304
  },
281
305
  userDn: 'uid=einstein,dc=example,dc=com',
282
306
  userPassword: '',
283
307
  }
308
+
284
309
  try {
285
310
  await authenticate(options)
286
311
  } catch (error) {
287
312
  e = error
288
313
  }
314
+
289
315
  expect(e).toBeTruthy()
290
316
  })
291
317
  it('Wrong options give LdapAuthenticationError', async () => {
292
318
  let options = {
293
319
  ldapOpts: {
294
- url: 'ldap://ldap.forumsys.com',
320
+ url: url,
295
321
  },
296
- userDn: 'uid=einstein,dc=example,dc=com',
322
+ userDn: 'cn=einstein,ou=users,dc=example,dc=com',
297
323
  userPassword: 'password',
298
- usernameAttribute: 'cn',
324
+ usernameAttribute: 'wrongattribute',
299
325
  userSearchBase: 'dc=example,dc=com',
300
326
  username: 'einstein',
301
327
  }
328
+
302
329
  try {
303
330
  await authenticate(options)
304
331
  } catch (error) {
305
332
  e = error
306
333
  }
334
+
307
335
  expect(e).toBeTruthy()
308
336
  expect(e).toBeInstanceOf(LdapAuthenticationError)
309
337
  })
@@ -319,11 +347,13 @@ describe('ldap-authentication negative test', () => {
319
347
  userSearchBase: 'dc=example,dc=com',
320
348
  username: 'einstein',
321
349
  }
350
+
322
351
  try {
323
352
  await authenticate(options)
324
353
  } catch (error) {
325
354
  e = error
326
355
  }
356
+
327
357
  expect(e).toBeTruthy()
328
358
  })
329
359
  it('Unreachable ldap server should throw error (with starttls=true)', async () => {
@@ -339,28 +369,31 @@ describe('ldap-authentication negative test', () => {
339
369
  username: 'einstein',
340
370
  starttls: true,
341
371
  }
372
+
342
373
  try {
343
374
  await authenticate(options)
344
375
  } catch (error) {
345
376
  e = error
346
377
  }
378
+
347
379
  expect(e).toBeTruthy()
348
380
  })
349
381
  it('Unmatched supplied groupMemberUserAttribute should return empty group list', async () => {
350
382
  let options = {
351
383
  ldapOpts: {
352
- url: 'ldap://ldap.forumsys.com',
384
+ url: url,
353
385
  },
354
- userDn: 'uid=gauss,dc=example,dc=com',
386
+ userDn: 'cn=gauss,ou=users,dc=example,dc=com',
355
387
  userPassword: 'password',
356
388
  userSearchBase: 'dc=example,dc=com',
357
389
  usernameAttribute: 'uid',
358
390
  username: 'gauss',
359
391
  groupsSearchBase: 'dc=example,dc=com',
360
- groupClass: 'groupOfUniqueNames',
361
- groupMemberAttribute: 'uniqueMember',
362
- groupMemberUserAttribute: 'notARealGroupMemberUserAttribute',
392
+ groupClass: 'groupOfNames',
393
+ groupMemberAttribute: 'member',
394
+ groupMemberUserAttribute: 'dnWRONG',
363
395
  }
396
+
364
397
  let user = await authenticate(options)
365
398
  expect(user).toBeTruthy()
366
399
  expect(user.groups.length).toBeLessThan(1)
package/.travis.yml DELETED
@@ -1,3 +0,0 @@
1
- language: node_js
2
- node_js:
3
- - "12"