adapt-authoring-auth 1.0.1 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,19 +1,15 @@
1
- name: Add to main project
1
+ # Calls the org-level reusable workflow to add PRs to the TODO Board
2
+
3
+ name: Add PR to Project
2
4
 
3
5
  on:
4
- issues:
5
- types:
6
- - opened
7
6
  pull_request:
8
7
  types:
9
8
  - opened
9
+ - reopened
10
10
 
11
11
  jobs:
12
12
  add-to-project:
13
- name: Add to main project
14
- runs-on: ubuntu-latest
15
- steps:
16
- - uses: actions/add-to-project@v0.1.0
17
- with:
18
- project-url: https://github.com/orgs/adapt-security/projects/5
19
- github-token: ${{ secrets.PROJECTS_SECRET }}
13
+ uses: adapt-security/.github/.github/workflows/new.yml@main
14
+ secrets:
15
+ PROJECTS_SECRET: ${{ secrets.PROJECTS_SECRET }}
@@ -1,5 +1,9 @@
1
1
  {
2
2
  "documentation": {
3
- "enable": true
3
+ "enable": true,
4
+ "manualPages": {
5
+ "auth-permissions.md": "basics",
6
+ "creating-auth-plugins.md": "development"
7
+ }
4
8
  }
5
9
  }
@@ -0,0 +1,280 @@
1
+ # Authentication and permissions
2
+
3
+ The Adapt authoring tool uses a token-based authentication system with role-based access control (RBAC) provided through permissions scopes. This guide covers how authentication works, how to configure authorisation, and how to manage permissions.
4
+
5
+ ## Authentication
6
+
7
+ Authentication verifies user identity. The system supports multiple authentication methods through plugins (local username/password, OAuth providers like GitHub or Okta, etc.).
8
+
9
+ ### How authentication works
10
+
11
+ 1. User submits credentials to an auth plugin endpoint (e.g., `POST /api/auth/local`)
12
+ 2. The auth plugin validates the credentials
13
+ 3. On success, a JWT ([JSON Web Token](https://www.jwt.io/introduction#what-is-json-web-token)) is generated and returned to the client
14
+ 4. The client includes the token in subsequent requests via the `Authorization` header
15
+ 5. The server validates the token and checks permissions for each request
16
+
17
+ ### Request auth data
18
+
19
+ Authenticated requests have auth data attached to `req.auth`:
20
+
21
+ ```javascript
22
+ async myHandler (req, res, next) {
23
+ const {
24
+ user, // The authenticated user document
25
+ scopes, // Array of permission scopes
26
+ isSuper, // Whether user has super privileges
27
+ token, // The decoded JWT
28
+ userSchemaName // Schema used for the user
29
+ } = req.auth
30
+ }
31
+ ```
32
+
33
+ ### Making authenticated requests
34
+
35
+ Include the JWT in the `Authorization` header:
36
+
37
+ ```
38
+ Authorization: Bearer <token>
39
+ ```
40
+
41
+ ### Checking authentication status
42
+
43
+ Retrieve the current user's details and permissions if authenticated (error if not):
44
+
45
+ ```
46
+ GET /api/auth/check
47
+ ```
48
+
49
+ ### Generating tokens
50
+
51
+ For API integrations etc. tokens can be generated manually with custom lifespans:
52
+
53
+ > Requires the `generatetoken:auth` scope
54
+
55
+ ```
56
+ POST /api/auth/generatetoken
57
+ Content-Type: application/json
58
+
59
+ {
60
+ "lifespan": "30d"
61
+ }
62
+ ```
63
+
64
+ ### Revoking tokens
65
+
66
+ To log out or invalidate sessions and revoke the current user's token:
67
+
68
+ ```
69
+ POST /api/auth/disavow
70
+ ```
71
+
72
+ ### Disabling authentication
73
+
74
+ For development only, authentication can be disabled.
75
+
76
+ > **Warning:** Auth cannot be disabled in production environments. The system enforces this automatically.
77
+
78
+ ```javascript
79
+ module.exports = {
80
+ 'adapt-authoring-auth': {
81
+ isEnabled: false
82
+ }
83
+ }
84
+ ```
85
+
86
+ ### Authentication errors
87
+
88
+ | Error | Description |
89
+ | ----- | ----------- |
90
+ | `UNAUTHENTICATED` | No valid authentication provided |
91
+ | `AUTH_TOKEN_EXPIRED` | Token has expired |
92
+ | `AUTH_TOKEN_INVALID` | Token is malformed or tampered |
93
+ | `ACCOUNT_DISABLED` | User account is disabled |
94
+ | `ACCOUNT_LOCKED_TEMP` | Account temporarily locked (too many failed attempts) |
95
+ | `ACCOUNT_LOCKED_PERM` | Account permanently locked |
96
+ | `INVALID_LOGIN_DETAILS` | Wrong username or password |
97
+
98
+ ## Authorisation
99
+
100
+ Authorisation determines what authenticated users are allowed to do. The system uses roles to group permissions together.
101
+
102
+ ### Roles
103
+
104
+ Roles are collections of scopes (permissions) assigned to users. The system comes with three default roles:
105
+
106
+ **authuser** — Basic authenticated user:
107
+ - `clear:session`
108
+ - `read:config`
109
+ - `read:lang`
110
+ - `read:me`
111
+ - `write:me`
112
+ - `disavow:auth`
113
+
114
+ **contentcreator** _extends authuser_ — Can create and manage content:
115
+ - `export:adapt`
116
+ - `import:adapt`
117
+ - `preview:adapt`
118
+ - `publish:adapt`
119
+ - `read:assets`
120
+ - `write:assets`
121
+ - `read:content`
122
+ - `write:content`
123
+ - `read:contentplugins`
124
+ - `read:roles`
125
+ - `read:schema`
126
+ - `read:tags`
127
+ - `write:tags`
128
+ - `read:users`
129
+
130
+ **superuser** — Full access to everything:
131
+ - `*:*`
132
+
133
+ ### Role inheritance
134
+
135
+ Roles can extend other roles using the `extends` property. The child role inherits all scopes from its parent.
136
+
137
+ ```json
138
+ {
139
+ "shortName": "contentcreator",
140
+ "extends": "authuser",
141
+ "scopes": ["read:content", "write:content"]
142
+ }
143
+ ```
144
+
145
+ In this example, `contentcreator` has all scopes from `authuser` plus `read:content` and `write:content`.
146
+
147
+ ### Defining custom roles
148
+
149
+ Add roles via configuration in your config file:
150
+
151
+ ```javascript
152
+ module.exports = {
153
+ 'adapt-authoring-roles': {
154
+ roleDefinitions: [
155
+ {
156
+ shortName: 'reviewer',
157
+ displayName: 'Content Reviewer',
158
+ extends: 'authuser',
159
+ scopes: [
160
+ 'read:content',
161
+ 'read:assets',
162
+ 'preview:adapt'
163
+ ]
164
+ }
165
+ ]
166
+ }
167
+ }
168
+ ```
169
+
170
+ ### Default roles for new users
171
+
172
+ Configure which roles are assigned to new users:
173
+
174
+ ```javascript
175
+ module.exports = {
176
+ 'adapt-authoring-roles': {
177
+ defaultRoles: ['authuser'],
178
+ defaultRolesForAuthTypes: {
179
+ local: ['contentcreator']
180
+ }
181
+ }
182
+ }
183
+ ```
184
+
185
+ ### Authorisation errors
186
+
187
+ | Error | Description |
188
+ | ----- | ----------- |
189
+ | `UNAUTHORISED` | User lacks required permissions |
190
+
191
+ ## Permissions
192
+
193
+ Once a user has been authorised and authenticated, the final permissions checks are performed. These are to ensure that the user has access to the specific resources they are requesting.
194
+ Permissions control access to specific actions and resources. They are enforced in two ways:
195
+ - Using scopes on routes
196
+ - Specific manual checks on individual resource items
197
+
198
+ ### Scopes
199
+
200
+ Scopes are strings in the format `action:resource` that define what a user can do in a plain human-readable way. You will find the most common actions are `read` and `write`, but there are various cases where a more specific and descriptive action is called for.
201
+
202
+ Some examples are: `read:content`, `delete:assets` and `preview:adapt`.
203
+
204
+ ### Securing routes
205
+
206
+ **Using AbstractApiModule:**
207
+
208
+ When extending `AbstractApiModule`, define permissions in your route configuration:
209
+
210
+ ```javascript
211
+ async setValues () {
212
+ this.root = 'myresource'
213
+ this.schemaName = 'myresource'
214
+ this.collectionName = 'myresources'
215
+
216
+ this.routes = [
217
+ {
218
+ route: '/',
219
+ handlers: {
220
+ get: this.getHandler.bind(this),
221
+ post: this.postHandler.bind(this)
222
+ },
223
+ permissions: {
224
+ get: ['read:myresource'],
225
+ post: ['write:myresource']
226
+ }
227
+ }
228
+ ]
229
+ }
230
+ ```
231
+
232
+ **Using secureRoute directly:**
233
+
234
+ For custom routes outside `AbstractApiModule`:
235
+
236
+ ```javascript
237
+ async init () {
238
+ const auth = await this.app.waitForModule('auth')
239
+
240
+ auth.secureRoute('/api/custom/action', 'post', ['custom:action'])
241
+ }
242
+ ```
243
+
244
+ **Unsecuring routes:**
245
+
246
+ Some routes need to be publicly accessible (e.g., login endpoints):
247
+
248
+ > **Warning:** Unsecured routes are accessible without authentication. Use sparingly.
249
+
250
+ ```javascript
251
+ async init () {
252
+ const auth = await this.app.waitForModule('auth')
253
+
254
+ auth.unsecureRoute('/api/public/data', 'get')
255
+ }
256
+ ```
257
+
258
+ ### Access control hooks
259
+
260
+ Although a user may have access to a resource, there may be occasions when more fine-grained control is necessary to filter out specific resources. In this case, you can use hooks to implement custom access control logic.
261
+
262
+ The `AbstractApiModule#accessCheckHook` is called for each document returned by queries, allowing fine-grained access control:
263
+
264
+ ```javascript
265
+ async init () {
266
+ await super.init()
267
+ const content = await this.app.waitForModule('content')
268
+
269
+ // Check if user owns the document
270
+ content.accessCheckHook.tap((req, doc) => {
271
+ if (doc.createdBy.toString() !== req.auth.user._id.toString()) {
272
+ throw this.app.errors.UNAUTHORISED
273
+ }
274
+ })
275
+ }
276
+ ```
277
+
278
+ ## Further reading
279
+
280
+ - [Creating auth plugins](creating-auth-plugins.md) — How to implement custom authentication methods
@@ -0,0 +1,192 @@
1
+ # Creating auth plugins
2
+
3
+ > Preliminary reading: [Authentication and permissions](auth-permissions.md) — Overview of the auth system
4
+ ---
5
+
6
+ Auth plugins allows for implementations of different authentication methods.
7
+
8
+ ## Basic structure
9
+
10
+ Extend `AbstractAuthModule` and implement the required methods:
11
+
12
+ ```javascript
13
+ import { AbstractAuthModule } from 'adapt-authoring-auth'
14
+
15
+ class MyAuthModule extends AbstractAuthModule {
16
+ async setValues () {
17
+ this.type = 'myauth' // Unique identifier
18
+ this.userSchema = 'myauthuser' // Optional custom user schema
19
+ }
20
+
21
+ async authenticate (user, req, res) {
22
+ // Verify credentials - throw on failure
23
+ }
24
+ }
25
+ ```
26
+
27
+ ## Reference
28
+
29
+ ### Required values
30
+
31
+ | Property | Description |
32
+ | -------- | ----------- |
33
+ | `this.type` | Unique identifier for the auth type (e.g., `'local'`, `'github'`) |
34
+
35
+ ### Optional values
36
+
37
+ | Property | Default | Description |
38
+ | -------- | ------- | ----------- |
39
+ | `this.userSchema` | `'user'` | Schema name for validating users of this auth type - allows custom data to be added to users |
40
+ | `this.routes` | `[]` | Additional routes for the auth plugin |
41
+
42
+ ### Inherited methods
43
+
44
+ | Method | Description |
45
+ | ------ | ----------- |
46
+ | `register(data)` | Register a new user with this auth type |
47
+ | `setUserEnabled(user, isEnabled)` | Enable or disable a user account |
48
+ | `disavowUser(query)` | Revoke user tokens |
49
+ | `secureRoute(route, method, scopes)` | Secure a route |
50
+ | `unsecureRoute(route, method)` | Remove auth from a route |
51
+ | `comparePassword(plain, hashed)` | Compare a plain password against a hash |
52
+
53
+ ### Hooks
54
+
55
+ | Hook | Description |
56
+ | ---- | ----------- |
57
+ | `registerHook` | Invoked when a new user registers (mutable) |
58
+
59
+ ## Worked Example: GitHub OAuth
60
+
61
+ We will work through an example authentication plugin using GitHub OAuth.
62
+
63
+ For OAuth providers (GitHub, Okta, Google, etc.), the flow redirects users to the provider rather than validating credentials directly.
64
+
65
+ ### Key steps for OAuth plugins
66
+
67
+ 1. Extend `AbstractAuthModule` and set `this.type` to a unique identifier
68
+ 2. Add config & user schemas (if necessary)
69
+ 3. Use Passport.js with the appropriate strategy for your provider
70
+ 4. The OAuth callback should generate a token and store it in the session
71
+ 5. Mark OAuth routes as unsecured since users aren't authenticated yet
72
+ 6. Handle user registration if the OAuth profile doesn't match an existing user
73
+ 7. Add UI code for your plugin, and register it with the UI module (see [this page](ui-extensions))
74
+
75
+ **Below code is for illustrative purposes only, and is not guaranteed to work without modifications**
76
+
77
+ ### Creating Auth Module
78
+
79
+ ```javascript
80
+ import { AbstractAuthModule, AuthToken } from 'adapt-authoring-auth'
81
+ import passport from 'passport'
82
+ import { Strategy as GitHubStrategy } from 'passport-github2'
83
+
84
+ class GitHubAuthModule extends AbstractAuthModule {
85
+ async setValues () {
86
+ this.type = 'github'
87
+ this.userSchema = 'githubauthuser'
88
+ }
89
+
90
+ async init () {
91
+ await super.init()
92
+ const [server, users] = await this.app.waitForModule('server', 'users', 'sessions')
93
+
94
+ this.router.expressRouter.use(passport.initialize())
95
+ this.router.expressRouter.use(passport.session())
96
+
97
+ passport.use(new GitHubStrategy({
98
+ clientID: this.getConfig('clientID'),
99
+ clientSecret: this.getConfig('clientSecret'),
100
+ callbackURL: `//${server.getConfig('host')}:${server.getConfig('port')}${this.router.path}/callback`
101
+ }, async (accessToken, refreshToken, profile, done) => {
102
+ try {
103
+ // Find user by any of their GitHub emails
104
+ let [user] = await users.find({
105
+ $or: profile.emails.map(({ value }) => ({ email: value }))
106
+ })
107
+
108
+ if (!user && this.getConfig('registerUserWithRoles').length) {
109
+ user = await this.registerUser(profile)
110
+ }
111
+
112
+ return done(null, user || false)
113
+ } catch (e) {
114
+ return done(e)
115
+ }
116
+ }))
117
+
118
+ passport.serializeUser((user, done) => done(null, user))
119
+ passport.deserializeUser((obj, done) => done(null, obj))
120
+
121
+ // OAuth flow: redirect to provider, then handle callback
122
+ this.router.addRoute({
123
+ route: '/',
124
+ handlers: { get: passport.authenticate('github', { scope: ['user:email'] }) }
125
+ }, {
126
+ route: '/callback',
127
+ handlers: { get: passport.authenticate('github', { failureRedirect: '/' }) }
128
+ }, {
129
+ route: '/callback',
130
+ handlers: { get: this.onAuthenticated.bind(this) }
131
+ })
132
+
133
+ this.unsecureRoute('/', 'get')
134
+ this.unsecureRoute('/callback', 'get')
135
+ }
136
+
137
+ async registerUser (profile) {
138
+ const email = profile.emails[0].value
139
+ const nameParts = profile.displayName.split(' ')
140
+ const roles = await this.app.waitForModule('roles')
141
+ const roleNames = this.getConfig('registerUserWithRoles')
142
+ const matchedRoles = await roles.find({
143
+ $or: roleNames.map(shortName => ({ shortName }))
144
+ })
145
+
146
+ return this.register({
147
+ email,
148
+ firstName: nameParts[0] || profile.displayName,
149
+ lastName: nameParts[1] || '',
150
+ roles: matchedRoles.map(r => r._id.toString())
151
+ })
152
+ }
153
+
154
+ async onAuthenticated (req, res, next) {
155
+ try {
156
+ req.session.token = await AuthToken.generate(this.type, req.user)
157
+ res.redirect('/')
158
+ } catch (e) {
159
+ return next(e)
160
+ }
161
+ }
162
+ }
163
+ ```
164
+
165
+ ### Adding UI login support
166
+
167
+ For OAuth plugins, you may need to override the default login behaviour to redirect to the provider instead of showing a login form.
168
+
169
+ Create a UI plugin:
170
+
171
+ ```javascript
172
+ // plugins/myauth/index.js
173
+ define(function (require) {
174
+ const Origin = require('core/origin')
175
+
176
+ Origin.on('router:handleLogin', function () {
177
+ // Redirect to OAuth provider
178
+ window.location = window.origin + '/api/auth/myauth'
179
+ })
180
+ })
181
+ ```
182
+
183
+ Register the UI plugin in your module's `init` method:
184
+
185
+ ```javascript
186
+ async init () {
187
+ await super.init()
188
+
189
+ const ui = await this.app.waitForModule('ui')
190
+ ui.addUiPlugin(path.resolve(this.rootDir, 'plugins'))
191
+ }
192
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "adapt-authoring-auth",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
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",
@@ -11,7 +11,7 @@
11
11
  "adapt-authoring-roles": "github:adapt-security/adapt-authoring-roles",
12
12
  "adapt-authoring-users": "github:adapt-security/adapt-authoring-users",
13
13
  "express-session": "1.18.2",
14
- "jsonwebtoken": "9.0.2",
14
+ "jsonwebtoken": "9.0.3",
15
15
  "path-to-regexp": "^8.0.0"
16
16
  },
17
17
  "peerDependencies": {
@@ -1,16 +0,0 @@
1
- name: Add labelled PRs to project
2
-
3
- on:
4
- pull_request:
5
- types: [ labeled ]
6
-
7
- jobs:
8
- add-to-project:
9
- if: ${{ github.event.label.name == 'dependencies' }}
10
- name: Add to main project
11
- runs-on: ubuntu-latest
12
- steps:
13
- - uses: actions/add-to-project@v0.1.0
14
- with:
15
- project-url: https://github.com/orgs/adapt-security/projects/5
16
- github-token: ${{ secrets.PROJECTS_SECRET }}