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.
@@ -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.6",
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-roles": "github:adapt-security/adapt-authoring-roles",
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-core": "^1.7.0",
19
- "adapt-authoring-jsonschema": "^1.1.5",
20
- "adapt-authoring-mongodb": "^1.1.2",
21
- "adapt-authoring-roles": "^1.1.2",
22
- "adapt-authoring-server": "^1.2.0",
23
- "adapt-authoring-sessions": "^1.0.1",
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
+ })