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.
- package/README.md +63 -7
- package/index.js +43 -24
- 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
|
|
56
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
|
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) =>
|
|
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: {
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
121
|
-
const oidcSession = req.session
|
|
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 = {
|
|
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 {
|
|
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
|
|
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
|
-
|
|
155
|
+
logger.info('Successfully authenticated user with userinfo:', userinfo);
|
|
144
156
|
session.user = users[sub];
|
|
145
|
-
session.user.
|
|
146
|
-
|
|
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
|
|