ep_openid_connect 2.0.9 → 2.1.1

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.
Files changed (3) hide show
  1. package/README.md +63 -7
  2. package/index.js +43 -24
  3. package/package.json +1 -1
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,17 +5,18 @@ 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
 
21
22
  const discoverIssuer = async (issuerUrl) => {
@@ -54,24 +55,31 @@ const getIssuer = async (settings) => {
54
55
  return new Issuer(settings.issuer_metadata);
55
56
  };
56
57
 
57
- exports.loadSettings = async (hookName, {settings: {[pluginName]: config = {}}}) => {
58
+ exports.loadSettings = async (hookName, {settings: {ep_openid_connect: config = {}}}) => {
58
59
  Object.assign(settings, config);
59
60
  for (const setting of ['base_url', 'client_id', 'client_secret']) {
60
61
  if (!settings[setting]) {
61
- logger.error(`Required setting missing from settings.json: ${pluginName}.${setting}`);
62
+ logger.error(`Required setting missing from settings.json: ep_openid_connect.${setting}`);
62
63
  return;
63
64
  }
64
65
  }
65
66
  if (!settings.issuer && !settings.issuer_metadata) {
66
- logger.error(`Either ${pluginName}.issuer or ${pluginName}.issuer_metadata must be set`);
67
+ logger.error(
68
+ 'Either ep_openid_connect.issuer or ep_openid_connect.issuer_metadata must be set');
67
69
  return;
68
70
  }
69
71
  if (settings.issuer && settings.issuer_metadata) {
70
- logger.warn(
71
- `Ignoring ${pluginName}.issuer_metadata setting because ${pluginName}.issuer is set`);
72
+ logger.warn('Ignoring ep_openid_connect.issuer_metadata setting ' +
73
+ 'because ep_openid_connect.issuer is set');
72
74
  }
73
75
  // Make sure base_url ends with '/' so that relative URLs are appended:
74
76
  if (!settings.base_url.endsWith('/')) settings.base_url += '/';
77
+ settings.user_properties = {
78
+ displayname: {claim: settings.displayname_claim},
79
+ ...settings.user_properties,
80
+ // The username property must always match the key used in settings.users.
81
+ username: {claim: 'sub'},
82
+ };
75
83
  oidcClient = new (await getIssuer(settings)).Client({
76
84
  client_id: settings.client_id,
77
85
  client_secret: settings.client_secret,
@@ -84,7 +92,7 @@ exports.loadSettings = async (hookName, {settings: {[pluginName]: config = {}}})
84
92
  exports.clientVars = (hookName, context) => {
85
93
  if (oidcClient == null) return;
86
94
  const {permit_displayname_change} = settings;
87
- return {[pluginName]: {permit_displayname_change}};
95
+ return {ep_openid_connect: {permit_displayname_change}};
88
96
  };
89
97
 
90
98
  exports.expressCreateServer = (hookName, {app}) => {
@@ -94,15 +102,11 @@ exports.expressCreateServer = (hookName, {app}) => {
94
102
  logger.debug(`Processing ${req.url}`);
95
103
  (async () => {
96
104
  const params = oidcClient.callbackParams(req);
97
- const oidcSession = req.session[pluginName] || {};
105
+ const oidcSession = req.session.ep_openid_connect || {};
98
106
  const {authParams} = oidcSession;
99
107
  if (authParams == null) throw new Error('no authentication paramters found in session state');
100
108
  const tokenset = await oidcClient.callback(endpointUrl('callback'), params, authParams);
101
- 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;
109
+ oidcSession.userinfo = await oidcClient.userinfo(tokenset);
106
110
  // The user has successfully authenticated, but don't set req.session.user here -- do it in
107
111
  // the authenticate hook so that Etherpad can log the authentication success. However, DO "log
108
112
  // out" the previous user to force the authenticate hook to run in case the user was already
@@ -117,10 +121,14 @@ exports.expressCreateServer = (hookName, {app}) => {
117
121
  });
118
122
  app.get(ep('login'), (req, res, next) => {
119
123
  logger.debug(`Processing ${req.url}`);
120
- if (req.session[pluginName] == null) req.session[pluginName] = {};
121
- const oidcSession = req.session[pluginName];
124
+ if (req.session.ep_openid_connect == null) req.session.ep_openid_connect = {};
125
+ const oidcSession = req.session.ep_openid_connect;
122
126
  oidcSession.next = req.query.redirect_uri || settings.base_url;
123
- oidcSession.authParams = {nonce: generators.nonce(), state: generators.state()};
127
+ oidcSession.authParams = {
128
+ nonce: generators.nonce(),
129
+ scope: settings.scope.join(' '),
130
+ state: generators.state(),
131
+ };
124
132
  res.redirect(oidcClient.authorizationUrl(oidcSession.authParams));
125
133
  });
126
134
  app.get(ep('logout'), (req, res) => req.session.destroy(() => res.redirect(settings.base_url)));
@@ -130,20 +138,31 @@ exports.authenticate = (hookName, {req, res, users}) => {
130
138
  if (oidcClient == null) return;
131
139
  logger.debug('authenticate hook for', req.url);
132
140
  const {session} = req;
133
- const {[pluginName]: {userinfo = {}} = {}} = session;
141
+ const {ep_openid_connect: {userinfo = {}} = {}} = session;
134
142
  const {sub} = userinfo;
135
- if (sub == null) {
143
+ if (sub == null || // Nullish means the user isn't authenticated.
144
+ typeof sub !== 'string' || // `sub` is used as the username, so it must be a string.
145
+ sub === '' || // Empty string doesn't make sense.
146
+ sub === '__proto__' || // Prevent prototype pollution.
147
+ settings.prohibited_usernames.includes(sub)) {
136
148
  // Out of an abundance of caution, clear out the old state, nonce, and userinfo (if present) to
137
149
  // force regeneration.
138
- delete session[pluginName];
150
+ delete session.ep_openid_connect;
139
151
  // Authn failed. Let another plugin try to authenticate the user.
140
152
  return;
141
153
  }
142
154
  // Successfully authenticated.
143
- if (users[sub] == null) users[sub] = {};
155
+ logger.info('Successfully authenticated user with userinfo:', userinfo);
144
156
  session.user = users[sub];
145
- session.user.username = sub;
146
- session.user.displayname = userinfo[settings.displayname_claim] || session.user.displayname;
157
+ if (session.user == null) session.user = users[sub] = {};
158
+ for (const [propName, descriptor] of Object.entries(settings.user_properties)) {
159
+ if (descriptor.claim != null && descriptor.claim in userinfo) {
160
+ session.user[propName] = userinfo[descriptor.claim];
161
+ } else if ('default' in descriptor && !(propName in session.user)) {
162
+ session.user[propName] = descriptor.default;
163
+ }
164
+ }
165
+ logger.debug('User properties:', session.user);
147
166
  return true;
148
167
  };
149
168
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_openid_connect",
3
- "version": "2.0.9",
3
+ "version": "2.1.1",
4
4
  "description": "Etherpad plugin to authenticate users against an OpenID Connect provider.",
5
5
  "main": "index.js",
6
6
  "repository": {