adapt-authoring-auth 1.0.6 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/tests.yml +15 -0
- package/package.json +11 -13
- package/tests/AbstractAuthModule.spec.js +341 -0
- package/tests/AuthModule.spec.js +193 -0
- package/tests/AuthToken.spec.js +109 -0
- package/tests/AuthUtils.spec.js +102 -0
- package/tests/Authentication.spec.js +159 -0
- package/tests/Permissions.spec.js +154 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
name: Tests
|
|
2
|
+
on: push
|
|
3
|
+
jobs:
|
|
4
|
+
default:
|
|
5
|
+
runs-on: ubuntu-latest
|
|
6
|
+
permissions:
|
|
7
|
+
contents: read
|
|
8
|
+
steps:
|
|
9
|
+
- uses: actions/checkout@master
|
|
10
|
+
- uses: actions/setup-node@master
|
|
11
|
+
with:
|
|
12
|
+
node-version: 'lts/*'
|
|
13
|
+
cache: 'npm'
|
|
14
|
+
- run: npm ci
|
|
15
|
+
- run: npm test
|
package/package.json
CHANGED
|
@@ -1,32 +1,30 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "adapt-authoring-auth",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Authentication + authorisation module for the Adapt authoring tool",
|
|
5
5
|
"homepage": "https://github.com/adaptlearning/adapt-authoring-auth",
|
|
6
6
|
"license": "GPL-3.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "index.js",
|
|
9
9
|
"repository": "github:adapt-security/adapt-authoring-auth",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test 'tests/**/*.spec.js'"
|
|
12
|
+
},
|
|
10
13
|
"dependencies": {
|
|
11
|
-
"adapt-authoring-
|
|
12
|
-
"adapt-authoring-users": "github:adapt-security/adapt-authoring-users",
|
|
14
|
+
"adapt-authoring-core": "^1.7.0",
|
|
13
15
|
"express-session": "1.19.0",
|
|
14
16
|
"jsonwebtoken": "9.0.3",
|
|
15
17
|
"path-to-regexp": "^8.0.0"
|
|
16
18
|
},
|
|
17
19
|
"peerDependencies": {
|
|
18
|
-
"adapt-authoring-
|
|
19
|
-
"adapt-authoring-
|
|
20
|
-
"adapt-authoring-
|
|
21
|
-
"adapt-authoring-
|
|
22
|
-
"adapt-authoring-
|
|
23
|
-
"adapt-authoring-
|
|
24
|
-
"adapt-authoring-users": "^1.0.1"
|
|
20
|
+
"adapt-authoring-jsonschema": "^1.2.0",
|
|
21
|
+
"adapt-authoring-mongodb": "^1.1.3",
|
|
22
|
+
"adapt-authoring-roles": "^1.1.3",
|
|
23
|
+
"adapt-authoring-server": "^1.2.1",
|
|
24
|
+
"adapt-authoring-sessions": "^1.0.2",
|
|
25
|
+
"adapt-authoring-users": "^1.0.2"
|
|
25
26
|
},
|
|
26
27
|
"peerDependenciesMeta": {
|
|
27
|
-
"adapt-authoring-core": {
|
|
28
|
-
"optional": true
|
|
29
|
-
},
|
|
30
28
|
"adapt-authoring-jsonschema": {
|
|
31
29
|
"optional": true
|
|
32
30
|
},
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Hook } from 'adapt-authoring-core'
|
|
4
|
+
import AbstractAuthModule from '../lib/AbstractAuthModule.js'
|
|
5
|
+
|
|
6
|
+
function createMockApp () {
|
|
7
|
+
const moduleLoadedHook = new Hook()
|
|
8
|
+
return {
|
|
9
|
+
logger: { log: () => {} },
|
|
10
|
+
dependencyloader: { moduleLoadedHook },
|
|
11
|
+
errors: {
|
|
12
|
+
FUNC_NOT_OVERRIDDEN: {
|
|
13
|
+
setData: (data) => new Error(`Function not overridden: ${data.name}`)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('AbstractAuthModule', () => {
|
|
20
|
+
describe('constructor', () => {
|
|
21
|
+
it('should be instantiable', () => {
|
|
22
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
23
|
+
assert.ok(module instanceof AbstractAuthModule)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('#setValues()', () => {
|
|
28
|
+
it('should set default values', async () => {
|
|
29
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
30
|
+
await module.setValues()
|
|
31
|
+
assert.equal(module.type, undefined)
|
|
32
|
+
assert.equal(module.routes, undefined)
|
|
33
|
+
assert.equal(module.userSchema, 'user')
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('should be callable multiple times without error', async () => {
|
|
37
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
38
|
+
await module.setValues()
|
|
39
|
+
await module.setValues()
|
|
40
|
+
assert.equal(module.userSchema, 'user')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('#authenticate()', () => {
|
|
45
|
+
it('should throw FUNC_NOT_OVERRIDDEN error when not overridden', async () => {
|
|
46
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await module.authenticate({}, {}, {})
|
|
50
|
+
assert.fail('Should have thrown error')
|
|
51
|
+
} catch (e) {
|
|
52
|
+
assert.ok(e.message.includes('authenticate'))
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('should include the class name in the error', async () => {
|
|
57
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
await module.authenticate({}, {}, {})
|
|
61
|
+
assert.fail('Should have thrown error')
|
|
62
|
+
} catch (e) {
|
|
63
|
+
assert.ok(e.message.includes('AbstractAuthModule'))
|
|
64
|
+
}
|
|
65
|
+
})
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe('#secureRoute()', () => {
|
|
69
|
+
it('should delegate to auth.secureRoute with prefixed path', () => {
|
|
70
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
71
|
+
const secured = []
|
|
72
|
+
module.auth = {
|
|
73
|
+
secureRoute: (route, method, scopes) => secured.push({ route, method, scopes })
|
|
74
|
+
}
|
|
75
|
+
module.router = { path: '/api/auth/local' }
|
|
76
|
+
|
|
77
|
+
module.secureRoute('/register', 'post', ['register:users'])
|
|
78
|
+
assert.equal(secured.length, 1)
|
|
79
|
+
assert.equal(secured[0].route, '/api/auth/local/register')
|
|
80
|
+
assert.equal(secured[0].method, 'post')
|
|
81
|
+
assert.deepEqual(secured[0].scopes, ['register:users'])
|
|
82
|
+
})
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
describe('#unsecureRoute()', () => {
|
|
86
|
+
it('should delegate to auth.unsecureRoute with prefixed path', () => {
|
|
87
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
88
|
+
const unsecured = []
|
|
89
|
+
module.auth = {
|
|
90
|
+
unsecureRoute: (route, method) => unsecured.push({ route, method })
|
|
91
|
+
}
|
|
92
|
+
module.router = { path: '/api/auth/local' }
|
|
93
|
+
|
|
94
|
+
module.unsecureRoute('/', 'post')
|
|
95
|
+
assert.equal(unsecured.length, 1)
|
|
96
|
+
assert.equal(unsecured[0].route, '/api/auth/local/')
|
|
97
|
+
assert.equal(unsecured[0].method, 'post')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
describe('#register()', () => {
|
|
102
|
+
it('should delegate to authentication.registerUser', async () => {
|
|
103
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
104
|
+
const expectedResult = { _id: '123', email: 'test@test.com' }
|
|
105
|
+
module.type = 'local'
|
|
106
|
+
module.auth = {
|
|
107
|
+
authentication: {
|
|
108
|
+
registerUser: (type, data) => {
|
|
109
|
+
assert.equal(type, 'local')
|
|
110
|
+
return expectedResult
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const result = await module.register({ email: 'test@test.com' })
|
|
116
|
+
assert.deepEqual(result, expectedResult)
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
describe('#setUserEnabled()', () => {
|
|
121
|
+
it('should call users.update with correct params', async () => {
|
|
122
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
123
|
+
let updateArgs
|
|
124
|
+
module.users = {
|
|
125
|
+
update: (query, data) => { updateArgs = { query, data } }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await module.setUserEnabled({ _id: 'user123' }, true)
|
|
129
|
+
assert.deepEqual(updateArgs.query, { _id: 'user123' })
|
|
130
|
+
assert.deepEqual(updateArgs.data, { isEnabled: true })
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('should pass false to disable a user', async () => {
|
|
134
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
135
|
+
let updateArgs
|
|
136
|
+
module.users = {
|
|
137
|
+
update: (query, data) => { updateArgs = { query, data } }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await module.setUserEnabled({ _id: 'user123' }, false)
|
|
141
|
+
assert.deepEqual(updateArgs.data, { isEnabled: false })
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('#disavowUser()', () => {
|
|
146
|
+
it('should delegate to authentication.disavowUser', async () => {
|
|
147
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
148
|
+
const expectedResult = { ok: true }
|
|
149
|
+
module.auth = {
|
|
150
|
+
authentication: {
|
|
151
|
+
disavowUser: (query) => {
|
|
152
|
+
assert.deepEqual(query, { userId: '123' })
|
|
153
|
+
return expectedResult
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const result = await module.disavowUser({ userId: '123' })
|
|
159
|
+
assert.deepEqual(result, expectedResult)
|
|
160
|
+
})
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
describe('#authenticateHandler()', () => {
|
|
164
|
+
it('should send error if user is not found', async () => {
|
|
165
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
166
|
+
const sentError = {}
|
|
167
|
+
module.users = { find: () => [] }
|
|
168
|
+
const req = { body: { email: 'noone@test.com' } }
|
|
169
|
+
const res = { sendError: (e) => { sentError.error = e } }
|
|
170
|
+
|
|
171
|
+
await module.authenticateHandler(req, res, () => {})
|
|
172
|
+
assert.equal(sentError.error, module.app.errors.INVALID_LOGIN_DETAILS)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should call authenticate and set session token on success', async () => {
|
|
176
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
177
|
+
const user = { _id: '123', email: 'test@test.com' }
|
|
178
|
+
module.users = { find: () => [user] }
|
|
179
|
+
module.authenticate = async () => {}
|
|
180
|
+
module.log = () => {}
|
|
181
|
+
|
|
182
|
+
let statusCode
|
|
183
|
+
let jsonCalled = false
|
|
184
|
+
const req = {
|
|
185
|
+
body: { email: 'test@test.com', persistSession: false },
|
|
186
|
+
session: { cookie: { maxAge: 3600 }, token: null }
|
|
187
|
+
}
|
|
188
|
+
const res = {
|
|
189
|
+
status: (code) => { statusCode = code; return res },
|
|
190
|
+
json: () => { jsonCalled = true }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Mock AuthToken.generate
|
|
194
|
+
const { default: AuthToken } = await import('../lib/AuthToken.js')
|
|
195
|
+
const originalGenerate = AuthToken.generate
|
|
196
|
+
AuthToken.generate = async () => 'mock-token'
|
|
197
|
+
|
|
198
|
+
try {
|
|
199
|
+
await module.authenticateHandler(req, res, () => {})
|
|
200
|
+
assert.equal(statusCode, 204)
|
|
201
|
+
assert.equal(jsonCalled, true)
|
|
202
|
+
assert.equal(req.session.cookie.maxAge, null)
|
|
203
|
+
assert.equal(req.session.token, 'mock-token')
|
|
204
|
+
} finally {
|
|
205
|
+
AuthToken.generate = originalGenerate
|
|
206
|
+
}
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('should send error if authenticate throws', async () => {
|
|
210
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
211
|
+
const user = { _id: '123', email: 'test@test.com' }
|
|
212
|
+
const authError = new Error('bad password')
|
|
213
|
+
module.users = { find: () => [user] }
|
|
214
|
+
module.authenticate = async () => { throw authError }
|
|
215
|
+
module.log = () => {}
|
|
216
|
+
module.app.lang = { translate: (_, e) => e.message }
|
|
217
|
+
|
|
218
|
+
let sentError
|
|
219
|
+
const req = { body: { email: 'test@test.com' } }
|
|
220
|
+
const res = { sendError: (e) => { sentError = e } }
|
|
221
|
+
|
|
222
|
+
await module.authenticateHandler(req, res, () => {})
|
|
223
|
+
assert.equal(sentError, authError)
|
|
224
|
+
})
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
describe('#enableHandler()', () => {
|
|
228
|
+
it('should enable a user when URL is /enable', async () => {
|
|
229
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
230
|
+
const user = { _id: 'u1' }
|
|
231
|
+
let enabledValue
|
|
232
|
+
module.users = { find: () => [user] }
|
|
233
|
+
module.setUserEnabled = async (u, enabled) => { enabledValue = enabled }
|
|
234
|
+
module.log = () => {}
|
|
235
|
+
|
|
236
|
+
let statusCode
|
|
237
|
+
const req = {
|
|
238
|
+
body: { _id: 'u1' },
|
|
239
|
+
url: '/enable',
|
|
240
|
+
auth: { user: { _id: { toString: () => 'admin1' } } }
|
|
241
|
+
}
|
|
242
|
+
const res = {
|
|
243
|
+
status: (code) => { statusCode = code; return res },
|
|
244
|
+
json: () => {}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
await module.enableHandler(req, res, () => {})
|
|
248
|
+
assert.equal(enabledValue, true)
|
|
249
|
+
assert.equal(statusCode, 204)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('should disable a user when URL is not /enable', async () => {
|
|
253
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
254
|
+
const user = { _id: 'u1' }
|
|
255
|
+
let enabledValue
|
|
256
|
+
module.users = { find: () => [user] }
|
|
257
|
+
module.setUserEnabled = async (u, enabled) => { enabledValue = enabled }
|
|
258
|
+
module.log = () => {}
|
|
259
|
+
|
|
260
|
+
const req = {
|
|
261
|
+
body: { _id: 'u1' },
|
|
262
|
+
url: '/disable',
|
|
263
|
+
auth: { user: { _id: { toString: () => 'admin1' } } }
|
|
264
|
+
}
|
|
265
|
+
const res = {
|
|
266
|
+
status: (code) => { return res },
|
|
267
|
+
json: () => {}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await module.enableHandler(req, res, () => {})
|
|
271
|
+
assert.equal(enabledValue, false)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
it('should call next with error on failure', async () => {
|
|
275
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
276
|
+
const error = new Error('user not found')
|
|
277
|
+
module.users = { find: () => { throw error } }
|
|
278
|
+
|
|
279
|
+
let nextError
|
|
280
|
+
const req = { body: { _id: 'u1' }, url: '/enable' }
|
|
281
|
+
const res = {}
|
|
282
|
+
|
|
283
|
+
await module.enableHandler(req, res, (e) => { nextError = e })
|
|
284
|
+
assert.equal(nextError, error)
|
|
285
|
+
})
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
describe('#registerHandler()', () => {
|
|
289
|
+
it('should register user and return result', async () => {
|
|
290
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
291
|
+
const newUser = { _id: '456', email: 'new@test.com' }
|
|
292
|
+
module.registerHook = new Hook({ mutable: true })
|
|
293
|
+
module.register = async () => newUser
|
|
294
|
+
module.log = () => {}
|
|
295
|
+
|
|
296
|
+
let jsonResult
|
|
297
|
+
const req = {
|
|
298
|
+
body: { email: 'new@test.com' },
|
|
299
|
+
auth: { user: { _id: { toString: () => 'admin1' } } }
|
|
300
|
+
}
|
|
301
|
+
const res = { json: (data) => { jsonResult = data } }
|
|
302
|
+
|
|
303
|
+
await module.registerHandler(req, res, () => {})
|
|
304
|
+
assert.deepEqual(jsonResult, newUser)
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('should set apiData if not already set', async () => {
|
|
308
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
309
|
+
module.registerHook = new Hook({ mutable: true })
|
|
310
|
+
module.register = async () => ({ _id: '1' })
|
|
311
|
+
module.log = () => {}
|
|
312
|
+
|
|
313
|
+
const req = {
|
|
314
|
+
body: { email: 'a@b.com' },
|
|
315
|
+
auth: { user: { _id: { toString: () => 'admin' } } }
|
|
316
|
+
}
|
|
317
|
+
const res = { json: () => {} }
|
|
318
|
+
|
|
319
|
+
await module.registerHandler(req, res, () => {})
|
|
320
|
+
assert.deepEqual(req.apiData, { modifying: true, data: req.body })
|
|
321
|
+
})
|
|
322
|
+
|
|
323
|
+
it('should call next with USER_REG_FAILED on error', async () => {
|
|
324
|
+
const module = new AbstractAuthModule(createMockApp(), { name: 'test-auth' })
|
|
325
|
+
const error = new Error('registration failed')
|
|
326
|
+
module.registerHook = new Hook({ mutable: true })
|
|
327
|
+
module.register = async () => { throw error }
|
|
328
|
+
module.app.errors.USER_REG_FAILED = { setData: (data) => ({ code: 'USER_REG_FAILED', ...data }) }
|
|
329
|
+
|
|
330
|
+
let nextArg
|
|
331
|
+
const req = {
|
|
332
|
+
body: { email: 'fail@test.com' },
|
|
333
|
+
translate: (e) => e.message
|
|
334
|
+
}
|
|
335
|
+
const res = {}
|
|
336
|
+
|
|
337
|
+
await module.registerHandler(req, res, (e) => { nextArg = e })
|
|
338
|
+
assert.equal(nextArg.code, 'USER_REG_FAILED')
|
|
339
|
+
})
|
|
340
|
+
})
|
|
341
|
+
})
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { Hook } from 'adapt-authoring-core'
|
|
4
|
+
import AuthModule from '../lib/AuthModule.js'
|
|
5
|
+
|
|
6
|
+
function createMockApp () {
|
|
7
|
+
const moduleLoadedHook = new Hook()
|
|
8
|
+
return {
|
|
9
|
+
logger: { log: () => {} },
|
|
10
|
+
dependencyloader: { moduleLoadedHook },
|
|
11
|
+
config: { get: () => undefined }
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('AuthModule', () => {
|
|
16
|
+
describe('constructor', () => {
|
|
17
|
+
it('should be instantiable', () => {
|
|
18
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
19
|
+
assert.ok(authModule instanceof AuthModule)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('#unsecureRoute()', () => {
|
|
24
|
+
it('should mark route as unsecured', () => {
|
|
25
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
26
|
+
authModule.unsecuredRoutes = { post: {}, get: {}, put: {}, patch: {}, delete: {} }
|
|
27
|
+
authModule.log = () => {}
|
|
28
|
+
|
|
29
|
+
authModule.unsecureRoute('/api/test', 'post')
|
|
30
|
+
assert.equal(authModule.unsecuredRoutes.post['/api/test'], true)
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('should handle different HTTP methods', () => {
|
|
34
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
35
|
+
authModule.unsecuredRoutes = { post: {}, get: {}, put: {}, patch: {}, delete: {} }
|
|
36
|
+
authModule.log = () => {}
|
|
37
|
+
|
|
38
|
+
authModule.unsecureRoute('/api/another', 'get')
|
|
39
|
+
assert.equal(authModule.unsecuredRoutes.get['/api/another'], true)
|
|
40
|
+
assert.equal(authModule.unsecuredRoutes.post['/api/another'], undefined)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should handle case-insensitive HTTP methods', () => {
|
|
44
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
45
|
+
authModule.unsecuredRoutes = { post: {}, get: {}, put: {}, patch: {}, delete: {} }
|
|
46
|
+
authModule.log = () => {}
|
|
47
|
+
|
|
48
|
+
authModule.unsecureRoute('/api/case', 'POST')
|
|
49
|
+
assert.equal(authModule.unsecuredRoutes.post['/api/case'], true)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('should allow multiple routes for same method', () => {
|
|
53
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
54
|
+
authModule.unsecuredRoutes = { post: {}, get: {}, put: {}, patch: {}, delete: {} }
|
|
55
|
+
authModule.log = () => {}
|
|
56
|
+
|
|
57
|
+
authModule.unsecureRoute('/api/a', 'get')
|
|
58
|
+
authModule.unsecureRoute('/api/b', 'get')
|
|
59
|
+
assert.equal(authModule.unsecuredRoutes.get['/api/a'], true)
|
|
60
|
+
assert.equal(authModule.unsecuredRoutes.get['/api/b'], true)
|
|
61
|
+
})
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
describe('#secureRoute()', () => {
|
|
65
|
+
it('should delegate to permissions.secureRoute', () => {
|
|
66
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
67
|
+
const secured = []
|
|
68
|
+
authModule.permissions = {
|
|
69
|
+
secureRoute: (route, method, scopes) => secured.push({ route, method, scopes })
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
authModule.secureRoute('/api/users', 'get', ['read:users'])
|
|
73
|
+
assert.equal(secured.length, 1)
|
|
74
|
+
assert.equal(secured[0].route, '/api/users')
|
|
75
|
+
assert.equal(secured[0].method, 'get')
|
|
76
|
+
assert.deepEqual(secured[0].scopes, ['read:users'])
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('#initAuthData()', () => {
|
|
81
|
+
it('should call AuthUtils.initAuthData', async () => {
|
|
82
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
83
|
+
authModule.isEnabled = false
|
|
84
|
+
|
|
85
|
+
const req = { get: () => undefined, headers: {} }
|
|
86
|
+
await authModule.initAuthData(req)
|
|
87
|
+
assert.deepEqual(req.auth, {})
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('#rootMiddleware()', () => {
|
|
92
|
+
it('should call next after initAuthData resolves', async () => {
|
|
93
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
94
|
+
authModule.isEnabled = false
|
|
95
|
+
|
|
96
|
+
let nextCalled = false
|
|
97
|
+
const req = { get: () => undefined, headers: {} }
|
|
98
|
+
const res = {}
|
|
99
|
+
|
|
100
|
+
await new Promise((resolve) => {
|
|
101
|
+
authModule.rootMiddleware(req, res, () => {
|
|
102
|
+
nextCalled = true
|
|
103
|
+
resolve()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
assert.equal(nextCalled, true)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('should call next even if initAuthData fails', async () => {
|
|
110
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
111
|
+
authModule.isEnabled = true
|
|
112
|
+
// Force initAuthData to throw by making initAuthData fail
|
|
113
|
+
authModule.initAuthData = async () => { throw new Error('fail') }
|
|
114
|
+
|
|
115
|
+
let nextCalled = false
|
|
116
|
+
const req = {}
|
|
117
|
+
const res = {}
|
|
118
|
+
|
|
119
|
+
await new Promise((resolve) => {
|
|
120
|
+
authModule.rootMiddleware(req, res, () => {
|
|
121
|
+
nextCalled = true
|
|
122
|
+
resolve()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
assert.equal(nextCalled, true)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('#apiMiddleware()', () => {
|
|
130
|
+
it('should call next for unsecured routes without auth', async () => {
|
|
131
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
132
|
+
authModule.unsecuredRoutes = { get: { '/api/auth/check': true }, post: {}, put: {}, patch: {}, delete: {} }
|
|
133
|
+
authModule.isEnabled = false
|
|
134
|
+
|
|
135
|
+
let nextCalled = false
|
|
136
|
+
const req = {
|
|
137
|
+
get: () => undefined,
|
|
138
|
+
headers: {},
|
|
139
|
+
method: 'GET',
|
|
140
|
+
baseUrl: '/api/auth',
|
|
141
|
+
route: { path: '/check/' },
|
|
142
|
+
originalUrl: '/api/auth/check'
|
|
143
|
+
}
|
|
144
|
+
const res = { sendError: () => {} }
|
|
145
|
+
|
|
146
|
+
await authModule.apiMiddleware(req, res, () => { nextCalled = true })
|
|
147
|
+
assert.equal(nextCalled, true)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should send error for secured routes when initAuthData fails', async () => {
|
|
151
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
152
|
+
authModule.unsecuredRoutes = { get: {}, post: {}, put: {}, patch: {}, delete: {} }
|
|
153
|
+
authModule.log = () => {}
|
|
154
|
+
const initError = { statusCode: 401, message: 'Unauthenticated' }
|
|
155
|
+
authModule.initAuthData = async () => { throw initError }
|
|
156
|
+
|
|
157
|
+
let sentError
|
|
158
|
+
const req = {
|
|
159
|
+
method: 'GET',
|
|
160
|
+
baseUrl: '/api',
|
|
161
|
+
route: { path: '/users/' },
|
|
162
|
+
originalUrl: '/api/users'
|
|
163
|
+
}
|
|
164
|
+
const res = { sendError: (e) => { sentError = e } }
|
|
165
|
+
|
|
166
|
+
await authModule.apiMiddleware(req, res, () => {})
|
|
167
|
+
assert.equal(sentError, initError)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('should check permissions for secured routes', async () => {
|
|
171
|
+
const authModule = new AuthModule(createMockApp(), { name: 'test-auth' })
|
|
172
|
+
authModule.unsecuredRoutes = { get: {}, post: {}, put: {}, patch: {}, delete: {} }
|
|
173
|
+
authModule.isEnabled = false
|
|
174
|
+
let permissionsChecked = false
|
|
175
|
+
authModule.permissions = {
|
|
176
|
+
check: async () => { permissionsChecked = true }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const req = {
|
|
180
|
+
get: () => undefined,
|
|
181
|
+
headers: {},
|
|
182
|
+
method: 'GET',
|
|
183
|
+
baseUrl: '/api',
|
|
184
|
+
route: { path: '/secure/' },
|
|
185
|
+
originalUrl: '/api/secure'
|
|
186
|
+
}
|
|
187
|
+
const res = {}
|
|
188
|
+
|
|
189
|
+
await authModule.apiMiddleware(req, res, () => {})
|
|
190
|
+
assert.equal(permissionsChecked, true)
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import AuthToken from '../lib/AuthToken.js'
|
|
4
|
+
|
|
5
|
+
describe('AuthToken', () => {
|
|
6
|
+
describe('#getSignature()', () => {
|
|
7
|
+
it('should extract signature from JWT token', () => {
|
|
8
|
+
const token = 'header.payload.signature'
|
|
9
|
+
const signature = AuthToken.getSignature(token)
|
|
10
|
+
assert.equal(signature, 'signature')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('should return undefined for malformed token', () => {
|
|
14
|
+
const token = 'invalid'
|
|
15
|
+
const signature = AuthToken.getSignature(token)
|
|
16
|
+
assert.equal(signature, undefined)
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('should extract third part from token with more than three parts', () => {
|
|
20
|
+
const token = 'header.payload.signature.extra'
|
|
21
|
+
const signature = AuthToken.getSignature(token)
|
|
22
|
+
assert.equal(signature, 'signature')
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('should handle empty token parts', () => {
|
|
26
|
+
const token = '..'
|
|
27
|
+
const signature = AuthToken.getSignature(token)
|
|
28
|
+
assert.equal(signature, '')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('should handle token with only two parts', () => {
|
|
32
|
+
const token = 'header.payload'
|
|
33
|
+
const signature = AuthToken.getSignature(token)
|
|
34
|
+
assert.equal(signature, undefined)
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should handle realistic JWT signature', () => {
|
|
38
|
+
const token = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.abc123def456'
|
|
39
|
+
const signature = AuthToken.getSignature(token)
|
|
40
|
+
assert.equal(signature, 'abc123def456')
|
|
41
|
+
})
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe('#isSuper()', () => {
|
|
45
|
+
it('should return true for super user scope', () => {
|
|
46
|
+
const scopes = ['*:*']
|
|
47
|
+
assert.equal(AuthToken.isSuper(scopes), true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('should return false for empty scopes', () => {
|
|
51
|
+
const scopes = []
|
|
52
|
+
assert.equal(AuthToken.isSuper(scopes), false)
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should return false for regular scopes', () => {
|
|
56
|
+
const scopes = ['read:users', 'write:content']
|
|
57
|
+
assert.equal(AuthToken.isSuper(scopes), false)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('should return false when super scope is not the only scope', () => {
|
|
61
|
+
const scopes = ['*:*', 'read:users']
|
|
62
|
+
assert.equal(AuthToken.isSuper(scopes), false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should return false for similar but not exact super scope', () => {
|
|
66
|
+
const scopes = ['*:']
|
|
67
|
+
assert.equal(AuthToken.isSuper(scopes), false)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('should return false for partial wildcard scopes', () => {
|
|
71
|
+
assert.equal(AuthToken.isSuper(['*:users']), false)
|
|
72
|
+
assert.equal(AuthToken.isSuper(['read:*']), false)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('should return false for single non-super scope', () => {
|
|
76
|
+
assert.equal(AuthToken.isSuper(['admin:all']), false)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
describe('.generate()', () => {
|
|
81
|
+
it('should be a static method', () => {
|
|
82
|
+
assert.equal(typeof AuthToken.generate, 'function')
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe('.decode()', () => {
|
|
87
|
+
it('should be a static method', () => {
|
|
88
|
+
assert.equal(typeof AuthToken.decode, 'function')
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
describe('.find()', () => {
|
|
93
|
+
it('should be a static method', () => {
|
|
94
|
+
assert.equal(typeof AuthToken.find, 'function')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('.revoke()', () => {
|
|
99
|
+
it('should be a static method', () => {
|
|
100
|
+
assert.equal(typeof AuthToken.revoke, 'function')
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('.initRequestData()', () => {
|
|
105
|
+
it('should be a static method', () => {
|
|
106
|
+
assert.equal(typeof AuthToken.initRequestData, 'function')
|
|
107
|
+
})
|
|
108
|
+
})
|
|
109
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { describe, it } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import AuthUtils from '../lib/AuthUtils.js'
|
|
4
|
+
|
|
5
|
+
describe('AuthUtils', () => {
|
|
6
|
+
describe('#createEmptyStore()', () => {
|
|
7
|
+
it('should return an object with empty arrays for HTTP methods', () => {
|
|
8
|
+
const store = AuthUtils.createEmptyStore()
|
|
9
|
+
assert.equal(typeof store, 'object')
|
|
10
|
+
assert.ok(Array.isArray(store.post))
|
|
11
|
+
assert.ok(Array.isArray(store.get))
|
|
12
|
+
assert.ok(Array.isArray(store.put))
|
|
13
|
+
assert.ok(Array.isArray(store.patch))
|
|
14
|
+
assert.ok(Array.isArray(store.delete))
|
|
15
|
+
assert.equal(store.post.length, 0)
|
|
16
|
+
assert.equal(store.get.length, 0)
|
|
17
|
+
assert.equal(store.put.length, 0)
|
|
18
|
+
assert.equal(store.patch.length, 0)
|
|
19
|
+
assert.equal(store.delete.length, 0)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('should return exactly five HTTP method keys', () => {
|
|
23
|
+
const store = AuthUtils.createEmptyStore()
|
|
24
|
+
assert.equal(Object.keys(store).length, 5)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('should return a new object each time', () => {
|
|
28
|
+
const store1 = AuthUtils.createEmptyStore()
|
|
29
|
+
const store2 = AuthUtils.createEmptyStore()
|
|
30
|
+
assert.notEqual(store1, store2)
|
|
31
|
+
assert.notEqual(store1.post, store2.post)
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
describe('#initAuthData()', () => {
|
|
36
|
+
it('should initialize req.auth as empty object when no auth header', async () => {
|
|
37
|
+
const req = {
|
|
38
|
+
get: () => undefined,
|
|
39
|
+
headers: {}
|
|
40
|
+
}
|
|
41
|
+
await AuthUtils.initAuthData(req)
|
|
42
|
+
assert.deepEqual(req.auth, {})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('should parse Authorization header with Bearer token', async () => {
|
|
46
|
+
const req = {
|
|
47
|
+
get: (header) => header === 'Authorization' ? 'Bearer abc123' : undefined,
|
|
48
|
+
headers: {}
|
|
49
|
+
}
|
|
50
|
+
await AuthUtils.initAuthData(req)
|
|
51
|
+
assert.equal(typeof req.auth, 'object')
|
|
52
|
+
assert.equal(typeof req.auth.header, 'object')
|
|
53
|
+
assert.equal(req.auth.header.type, 'Bearer')
|
|
54
|
+
assert.equal(req.auth.header.value, 'abc123')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('should parse Authorization header from headers object', async () => {
|
|
58
|
+
const req = {
|
|
59
|
+
get: () => undefined,
|
|
60
|
+
headers: { Authorization: 'Basic xyz789' }
|
|
61
|
+
}
|
|
62
|
+
await AuthUtils.initAuthData(req)
|
|
63
|
+
assert.equal(typeof req.auth, 'object')
|
|
64
|
+
assert.equal(typeof req.auth.header, 'object')
|
|
65
|
+
assert.equal(req.auth.header.type, 'Basic')
|
|
66
|
+
assert.equal(req.auth.header.value, 'xyz789')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should handle auth headers with only type', async () => {
|
|
70
|
+
const req = {
|
|
71
|
+
get: (header) => header === 'Authorization' ? 'Bearer' : undefined,
|
|
72
|
+
headers: {}
|
|
73
|
+
}
|
|
74
|
+
await AuthUtils.initAuthData(req)
|
|
75
|
+
assert.equal(typeof req.auth, 'object')
|
|
76
|
+
assert.equal(typeof req.auth.header, 'object')
|
|
77
|
+
assert.equal(req.auth.header.type, 'Bearer')
|
|
78
|
+
assert.equal(req.auth.header.value, undefined)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should prefer req.get() over req.headers', async () => {
|
|
82
|
+
const req = {
|
|
83
|
+
get: (header) => header === 'Authorization' ? 'Bearer fromGet' : undefined,
|
|
84
|
+
headers: { Authorization: 'Basic fromHeaders' }
|
|
85
|
+
}
|
|
86
|
+
await AuthUtils.initAuthData(req)
|
|
87
|
+
assert.equal(req.auth.header.type, 'Bearer')
|
|
88
|
+
assert.equal(req.auth.header.value, 'fromGet')
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should overwrite any existing req.auth', async () => {
|
|
92
|
+
const req = {
|
|
93
|
+
get: () => undefined,
|
|
94
|
+
headers: {},
|
|
95
|
+
auth: { stale: true }
|
|
96
|
+
}
|
|
97
|
+
await AuthUtils.initAuthData(req)
|
|
98
|
+
assert.deepEqual(req.auth, {})
|
|
99
|
+
assert.equal(req.auth.stale, undefined)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { App, Hook } from 'adapt-authoring-core'
|
|
4
|
+
import Authentication from '../lib/Authentication.js'
|
|
5
|
+
import AbstractAuthModule from '../lib/AbstractAuthModule.js'
|
|
6
|
+
|
|
7
|
+
function createMockApp () {
|
|
8
|
+
const moduleLoadedHook = new Hook()
|
|
9
|
+
return {
|
|
10
|
+
logger: { log: () => {} },
|
|
11
|
+
dependencyloader: { moduleLoadedHook },
|
|
12
|
+
errors: {
|
|
13
|
+
DUPL_AUTH_PLUGIN_REG: {
|
|
14
|
+
setData: (data) => Object.assign(new Error(`Duplicate plugin: ${data.name}`), { code: 'DUPL_AUTH_PLUGIN_REG' })
|
|
15
|
+
},
|
|
16
|
+
AUTH_PLUGIN_INVALID_CLASS: {
|
|
17
|
+
setData: (data) => Object.assign(new Error(`Invalid class: ${data.name}`), { code: 'AUTH_PLUGIN_INVALID_CLASS' })
|
|
18
|
+
},
|
|
19
|
+
NOT_FOUND: {
|
|
20
|
+
setData: (data) => Object.assign(new Error(`Not found: ${data.id}`), { code: 'NOT_FOUND' })
|
|
21
|
+
},
|
|
22
|
+
INVALID_PARAMS: {
|
|
23
|
+
setData: (data) => Object.assign(new Error(`Invalid params: ${data.params}`), { code: 'INVALID_PARAMS' })
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function mockAppInstance (mockApp) {
|
|
30
|
+
Object.defineProperty(App, 'instance', {
|
|
31
|
+
get: () => mockApp,
|
|
32
|
+
configurable: true
|
|
33
|
+
})
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function restoreAppInstance () {
|
|
37
|
+
// Delete the overridden property to restore the original getter from the prototype
|
|
38
|
+
delete App.instance
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('Authentication', () => {
|
|
42
|
+
let mockApp
|
|
43
|
+
|
|
44
|
+
before(() => {
|
|
45
|
+
mockApp = createMockApp()
|
|
46
|
+
mockAppInstance(mockApp)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
after(() => {
|
|
50
|
+
restoreAppInstance()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('constructor', () => {
|
|
54
|
+
it('should initialize plugins object', () => {
|
|
55
|
+
const authentication = new Authentication()
|
|
56
|
+
assert.equal(typeof authentication.plugins, 'object')
|
|
57
|
+
assert.equal(Object.keys(authentication.plugins).length, 0)
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('#registerPlugin()', () => {
|
|
62
|
+
it('should register a valid auth plugin', () => {
|
|
63
|
+
const authentication = new Authentication()
|
|
64
|
+
const plugin = new AbstractAuthModule(mockApp, { name: 'test-plugin' })
|
|
65
|
+
authentication.registerPlugin('local', plugin)
|
|
66
|
+
assert.equal(authentication.plugins.local, plugin)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('should throw DUPL_AUTH_PLUGIN_REG for duplicate type', () => {
|
|
70
|
+
const authentication = new Authentication()
|
|
71
|
+
const plugin = new AbstractAuthModule(mockApp, { name: 'test-plugin' })
|
|
72
|
+
authentication.registerPlugin('local', plugin)
|
|
73
|
+
|
|
74
|
+
assert.throws(() => {
|
|
75
|
+
authentication.registerPlugin('local', plugin)
|
|
76
|
+
}, (err) => {
|
|
77
|
+
assert.equal(err.code, 'DUPL_AUTH_PLUGIN_REG')
|
|
78
|
+
return true
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('should throw AUTH_PLUGIN_INVALID_CLASS for non-AbstractAuthModule instance', () => {
|
|
83
|
+
const authentication = new Authentication()
|
|
84
|
+
|
|
85
|
+
assert.throws(() => {
|
|
86
|
+
authentication.registerPlugin('invalid', { type: 'invalid' })
|
|
87
|
+
}, (err) => {
|
|
88
|
+
assert.equal(err.code, 'AUTH_PLUGIN_INVALID_CLASS')
|
|
89
|
+
return true
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('should allow registering multiple different types', () => {
|
|
94
|
+
const authentication = new Authentication()
|
|
95
|
+
const plugin1 = new AbstractAuthModule(mockApp, { name: 'plugin1' })
|
|
96
|
+
const plugin2 = new AbstractAuthModule(mockApp, { name: 'plugin2' })
|
|
97
|
+
authentication.registerPlugin('local', plugin1)
|
|
98
|
+
authentication.registerPlugin('oauth', plugin2)
|
|
99
|
+
assert.equal(authentication.plugins.local, plugin1)
|
|
100
|
+
assert.equal(authentication.plugins.oauth, plugin2)
|
|
101
|
+
assert.equal(Object.keys(authentication.plugins).length, 2)
|
|
102
|
+
})
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
describe('#registerUser()', () => {
|
|
106
|
+
it('should throw NOT_FOUND if auth plugin is not registered', async () => {
|
|
107
|
+
const authentication = new Authentication()
|
|
108
|
+
|
|
109
|
+
await assert.rejects(
|
|
110
|
+
() => authentication.registerUser('nonexistent', { email: 'test@test.com' }),
|
|
111
|
+
(err) => {
|
|
112
|
+
assert.equal(err.code, 'NOT_FOUND')
|
|
113
|
+
return true
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('should call users.insert with correct data when plugin exists', async () => {
|
|
119
|
+
const authentication = new Authentication()
|
|
120
|
+
const insertedData = {}
|
|
121
|
+
mockApp.waitForModule = async () => ({
|
|
122
|
+
insert: (data, opts) => {
|
|
123
|
+
insertedData.data = data
|
|
124
|
+
insertedData.opts = opts
|
|
125
|
+
return data
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
const plugin = new AbstractAuthModule(mockApp, { name: 'test-plugin' })
|
|
130
|
+
plugin.userSchema = 'localuser'
|
|
131
|
+
authentication.plugins.local = plugin
|
|
132
|
+
|
|
133
|
+
await authentication.registerUser('local', { email: 'user@test.com' })
|
|
134
|
+
assert.equal(insertedData.data.email, 'user@test.com')
|
|
135
|
+
assert.equal(insertedData.data.authType, 'local')
|
|
136
|
+
assert.equal(insertedData.opts.schemaName, 'localuser')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('#disavowUser()', () => {
|
|
141
|
+
it('should throw INVALID_PARAMS if userId is missing', async () => {
|
|
142
|
+
const authentication = new Authentication()
|
|
143
|
+
|
|
144
|
+
await assert.rejects(
|
|
145
|
+
() => authentication.disavowUser({}),
|
|
146
|
+
(err) => {
|
|
147
|
+
assert.equal(err.code, 'INVALID_PARAMS')
|
|
148
|
+
return true
|
|
149
|
+
}
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('static #init()', () => {
|
|
155
|
+
it('should be a static method', () => {
|
|
156
|
+
assert.equal(typeof Authentication.init, 'function')
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { describe, it, before, after } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { App } from 'adapt-authoring-core'
|
|
4
|
+
import Permissions from '../lib/Permissions.js'
|
|
5
|
+
|
|
6
|
+
function mockAppInstance (mockApp) {
|
|
7
|
+
Object.defineProperty(App, 'instance', {
|
|
8
|
+
get: () => mockApp,
|
|
9
|
+
configurable: true
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function restoreAppInstance () {
|
|
14
|
+
delete App.instance
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('Permissions', () => {
|
|
18
|
+
let permissions
|
|
19
|
+
|
|
20
|
+
before(async () => {
|
|
21
|
+
mockAppInstance({
|
|
22
|
+
onReady: () => Promise.resolve({
|
|
23
|
+
waitForModule: async () => [
|
|
24
|
+
{ getConfig: () => false }, // auth with logMissingPermissions=false
|
|
25
|
+
{ api: { flattenRouters: () => [] } } // server
|
|
26
|
+
]
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
permissions = await Permissions.init()
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
after(() => {
|
|
33
|
+
restoreAppInstance()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('#secureRoute()', () => {
|
|
37
|
+
it('should secure a route with scopes', () => {
|
|
38
|
+
permissions.secureRoute('/api/users/:id', 'get', ['read:users'])
|
|
39
|
+
const scopes = permissions.getScopesForRoute('get', '/api/users/123')
|
|
40
|
+
assert.deepEqual(scopes, ['read:users'])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('should handle multiple scopes', () => {
|
|
44
|
+
permissions.secureRoute('/api/content/:id', 'post', ['write:content', 'create:content'])
|
|
45
|
+
const scopes = permissions.getScopesForRoute('post', '/api/content/456')
|
|
46
|
+
assert.deepEqual(scopes, ['write:content', 'create:content'])
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('should match routes with path parameters', () => {
|
|
50
|
+
permissions.secureRoute('/api/resources/:resourceId/items/:itemId', 'put', ['write:resources'])
|
|
51
|
+
const scopes = permissions.getScopesForRoute('put', '/api/resources/abc/items/xyz')
|
|
52
|
+
assert.deepEqual(scopes, ['write:resources'])
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('should normalize HTTP method to lowercase', () => {
|
|
56
|
+
permissions.secureRoute('/api/admin', 'DELETE', ['delete:admin'])
|
|
57
|
+
const scopes = permissions.getScopesForRoute('delete', '/api/admin')
|
|
58
|
+
assert.deepEqual(scopes, ['delete:admin'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('should store routes as regexp/scopes pairs', () => {
|
|
62
|
+
const initialLength = permissions.routes.patch.length
|
|
63
|
+
permissions.secureRoute('/api/items/:id', 'patch', ['update:items'])
|
|
64
|
+
assert.equal(permissions.routes.patch.length, initialLength + 1)
|
|
65
|
+
const entry = permissions.routes.patch[permissions.routes.patch.length - 1]
|
|
66
|
+
assert.ok(Array.isArray(entry))
|
|
67
|
+
assert.equal(entry.length, 2)
|
|
68
|
+
assert.ok(entry[0] instanceof RegExp)
|
|
69
|
+
assert.deepEqual(entry[1], ['update:items'])
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('#getScopesForRoute()', () => {
|
|
74
|
+
it('should return undefined for unsecured route', () => {
|
|
75
|
+
const scopes = permissions.getScopesForRoute('get', '/api/nonexistent')
|
|
76
|
+
assert.equal(scopes, undefined)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should be case-sensitive for HTTP methods', () => {
|
|
80
|
+
permissions.secureRoute('/api/test', 'delete', ['delete:test'])
|
|
81
|
+
const scopes = permissions.getScopesForRoute('delete', '/api/test')
|
|
82
|
+
assert.deepEqual(scopes, ['delete:test'])
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('should not match wrong HTTP method', () => {
|
|
86
|
+
permissions.secureRoute('/api/different', 'patch', ['patch:different'])
|
|
87
|
+
const scopes = permissions.getScopesForRoute('get', '/api/different')
|
|
88
|
+
assert.equal(scopes, undefined)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('should handle exact path matches', () => {
|
|
92
|
+
permissions.secureRoute('/api/exact/path', 'get', ['read:exact'])
|
|
93
|
+
const scopes = permissions.getScopesForRoute('get', '/api/exact/path')
|
|
94
|
+
assert.deepEqual(scopes, ['read:exact'])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('should not match partial path', () => {
|
|
98
|
+
permissions.secureRoute('/api/full', 'get', ['read:full'])
|
|
99
|
+
const scopes = permissions.getScopesForRoute('get', '/api/full/extra')
|
|
100
|
+
assert.equal(scopes, undefined)
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
describe('#check()', () => {
|
|
105
|
+
it('should allow super users regardless of scopes', async () => {
|
|
106
|
+
permissions.secureRoute('/api/restricted', 'get', ['admin:all'])
|
|
107
|
+
|
|
108
|
+
const req = {
|
|
109
|
+
baseUrl: '/api',
|
|
110
|
+
path: '/restricted',
|
|
111
|
+
method: 'get',
|
|
112
|
+
auth: { isSuper: true, scopes: ['*:*'] }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
await assert.doesNotReject(() => permissions.check(req))
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('should allow users with matching scopes', async () => {
|
|
119
|
+
permissions.secureRoute('/api/data', 'get', ['read:data'])
|
|
120
|
+
|
|
121
|
+
const req = {
|
|
122
|
+
baseUrl: '/api',
|
|
123
|
+
path: '/data',
|
|
124
|
+
method: 'get',
|
|
125
|
+
auth: { isSuper: false, scopes: ['read:data', 'write:data'] }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
await assert.doesNotReject(() => permissions.check(req))
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should strip trailing slash from path', async () => {
|
|
132
|
+
permissions.secureRoute('/api/trailing', 'get', ['read:trailing'])
|
|
133
|
+
|
|
134
|
+
const req = {
|
|
135
|
+
baseUrl: '/api',
|
|
136
|
+
path: '/trailing/',
|
|
137
|
+
method: 'get',
|
|
138
|
+
auth: { isSuper: false, scopes: ['read:trailing'] }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
await assert.doesNotReject(() => permissions.check(req))
|
|
142
|
+
})
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
describe('constructor', () => {
|
|
146
|
+
it('should initialize routes as empty store', () => {
|
|
147
|
+
assert.ok(Array.isArray(permissions.routes.get))
|
|
148
|
+
assert.ok(Array.isArray(permissions.routes.post))
|
|
149
|
+
assert.ok(Array.isArray(permissions.routes.put))
|
|
150
|
+
assert.ok(Array.isArray(permissions.routes.patch))
|
|
151
|
+
assert.ok(Array.isArray(permissions.routes.delete))
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
})
|