ep_openid_connect 2.0.8 → 2.2.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.
@@ -1,6 +1,3 @@
1
- # You need to change lines 38 and 46 in case the plugin's name on npmjs.com is different
2
- # from the repository name
3
-
4
1
  name: "Backend tests"
5
2
 
6
3
  # any branch is useful for testing before a PR is submitted
@@ -17,6 +14,10 @@ jobs:
17
14
  runs-on: ubuntu-latest
18
15
 
19
16
  steps:
17
+ - uses: actions/setup-node@v2
18
+ with:
19
+ node-version: 12
20
+
20
21
  - name: Install libreoffice
21
22
  run: |
22
23
  sudo add-apt-repository -y ppa:libreoffice/ppa
@@ -32,19 +33,29 @@ jobs:
32
33
  - name: Install all dependencies and symlink for ep_etherpad-lite
33
34
  run: src/bin/installDeps.sh
34
35
 
35
- # clone this repository into node_modules/ep_plugin-name
36
36
  - name: Checkout plugin repository
37
37
  uses: actions/checkout@v2
38
38
  with:
39
- path: ./node_modules/${{github.event.repository.name}}
39
+ path: ./node_modules/__tmp
40
+
41
+ - name: Determine plugin name
42
+ id: plugin_name
43
+ run: |
44
+ cd ./node_modules/__tmp
45
+ npx -c 'printf %s\\n "::set-output name=plugin_name::${npm_package_name}"'
46
+
47
+ - name: Rename plugin directory
48
+ run: |
49
+ mv ./node_modules/__tmp ./node_modules/"${PLUGIN_NAME}"
50
+ env:
51
+ PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}
40
52
 
41
53
  - name: Install plugin dependencies
42
54
  run: |
43
- cd node_modules/${{github.event.repository.name}}
55
+ cd ./node_modules/"${PLUGIN_NAME}"
44
56
  npm ci
57
+ env:
58
+ PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}
45
59
 
46
60
  - name: Run the backend tests
47
61
  run: cd src && npm test
48
-
49
- ##ETHERPAD_NPM_V=2
50
- ## NPM configuration automatically created using src/bin/plugins/updateAllPluginsScript.sh
@@ -0,0 +1,92 @@
1
+ # Publicly credit Sauce Labs because they generously support open source
2
+ # projects.
3
+ name: "frontend tests powered by Sauce Labs"
4
+
5
+ on: [push]
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+
11
+ steps:
12
+ - name: Generate Sauce Labs strings
13
+ id: sauce_strings
14
+ run: |
15
+ printf %s\\n '::set-output name=name::${{github.event.repository.name}} ${{ github.workflow }} - ${{ github.job }}'
16
+ printf %s\\n '::set-output name=tunnel_id::${{ github.run_id }}-${{ github.run_number }}-${{ github.job }}'
17
+
18
+ - uses: actions/setup-node@v2
19
+ with:
20
+ node-version: 12
21
+
22
+ - name: Check out Etherpad core
23
+ uses: actions/checkout@v2
24
+ with:
25
+ repository: ether/etherpad-lite
26
+
27
+ - name: Check out the plugin
28
+ uses: actions/checkout@v2
29
+ with:
30
+ path: ./node_modules/__tmp
31
+
32
+ - name: export GIT_HASH to env
33
+ id: environment
34
+ run: |
35
+ cd ./node_modules/__tmp
36
+ echo "::set-output name=sha_short::$(git rev-parse --short ${{ github.sha }})"
37
+
38
+ - name: Determine plugin name
39
+ id: plugin_name
40
+ run: |
41
+ cd ./node_modules/__tmp
42
+ npx -c 'printf %s\\n "::set-output name=plugin_name::${npm_package_name}"'
43
+
44
+ - name: Rename plugin directory
45
+ env:
46
+ PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}
47
+ run: |
48
+ mv ./node_modules/__tmp ./node_modules/"${PLUGIN_NAME}"
49
+
50
+ - name: Install plugin dependencies
51
+ env:
52
+ PLUGIN_NAME: ${{ steps.plugin_name.outputs.plugin_name }}
53
+ run: |
54
+ cd ./node_modules/"${PLUGIN_NAME}"
55
+ npm ci
56
+
57
+ # This must be run after setting up the plugin, otherwise npm will try to
58
+ # hoist common dependencies by removing them from src/node_modules and
59
+ # installing them in the top-level node_modules. As of v6.14.10, npm's hoist
60
+ # logic appears to be buggy, because it sometimes removes dependencies from
61
+ # src/node_modules but fails to add them to the top-level node_modules. Even
62
+ # if npm correctly hoists the dependencies, the hoisting seems to confuse
63
+ # tools such as `npm outdated`, `npm update`, and some ESLint rules.
64
+ - name: Install Etherpad core dependencies
65
+ run: src/bin/installDeps.sh
66
+
67
+ - name: Create settings.json
68
+ run: cp settings.json.template settings.json
69
+
70
+ - name: Disable import/export rate limiting
71
+ run: |
72
+ sed -e '/^ *"importExportRateLimiting":/,/^ *\}/ s/"max":.*/"max": 0/' -i settings.json
73
+
74
+ - name: Remove standard frontend test files
75
+ run: rm -rf src/tests/frontend/specs
76
+
77
+ - uses: saucelabs/sauce-connect-action@v1
78
+ with:
79
+ username: ${{ secrets.SAUCE_USERNAME }}
80
+ accessKey: ${{ secrets.SAUCE_ACCESS_KEY }}
81
+ tunnelIdentifier: ${{ steps.sauce_strings.outputs.tunnel_id }}
82
+
83
+ - name: Run the frontend tests
84
+ shell: bash
85
+ env:
86
+ SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
87
+ SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
88
+ SAUCE_NAME: ${{ steps.sauce_strings.outputs.name }}
89
+ TRAVIS_JOB_NUMBER: ${{ steps.sauce_strings.outputs.tunnel_id }}
90
+ GIT_HASH: ${{ steps.environment.outputs.sha_short }}
91
+ run: |
92
+ src/tests/frontend/travis/runner.sh
@@ -56,15 +56,22 @@ jobs:
56
56
  runs-on: ubuntu-latest
57
57
  steps:
58
58
  - uses: actions/checkout@v2
59
+ with:
60
+ fetch-depth: 0
59
61
  - uses: actions/setup-node@v1
60
62
  with:
61
63
  node-version: 12
62
64
  registry-url: https://registry.npmjs.org/
63
- - run: git config user.name 'github-actions[bot]'
64
- - run: git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
65
- - run: npm ci
66
- - run: npm version patch
67
- - run: git push --follow-tags
65
+ - name: Bump version (patch)
66
+ run: |
67
+ LATEST_TAG=$(git describe --tags --abbrev=0) || exit 1
68
+ NEW_COMMITS=$(git rev-list --count "${LATEST_TAG}"..) || exit 1
69
+ [ "${NEW_COMMITS}" -gt 0 ] || exit 0
70
+ git config user.name 'github-actions[bot]'
71
+ git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
72
+ npm ci
73
+ npm version patch
74
+ git push --follow-tags
68
75
  # `npm publish` must come after `git push` otherwise there is a race
69
76
  # condition: If two PRs are merged back-to-back then master/main will be
70
77
  # updated with the commits from the second PR before the first PR's
@@ -78,6 +85,3 @@ jobs:
78
85
  - run: npm publish
79
86
  env:
80
87
  NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}}
81
-
82
- ##ETHERPAD_NPM_V=2
83
- ## NPM configuration automatically created using bin/plugins/updateAllPluginsScript.sh
package/README.md CHANGED
@@ -52,10 +52,12 @@ Etherpad's `requireAuthentication` setting must be `true`.
52
52
  * `base_url` (required): The public base Etherpad URL. When registering Etherpad
53
53
  with your identity provider, the redirect URL (a.k.a. callback URL) is this
54
54
  base URL plus `/ep_openid_connect/callback`.
55
- * `displayname_claim` (optional; defaults to `"name"`): The claim containing the
56
- name to display in the pad's user list.
55
+ * `displayname_claim` (optional; defaults to `"name"`): The default value of
56
+ `user_properties.displayname.claim`. **Deprecated:** Set
57
+ `user_properties.displayname.claim` instead.
57
58
  * `response_types` (optional; defaults to `["code"]`): List of OpenID Connect
58
59
  response types.
60
+ * `scope` (optional; defaults to `["openid"]`): List of OAuth2 scope strings.
59
61
  * `permit_displayname_change` (optional; defaults to `false`): Whether users may
60
62
  change their displayed name.
61
63
  * `prohibited_usernames` (optional; defaults to `['admin', 'guest']`): List of
@@ -64,6 +66,9 @@ Etherpad's `requireAuthentication` setting must be `true`.
64
66
  the users in the `users` setting and to avoid conflicts with other plugins
65
67
  (such as
66
68
  [ep\_readonly\_guest](https://github.com/ether/ep_readonly_guest#readme)).
69
+ * `user_properties` (optional): Object that controls the automatic creation of
70
+ additional properties on each authenticated user's account object. See below
71
+ for details.
67
72
 
68
73
  ## Interaction with the `users` Setting
69
74
 
@@ -73,15 +78,18 @@ identifier for the user.) Many identity providers (such as GitLab) identify
73
78
  users by a numeric user ID, so the `sub` claim (and thus the Etherpad username)
74
79
  will probably look something like "5374".
75
80
 
76
- All values associated with that username in the `settings.json` `users` object
77
- are applied to the user, including `is_admin`, `readOnly`, and `canCreate`.
78
-
79
- For example, if you want the user identified by "5374" to be a read-only user,
80
- you would add the following to your `settings.json`:
81
+ Each authenticated user gets their own account object. Default properties for a
82
+ user's account object come from the `users` setting in `settings.json`. Etherpad
83
+ uses the `is_admin`, `readOnly`, and `canCreate` properties to control access,
84
+ and this plugin uses the `displayname` property for the name displayed in the
85
+ user list. For example, the following sets the default display name to
86
+ "Firstname Lastname" and the default access to read-only for the user identified
87
+ by "5374":
81
88
 
82
89
  ```json
83
90
  "users": {
84
91
  "5374": {
92
+ "displayname": "Firstname Lastname",
85
93
  "readOnly": true
86
94
  }
87
95
  },
@@ -92,6 +100,54 @@ you can use the `prohibited_usernames` settings to force an authentication error
92
100
  if the `sub` claim happens to match. This is useful for preventing a malicious
93
101
  identity provider from gaining admin access to your Etherpad instance.
94
102
 
103
+ The `user_properties` setting can be used to automatically add or change
104
+ properties on a user's account object when the user authenticates. The
105
+ `user_properties` setting maps a property name to an object that describes how
106
+ the property's value is obtained. For example:
107
+
108
+ ```json
109
+ "ep_openid_connect": {
110
+ "user_properties": {
111
+ "fromClaimWithDefault": {
112
+ "claim": "claimName",
113
+ "default": "default value"
114
+ },
115
+ "fromClaimOrUnset": {
116
+ "claim": "claimName"
117
+ },
118
+ "fixedValue": {
119
+ "default": "fixed value"
120
+ }
121
+ }
122
+ },
123
+ ```
124
+
125
+ The above example sets properties as follows:
126
+ * Each user's `fromClaimWithDefault` property is set to the value of the user's
127
+ `claimName` claim if present, otherwise the property is left unchanged if
128
+ already set, otherwise it is set to the string `"default value"`.
129
+ * Each user's `fromClaimOrUnset` property is set to the value of the user's
130
+ `claimName` claim if present, otherwise the property is left unset/unchanged.
131
+ * Each user's `fixedValue` property is set to the string `"fixed value"`
132
+ unless already set.
133
+
134
+ You can use this feature to control access in the OpenID Connect provider if it
135
+ provides suitable claims:
136
+
137
+ ```json
138
+ "ep_openid_connect": {
139
+ "scope": ["openid", "etherpad"],
140
+ "user_properties": {
141
+ "is_admin": {"claim": "etherpad_is_admin"},
142
+ "readOnly": {"claim": "etherpad_readOnly"},
143
+ "canCreate": {"claim": "etherpad_canCreate"}
144
+ }
145
+ },
146
+ ```
147
+
148
+ To avoid breaking assumptions made by Etherpad, the `username` property cannot
149
+ be altered via the `user_properties` setting.
150
+
95
151
  ## Interaction with the ep\_readonly\_guest Plugin
96
152
 
97
153
  The [ep\_readonly\_guest
package/index.js CHANGED
@@ -5,19 +5,29 @@ const {URL} = require('url');
5
5
  const {Issuer, generators} = require('openid-client');
6
6
  const authorManager = require('ep_etherpad-lite/node/db/AuthorManager');
7
7
 
8
- const pluginName = 'ep_openid_connect';
9
- const logger = log4js.getLogger(pluginName);
8
+ const logger = log4js.getLogger('ep_openid_connect');
10
9
  const settings = {
11
10
  displayname_claim: 'name',
12
11
  response_types: ['code'],
13
12
  permit_displayname_change: false,
14
13
  prohibited_usernames: ['admin', 'guest'],
14
+ scope: ['openid'],
15
+ user_properties: {},
15
16
  };
16
17
  let oidcClient = null;
17
18
 
18
- const ep = (endpoint) => `/${pluginName}/${endpoint}`;
19
+ const ep = (endpoint) => `/ep_openid_connect/${endpoint}`;
19
20
  const endpointUrl = (endpoint) => new URL(ep(endpoint).substr(1), settings.base_url).toString();
20
21
 
22
+ const validateSubClaim = (sub) => {
23
+ if (typeof sub !== 'string' || // 'sub' claim must exist as a string per OIDC spec.
24
+ sub === '' || // Empty string doesn't make sense.
25
+ sub === '__proto__' || // Prevent prototype pollution.
26
+ settings.prohibited_usernames.includes(sub)) {
27
+ throw new Error('invalid sub claim');
28
+ }
29
+ };
30
+
21
31
  const discoverIssuer = async (issuerUrl) => {
22
32
  issuerUrl = new URL(issuerUrl);
23
33
  // https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery says that the URI
@@ -54,24 +64,31 @@ const getIssuer = async (settings) => {
54
64
  return new Issuer(settings.issuer_metadata);
55
65
  };
56
66
 
57
- exports.loadSettings = async (hookName, {settings: {[pluginName]: config = {}}}) => {
67
+ exports.loadSettings = async (hookName, {settings: {ep_openid_connect: config = {}}}) => {
58
68
  Object.assign(settings, config);
59
69
  for (const setting of ['base_url', 'client_id', 'client_secret']) {
60
70
  if (!settings[setting]) {
61
- logger.error(`Required setting missing from settings.json: ${pluginName}.${setting}`);
71
+ logger.error(`Required setting missing from settings.json: ep_openid_connect.${setting}`);
62
72
  return;
63
73
  }
64
74
  }
65
75
  if (!settings.issuer && !settings.issuer_metadata) {
66
- logger.error(`Either ${pluginName}.issuer or ${pluginName}.issuer_metadata must be set`);
76
+ logger.error(
77
+ 'Either ep_openid_connect.issuer or ep_openid_connect.issuer_metadata must be set');
67
78
  return;
68
79
  }
69
80
  if (settings.issuer && settings.issuer_metadata) {
70
- logger.warn(
71
- `Ignoring ${pluginName}.issuer_metadata setting because ${pluginName}.issuer is set`);
81
+ logger.warn('Ignoring ep_openid_connect.issuer_metadata setting ' +
82
+ 'because ep_openid_connect.issuer is set');
72
83
  }
73
84
  // Make sure base_url ends with '/' so that relative URLs are appended:
74
85
  if (!settings.base_url.endsWith('/')) settings.base_url += '/';
86
+ settings.user_properties = {
87
+ displayname: {claim: settings.displayname_claim},
88
+ ...settings.user_properties,
89
+ // The username property must always match the key used in settings.users.
90
+ username: {claim: 'sub'},
91
+ };
75
92
  oidcClient = new (await getIssuer(settings)).Client({
76
93
  client_id: settings.client_id,
77
94
  client_secret: settings.client_secret,
@@ -84,44 +101,61 @@ exports.loadSettings = async (hookName, {settings: {[pluginName]: config = {}}})
84
101
  exports.clientVars = (hookName, context) => {
85
102
  if (oidcClient == null) return;
86
103
  const {permit_displayname_change} = settings;
87
- return {[pluginName]: {permit_displayname_change}};
104
+ return {ep_openid_connect: {permit_displayname_change}};
88
105
  };
89
106
 
90
107
  exports.expressCreateServer = (hookName, {app}) => {
91
108
  if (oidcClient == null) return;
92
109
  logger.debug('Configuring auth routes');
93
- app.get(ep('callback'), (req, res, next) => {
94
- logger.debug(`Processing ${req.url}`);
95
- (async () => {
110
+ app.get(ep('callback'), async (req, res, next) => {
111
+ // This handler MUST NOT redirect to a page that requires authentication if there is a problem,
112
+ // otherwise the user could be caught in an infinite redirect loop.
113
+ try {
114
+ logger.debug(`Processing ${req.url}`);
96
115
  const params = oidcClient.callbackParams(req);
97
- const oidcSession = req.session[pluginName] || {};
98
- const {authParams} = oidcSession;
99
- if (authParams == null) throw new Error('no authentication paramters found in session state');
100
- const tokenset = await oidcClient.callback(endpointUrl('callback'), params, authParams);
116
+ const oidcSession = req.session.ep_openid_connect || {};
117
+ if (oidcSession.callbackChecks == null) throw new Error('missing authentication checks');
118
+ const tokenset =
119
+ await oidcClient.callback(endpointUrl('callback'), params, oidcSession.callbackChecks);
101
120
  const userinfo = await oidcClient.userinfo(tokenset);
102
- if (settings.prohibited_usernames.indexOf(userinfo.sub) !== -1) {
103
- throw new Error(`authenticated user's 'sub' claim (${userinfo.sub}) is not permitted`);
104
- }
105
- oidcSession.userinfo = userinfo;
121
+ validateSubClaim(userinfo.sub);
106
122
  // The user has successfully authenticated, but don't set req.session.user here -- do it in
107
123
  // the authenticate hook so that Etherpad can log the authentication success. However, DO "log
108
124
  // out" the previous user to force the authenticate hook to run in case the user was already
109
125
  // authenticated as someone else.
110
126
  delete req.session.user;
127
+ // userinfo should not be stored in req.session until after all checks have passed. (Otherwise
128
+ // it would be too easy to accidentally introduce a vulnerability.)
129
+ oidcSession.userinfo = userinfo;
111
130
  res.redirect(oidcSession.next || settings.base_url);
112
131
  // Defer deletion of state until success so that the user can reload the page to retry after a
113
132
  // transient backchannel failure.
114
- delete oidcSession.authParams;
133
+ delete oidcSession.callbackChecks;
115
134
  delete oidcSession.next;
116
- })().catch(next);
135
+ } catch (err) {
136
+ return next(err);
137
+ }
117
138
  });
118
139
  app.get(ep('login'), (req, res, next) => {
119
140
  logger.debug(`Processing ${req.url}`);
120
- if (req.session[pluginName] == null) req.session[pluginName] = {};
121
- const oidcSession = req.session[pluginName];
141
+ if (req.session.ep_openid_connect == null) req.session.ep_openid_connect = {};
142
+ const oidcSession = req.session.ep_openid_connect;
122
143
  oidcSession.next = req.query.redirect_uri || settings.base_url;
123
- oidcSession.authParams = {nonce: generators.nonce(), state: generators.state()};
124
- res.redirect(oidcClient.authorizationUrl(oidcSession.authParams));
144
+ const commonParams = {
145
+ nonce: generators.nonce(),
146
+ scope: settings.scope.join(' '),
147
+ state: generators.state(),
148
+ };
149
+ oidcSession.callbackChecks = {
150
+ ...commonParams,
151
+ code_verifier: generators.codeVerifier(), // RFC7636
152
+ };
153
+ res.redirect(oidcClient.authorizationUrl({
154
+ ...commonParams,
155
+ // RFC7636
156
+ code_challenge: generators.codeChallenge(oidcSession.callbackChecks.code_verifier),
157
+ code_challenge_method: 'S256',
158
+ }));
125
159
  });
126
160
  app.get(ep('logout'), (req, res) => req.session.destroy(() => res.redirect(settings.base_url)));
127
161
  };
@@ -129,21 +163,26 @@ exports.expressCreateServer = (hookName, {app}) => {
129
163
  exports.authenticate = (hookName, {req, res, users}) => {
130
164
  if (oidcClient == null) return;
131
165
  logger.debug('authenticate hook for', req.url);
132
- const {session} = req;
133
- const {[pluginName]: {userinfo = {}} = {}} = session;
134
- const {sub} = userinfo;
135
- if (sub == null) {
166
+ const {ep_openid_connect: {userinfo} = {}} = req.session;
167
+ if (userinfo == null) { // Nullish means the user isn't authenticated.
136
168
  // Out of an abundance of caution, clear out the old state, nonce, and userinfo (if present) to
137
169
  // force regeneration.
138
- delete session[pluginName];
170
+ delete req.session.ep_openid_connect;
139
171
  // Authn failed. Let another plugin try to authenticate the user.
140
172
  return;
141
173
  }
142
174
  // Successfully authenticated.
143
- if (users[sub] == null) users[sub] = {};
144
- session.user = users[sub];
145
- session.user.username = sub;
146
- session.user.displayname = userinfo[settings.displayname_claim] || session.user.displayname;
175
+ logger.info('Successfully authenticated user with userinfo:', userinfo);
176
+ req.session.user = users[userinfo.sub];
177
+ if (req.session.user == null) req.session.user = users[userinfo.sub] = {};
178
+ for (const [propName, descriptor] of Object.entries(settings.user_properties)) {
179
+ if (descriptor.claim != null && descriptor.claim in userinfo) {
180
+ req.session.user[propName] = userinfo[descriptor.claim];
181
+ } else if ('default' in descriptor && !(propName in req.session.user)) {
182
+ req.session.user[propName] = descriptor.default;
183
+ }
184
+ }
185
+ logger.debug('User properties:', req.session.user);
147
186
  return true;
148
187
  };
149
188
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_openid_connect",
3
- "version": "2.0.8",
3
+ "version": "2.2.0",
4
4
  "description": "Etherpad plugin to authenticate users against an OpenID Connect provider.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -26,14 +26,14 @@
26
26
  "openid-client": "^4.7.4"
27
27
  },
28
28
  "devDependencies": {
29
- "eslint": "^7.31.0",
30
- "eslint-config-etherpad": "^2.0.0",
31
- "eslint-plugin-cypress": "^2.11.3",
29
+ "eslint": "^7.32.0",
30
+ "eslint-config-etherpad": "^2.0.2",
31
+ "eslint-plugin-cypress": "^2.12.1",
32
32
  "eslint-plugin-eslint-comments": "^3.2.0",
33
33
  "eslint-plugin-mocha": "^9.0.0",
34
34
  "eslint-plugin-node": "^11.1.0",
35
35
  "eslint-plugin-prefer-arrow": "^1.2.3",
36
- "eslint-plugin-promise": "^5.1.0",
36
+ "eslint-plugin-promise": "^5.1.1",
37
37
  "eslint-plugin-you-dont-need-lodash-underscore": "^6.12.0"
38
38
  },
39
39
  "eslintConfig": {
@@ -46,12 +46,12 @@
46
46
  },
47
47
  "funding": {
48
48
  "type": "individual",
49
- "url": "http://etherpad.org/"
49
+ "url": "https://etherpad.org/"
50
50
  },
51
51
  "peerDependencies": {
52
52
  "ep_etherpad-lite": ">=1.8.6"
53
53
  },
54
54
  "engines": {
55
- "node": ">=10.13.0"
55
+ "node": ">=12.13.0"
56
56
  }
57
57
  }