ep_openid_connect 2.0.9 → 2.3.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.
- package/README.md +63 -9
- package/index.js +102 -40
- package/package.json +4 -2
- package/static/tests/backend/login.js +53 -0
- package/static/tests/backend/oidc-provider.js +143 -0
- package/static/tests/backend/specs/tests.js +222 -0
package/README.md
CHANGED
|
@@ -52,10 +52,10 @@ 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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
55
|
+
* `displayname_claim` (optional; defaults to `"name"`): The default value of
|
|
56
|
+
`user_properties.displayname.claim`. **Deprecated:** Set
|
|
57
|
+
`user_properties.displayname.claim` instead.
|
|
58
|
+
* `scope` (optional; defaults to `["openid"]`): List of OAuth2 scope strings.
|
|
59
59
|
* `permit_displayname_change` (optional; defaults to `false`): Whether users may
|
|
60
60
|
change their displayed name.
|
|
61
61
|
* `prohibited_usernames` (optional; defaults to `['admin', 'guest']`): List of
|
|
@@ -64,6 +64,9 @@ Etherpad's `requireAuthentication` setting must be `true`.
|
|
|
64
64
|
the users in the `users` setting and to avoid conflicts with other plugins
|
|
65
65
|
(such as
|
|
66
66
|
[ep\_readonly\_guest](https://github.com/ether/ep_readonly_guest#readme)).
|
|
67
|
+
* `user_properties` (optional): Object that controls the automatic creation of
|
|
68
|
+
additional properties on each authenticated user's account object. See below
|
|
69
|
+
for details.
|
|
67
70
|
|
|
68
71
|
## Interaction with the `users` Setting
|
|
69
72
|
|
|
@@ -73,15 +76,18 @@ identifier for the user.) Many identity providers (such as GitLab) identify
|
|
|
73
76
|
users by a numeric user ID, so the `sub` claim (and thus the Etherpad username)
|
|
74
77
|
will probably look something like "5374".
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
Each authenticated user gets their own account object. Default properties for a
|
|
80
|
+
user's account object come from the `users` setting in `settings.json`. Etherpad
|
|
81
|
+
uses the `is_admin`, `readOnly`, and `canCreate` properties to control access,
|
|
82
|
+
and this plugin uses the `displayname` property for the name displayed in the
|
|
83
|
+
user list. For example, the following sets the default display name to
|
|
84
|
+
"Firstname Lastname" and the default access to read-only for the user identified
|
|
85
|
+
by "5374":
|
|
81
86
|
|
|
82
87
|
```json
|
|
83
88
|
"users": {
|
|
84
89
|
"5374": {
|
|
90
|
+
"displayname": "Firstname Lastname",
|
|
85
91
|
"readOnly": true
|
|
86
92
|
}
|
|
87
93
|
},
|
|
@@ -92,6 +98,54 @@ you can use the `prohibited_usernames` settings to force an authentication error
|
|
|
92
98
|
if the `sub` claim happens to match. This is useful for preventing a malicious
|
|
93
99
|
identity provider from gaining admin access to your Etherpad instance.
|
|
94
100
|
|
|
101
|
+
The `user_properties` setting can be used to automatically add or change
|
|
102
|
+
properties on a user's account object when the user authenticates. The
|
|
103
|
+
`user_properties` setting maps a property name to an object that describes how
|
|
104
|
+
the property's value is obtained. For example:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
"ep_openid_connect": {
|
|
108
|
+
"user_properties": {
|
|
109
|
+
"fromClaimWithDefault": {
|
|
110
|
+
"claim": "claimName",
|
|
111
|
+
"default": "default value"
|
|
112
|
+
},
|
|
113
|
+
"fromClaimOrUnset": {
|
|
114
|
+
"claim": "claimName"
|
|
115
|
+
},
|
|
116
|
+
"fixedValue": {
|
|
117
|
+
"default": "fixed value"
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
The above example sets properties as follows:
|
|
124
|
+
* Each user's `fromClaimWithDefault` property is set to the value of the user's
|
|
125
|
+
`claimName` claim if present, otherwise the property is left unchanged if
|
|
126
|
+
already set, otherwise it is set to the string `"default value"`.
|
|
127
|
+
* Each user's `fromClaimOrUnset` property is set to the value of the user's
|
|
128
|
+
`claimName` claim if present, otherwise the property is left unset/unchanged.
|
|
129
|
+
* Each user's `fixedValue` property is set to the string `"fixed value"`
|
|
130
|
+
unless already set.
|
|
131
|
+
|
|
132
|
+
You can use this feature to control access in the OpenID Connect provider if it
|
|
133
|
+
provides suitable claims:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
"ep_openid_connect": {
|
|
137
|
+
"scope": ["openid", "etherpad"],
|
|
138
|
+
"user_properties": {
|
|
139
|
+
"is_admin": {"claim": "etherpad_is_admin"},
|
|
140
|
+
"readOnly": {"claim": "etherpad_readOnly"},
|
|
141
|
+
"canCreate": {"claim": "etherpad_canCreate"}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
To avoid breaking assumptions made by Etherpad, the `username` property cannot
|
|
147
|
+
be altered via the `user_properties` setting.
|
|
148
|
+
|
|
95
149
|
## Interaction with the ep\_readonly\_guest Plugin
|
|
96
150
|
|
|
97
151
|
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
|
|
9
|
-
const
|
|
10
|
-
const settings = {
|
|
8
|
+
const logger = log4js.getLogger('ep_openid_connect');
|
|
9
|
+
const defaultSettings = {
|
|
11
10
|
displayname_claim: 'name',
|
|
12
|
-
response_types: ['code'],
|
|
13
11
|
permit_displayname_change: false,
|
|
14
12
|
prohibited_usernames: ['admin', 'guest'],
|
|
13
|
+
scope: ['openid'],
|
|
14
|
+
user_properties: {},
|
|
15
15
|
};
|
|
16
|
+
const settings = {...defaultSettings};
|
|
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
|
|
|
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,28 +64,40 @@ const getIssuer = async (settings) => {
|
|
|
54
64
|
return new Issuer(settings.issuer_metadata);
|
|
55
65
|
};
|
|
56
66
|
|
|
57
|
-
exports.loadSettings = async (hookName, {settings: {
|
|
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:
|
|
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(
|
|
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
|
-
|
|
81
|
+
logger.warn('Ignoring ep_openid_connect.issuer_metadata setting ' +
|
|
82
|
+
'because ep_openid_connect.issuer is set');
|
|
83
|
+
}
|
|
84
|
+
if (settings.response_types) {
|
|
85
|
+
logger.warn('Ignoring ep_openid_connect.response_types setting (it is no longer used)');
|
|
86
|
+
delete settings.response_types;
|
|
72
87
|
}
|
|
73
88
|
// Make sure base_url ends with '/' so that relative URLs are appended:
|
|
74
89
|
if (!settings.base_url.endsWith('/')) settings.base_url += '/';
|
|
90
|
+
settings.user_properties = {
|
|
91
|
+
displayname: {claim: settings.displayname_claim},
|
|
92
|
+
...settings.user_properties,
|
|
93
|
+
// The username property must always match the key used in settings.users.
|
|
94
|
+
username: {claim: 'sub'},
|
|
95
|
+
};
|
|
96
|
+
logger.debug('Settings:', {...settings, client_secret: '********'});
|
|
75
97
|
oidcClient = new (await getIssuer(settings)).Client({
|
|
76
98
|
client_id: settings.client_id,
|
|
77
99
|
client_secret: settings.client_secret,
|
|
78
|
-
response_types:
|
|
100
|
+
response_types: ['code'],
|
|
79
101
|
redirect_uris: [endpointUrl('callback')],
|
|
80
102
|
});
|
|
81
103
|
logger.info('Configured.');
|
|
@@ -84,66 +106,102 @@ exports.loadSettings = async (hookName, {settings: {[pluginName]: config = {}}})
|
|
|
84
106
|
exports.clientVars = (hookName, context) => {
|
|
85
107
|
if (oidcClient == null) return;
|
|
86
108
|
const {permit_displayname_change} = settings;
|
|
87
|
-
return {
|
|
109
|
+
return {ep_openid_connect: {permit_displayname_change}};
|
|
88
110
|
};
|
|
89
111
|
|
|
90
112
|
exports.expressCreateServer = (hookName, {app}) => {
|
|
91
|
-
if (oidcClient == null) return;
|
|
92
113
|
logger.debug('Configuring auth routes');
|
|
93
|
-
app.get(ep('callback'), (req, res, next) => {
|
|
94
|
-
|
|
95
|
-
|
|
114
|
+
app.get(ep('callback'), async (req, res, next) => {
|
|
115
|
+
// This handler MUST NOT redirect to a page that requires authentication if there is a problem,
|
|
116
|
+
// otherwise the user could be caught in an infinite redirect loop.
|
|
117
|
+
try {
|
|
118
|
+
logger.debug(`Processing ${req.url}`);
|
|
119
|
+
if (oidcClient == null) {
|
|
120
|
+
logger.warn('Not configured; ignoring request.');
|
|
121
|
+
return next();
|
|
122
|
+
}
|
|
96
123
|
const params = oidcClient.callbackParams(req);
|
|
97
|
-
const oidcSession = req.session
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
124
|
+
const oidcSession = req.session.ep_openid_connect || {};
|
|
125
|
+
if (oidcSession.callbackChecks == null) throw new Error('missing authentication checks');
|
|
126
|
+
const tokenset =
|
|
127
|
+
await oidcClient.callback(endpointUrl('callback'), params, oidcSession.callbackChecks);
|
|
101
128
|
const userinfo = await oidcClient.userinfo(tokenset);
|
|
102
|
-
|
|
103
|
-
throw new Error(`authenticated user's 'sub' claim (${userinfo.sub}) is not permitted`);
|
|
104
|
-
}
|
|
105
|
-
oidcSession.userinfo = userinfo;
|
|
129
|
+
validateSubClaim(userinfo.sub);
|
|
106
130
|
// The user has successfully authenticated, but don't set req.session.user here -- do it in
|
|
107
131
|
// the authenticate hook so that Etherpad can log the authentication success. However, DO "log
|
|
108
132
|
// out" the previous user to force the authenticate hook to run in case the user was already
|
|
109
133
|
// authenticated as someone else.
|
|
110
134
|
delete req.session.user;
|
|
135
|
+
// userinfo should not be stored in req.session until after all checks have passed. (Otherwise
|
|
136
|
+
// it would be too easy to accidentally introduce a vulnerability.)
|
|
137
|
+
oidcSession.userinfo = userinfo;
|
|
111
138
|
res.redirect(oidcSession.next || settings.base_url);
|
|
112
139
|
// Defer deletion of state until success so that the user can reload the page to retry after a
|
|
113
140
|
// transient backchannel failure.
|
|
114
|
-
delete oidcSession.
|
|
141
|
+
delete oidcSession.callbackChecks;
|
|
115
142
|
delete oidcSession.next;
|
|
116
|
-
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
return next(err);
|
|
145
|
+
}
|
|
117
146
|
});
|
|
118
147
|
app.get(ep('login'), (req, res, next) => {
|
|
119
148
|
logger.debug(`Processing ${req.url}`);
|
|
120
|
-
if (
|
|
121
|
-
|
|
149
|
+
if (oidcClient == null) {
|
|
150
|
+
logger.warn('Not configured; ignoring request.');
|
|
151
|
+
return next();
|
|
152
|
+
}
|
|
153
|
+
if (req.session.ep_openid_connect == null) req.session.ep_openid_connect = {};
|
|
154
|
+
const oidcSession = req.session.ep_openid_connect;
|
|
122
155
|
oidcSession.next = req.query.redirect_uri || settings.base_url;
|
|
123
|
-
|
|
124
|
-
|
|
156
|
+
const commonParams = {
|
|
157
|
+
nonce: generators.nonce(),
|
|
158
|
+
scope: settings.scope.join(' '),
|
|
159
|
+
state: generators.state(),
|
|
160
|
+
};
|
|
161
|
+
oidcSession.callbackChecks = {
|
|
162
|
+
...commonParams,
|
|
163
|
+
code_verifier: generators.codeVerifier(), // RFC7636
|
|
164
|
+
};
|
|
165
|
+
res.redirect(oidcClient.authorizationUrl({
|
|
166
|
+
...commonParams,
|
|
167
|
+
// RFC7636
|
|
168
|
+
code_challenge: generators.codeChallenge(oidcSession.callbackChecks.code_verifier),
|
|
169
|
+
code_challenge_method: 'S256',
|
|
170
|
+
}));
|
|
171
|
+
});
|
|
172
|
+
app.get(ep('logout'), (req, res, next) => {
|
|
173
|
+
logger.debug(`Processing ${req.url}`);
|
|
174
|
+
if (oidcClient == null) {
|
|
175
|
+
logger.warn('Not configured; ignoring request.');
|
|
176
|
+
return next();
|
|
177
|
+
}
|
|
178
|
+
req.session.destroy(() => res.redirect(settings.base_url));
|
|
125
179
|
});
|
|
126
|
-
app.get(ep('logout'), (req, res) => req.session.destroy(() => res.redirect(settings.base_url)));
|
|
127
180
|
};
|
|
128
181
|
|
|
129
182
|
exports.authenticate = (hookName, {req, res, users}) => {
|
|
130
183
|
if (oidcClient == null) return;
|
|
131
184
|
logger.debug('authenticate hook for', req.url);
|
|
132
|
-
const {
|
|
133
|
-
|
|
134
|
-
const {sub} = userinfo;
|
|
135
|
-
if (sub == null) {
|
|
185
|
+
const {ep_openid_connect: {userinfo} = {}} = req.session;
|
|
186
|
+
if (userinfo == null) { // Nullish means the user isn't authenticated.
|
|
136
187
|
// Out of an abundance of caution, clear out the old state, nonce, and userinfo (if present) to
|
|
137
188
|
// force regeneration.
|
|
138
|
-
delete session
|
|
189
|
+
delete req.session.ep_openid_connect;
|
|
139
190
|
// Authn failed. Let another plugin try to authenticate the user.
|
|
140
191
|
return;
|
|
141
192
|
}
|
|
142
193
|
// Successfully authenticated.
|
|
143
|
-
|
|
144
|
-
session.user = users[sub];
|
|
145
|
-
session.user.
|
|
146
|
-
|
|
194
|
+
logger.info('Successfully authenticated user with userinfo:', userinfo);
|
|
195
|
+
req.session.user = users[userinfo.sub];
|
|
196
|
+
if (req.session.user == null) req.session.user = users[userinfo.sub] = {};
|
|
197
|
+
for (const [propName, descriptor] of Object.entries(settings.user_properties)) {
|
|
198
|
+
if (descriptor.claim != null && descriptor.claim in userinfo) {
|
|
199
|
+
req.session.user[propName] = userinfo[descriptor.claim];
|
|
200
|
+
} else if ('default' in descriptor && !(propName in req.session.user)) {
|
|
201
|
+
req.session.user[propName] = descriptor.default;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
logger.debug('User properties:', req.session.user);
|
|
147
205
|
return true;
|
|
148
206
|
};
|
|
149
207
|
|
|
@@ -178,3 +236,7 @@ exports.preAuthorize = (hookName, {req}) => {
|
|
|
178
236
|
if (req.path.startsWith(ep(''))) return true;
|
|
179
237
|
return;
|
|
180
238
|
};
|
|
239
|
+
|
|
240
|
+
exports.exportedForTestingOnly = {
|
|
241
|
+
defaultSettings,
|
|
242
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ep_openid_connect",
|
|
3
|
-
"version": "2.0
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "Etherpad plugin to authenticate users against an OpenID Connect provider.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": {
|
|
@@ -34,7 +34,9 @@
|
|
|
34
34
|
"eslint-plugin-node": "^11.1.0",
|
|
35
35
|
"eslint-plugin-prefer-arrow": "^1.2.3",
|
|
36
36
|
"eslint-plugin-promise": "^5.1.1",
|
|
37
|
-
"eslint-plugin-you-dont-need-lodash-underscore": "^6.12.0"
|
|
37
|
+
"eslint-plugin-you-dont-need-lodash-underscore": "^6.12.0",
|
|
38
|
+
"oidc-provider": "^7.10.6",
|
|
39
|
+
"supertest": "^6.1.6"
|
|
38
40
|
},
|
|
39
41
|
"eslintConfig": {
|
|
40
42
|
"root": true,
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert').strict;
|
|
4
|
+
const common = require('ep_etherpad-lite/tests/backend/common');
|
|
5
|
+
|
|
6
|
+
const logger = common.logger;
|
|
7
|
+
|
|
8
|
+
const followRedirects = async (agent, res) => {
|
|
9
|
+
if (![302, 303].includes(res.status)) return res;
|
|
10
|
+
const url = new URL(res.headers.location, res.request.url);
|
|
11
|
+
logger.debug(`redirected to ${url}`);
|
|
12
|
+
return await followRedirects(agent, await agent.get(url));
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// There might not be a set-cookie header on the response if the request sent a cookie and the
|
|
16
|
+
// cookie value hasn't changed. If there is no set-cookie header, use the request's cookie.
|
|
17
|
+
const fakeSetCookie = (jar, res) => {
|
|
18
|
+
if (!res.headers['set-cookie']) {
|
|
19
|
+
const url = new URL(res.request.url);
|
|
20
|
+
const cookies = jar.getCookies({
|
|
21
|
+
domain: url.hostname, // No port.
|
|
22
|
+
path: url.pathname,
|
|
23
|
+
secure: url.protocl === 'https:',
|
|
24
|
+
script: false,
|
|
25
|
+
}).toString();
|
|
26
|
+
if (cookies) res.headers['set-cookie'] = cookies;
|
|
27
|
+
}
|
|
28
|
+
return res;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
module.exports = async (agent, issuer, startUrl, username) => {
|
|
32
|
+
// Visit the start URL and follow the redirects to the login page.
|
|
33
|
+
let res = await followRedirects(agent, await agent.get(startUrl));
|
|
34
|
+
const wantUrlBase = new URL('/interaction/', issuer).toString();
|
|
35
|
+
assert(res.request.url.startsWith(wantUrlBase),
|
|
36
|
+
`expected ${res.request.url} to start with ${wantUrlBase}`);
|
|
37
|
+
assert.equal(res.status, 200);
|
|
38
|
+
|
|
39
|
+
// Log in and follow redirects to the consent page.
|
|
40
|
+
logger.debug(`submitting login=${username}`);
|
|
41
|
+
res = await followRedirects(agent, await agent.get(`${res.request.url}?login=${username}`));
|
|
42
|
+
if (!res.request.url.startsWith(new URL('/interaction/', issuer).toString())) {
|
|
43
|
+
logger.debug('did not redirect to the consent page');
|
|
44
|
+
return fakeSetCookie(agent.jar, res);
|
|
45
|
+
}
|
|
46
|
+
assert.equal(res.status, 200);
|
|
47
|
+
|
|
48
|
+
// Indicate consent and follow redirects. (It should redirect back to the original URL, but that
|
|
49
|
+
// verification is done by the caller so that this function can be used to test error cases.)
|
|
50
|
+
logger.debug('submitting consent=true');
|
|
51
|
+
res = await followRedirects(agent, await agent.get(`${res.request.url}?consent=true`));
|
|
52
|
+
return fakeSetCookie(agent.jar, res);
|
|
53
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const MemoryAdapter = require('oidc-provider/lib/adapters/memory_adapter');
|
|
4
|
+
const Provider = require('oidc-provider');
|
|
5
|
+
const common = require('ep_etherpad-lite/tests/backend/common');
|
|
6
|
+
const http = require('http');
|
|
7
|
+
const util = require('util');
|
|
8
|
+
|
|
9
|
+
const httpListen = util.promisify(http.Server.prototype.listen);
|
|
10
|
+
const httpClose = util.promisify(http.Server.prototype.close);
|
|
11
|
+
const logger = common.logger;
|
|
12
|
+
|
|
13
|
+
// No-op subclass to silence warnings about using MemoryAdapter.
|
|
14
|
+
class Adapter extends MemoryAdapter {}
|
|
15
|
+
|
|
16
|
+
class OidcProvider {
|
|
17
|
+
async start(options) {
|
|
18
|
+
await this.stop();
|
|
19
|
+
this._server = http.createServer();
|
|
20
|
+
await httpListen.call(this._server, 0, 'localhost');
|
|
21
|
+
this.issuer = `http://localhost:${this._server.address().port}/`;
|
|
22
|
+
this._provider = new Provider(this.issuer, Object.assign({
|
|
23
|
+
adapter: Adapter,
|
|
24
|
+
claims: {
|
|
25
|
+
etherpad: [
|
|
26
|
+
'etherpad_is_admin',
|
|
27
|
+
'etherpad_readOnly',
|
|
28
|
+
'etherpad_canCreate',
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
cookies: {
|
|
32
|
+
keys: [common.randomString()],
|
|
33
|
+
},
|
|
34
|
+
features: {
|
|
35
|
+
devInteractions: {enabled: false},
|
|
36
|
+
},
|
|
37
|
+
findAccount: async (ctx, sub, token) => ({
|
|
38
|
+
accountId: sub,
|
|
39
|
+
claims: async (use, scope, claims, rejected) => ({
|
|
40
|
+
sub,
|
|
41
|
+
etherpad_is_admin: sub.includes('admin'),
|
|
42
|
+
etherpad_readOnly: !sub.includes('admin') && sub.includes('readOnly'),
|
|
43
|
+
etherpad_canCreate:
|
|
44
|
+
sub.includes('admin') || (!sub.includes('readOnly') && !sub.includes('noCreate')),
|
|
45
|
+
}),
|
|
46
|
+
}),
|
|
47
|
+
jwks: {
|
|
48
|
+
/* eslint-disable max-len */
|
|
49
|
+
keys: [
|
|
50
|
+
{
|
|
51
|
+
d: 'VEZOsY07JTFzGTqv6cC2Y32vsfChind2I_TTuvV225_-0zrSej3XLRg8iE_u0-3GSgiGi4WImmTwmEgLo4Qp3uEcxCYbt4NMJC7fwT2i3dfRZjtZ4yJwFl0SIj8TgfQ8ptwZbFZUlcHGXZIr4nL8GXyQT0CK8wy4COfmymHrrUoyfZA154ql_OsoiupSUCRcKVvZj2JHL2KILsq_sh_l7g2dqAN8D7jYfJ58MkqlknBMa2-zi5I0-1JUOwztVNml_zGrp27UbEU60RqV3GHjoqwI6m01U7K0a8Q_SQAKYGqgepbAYOA-P4_TLl5KC4-WWBZu_rVfwgSENwWNEhw8oQ',
|
|
52
|
+
dp: 'E1Y-SN4bQqX7kP-bNgZ_gEv-pixJ5F_EGocHKfS56jtzRqQdTurrk4jIVpI-ZITA88lWAHxjD-OaoJUh9Jupd_lwD5Si80PyVxOMI2xaGQiF0lbKJfD38Sh8frRpgelZVaK_gm834B6SLfxKdNsP04DsJqGKktODF_fZeaGFPH0',
|
|
53
|
+
dq: 'F90JPxevQYOlAgEH0TUt1-3_hyxY6cfPRU2HQBaahyWrtCWpaOzenKZnvGFZdg-BuLVKjCchq3G_70OLE-XDP_ol0UTJmDTT-WyuJQdEMpt_WFF9yJGoeIu8yohfeLatU-67ukjghJ0s9CBzNE_LrGEV6Cup3FXywpSYZAV3iqc',
|
|
54
|
+
e: 'AQAB',
|
|
55
|
+
kty: 'RSA',
|
|
56
|
+
n: 'xwQ72P9z9OYshiQ-ntDYaPnnfwG6u9JAdLMZ5o0dmjlcyrvwQRdoFIKPnO65Q8mh6F_LDSxjxa2Yzo_wdjhbPZLjfUJXgCzm54cClXzT5twzo7lzoAfaJlkTsoZc2HFWqmcri0BuzmTFLZx2Q7wYBm0pXHmQKF0V-C1O6NWfd4mfBhbM-I1tHYSpAMgarSm22WDMDx-WWI7TEzy2QhaBVaENW9BKaKkJklocAZCxk18WhR0fckIGiWiSM5FcU1PY2jfGsTmX505Ub7P5Dz75Ygqrutd5tFrcqyPAtPTFDk8X1InxkkUwpP3nFU5o50DGhwQolGYKPGtQ-ZtmbOfcWQ',
|
|
57
|
+
p: '5wC6nY6Ev5FqcLPCqn9fC6R9KUuBej6NaAVOKW7GXiOJAq2WrileGKfMc9kIny20zW3uWkRLm-O-3Yzze1zFpxmqvsvCxZ5ERVZ6leiNXSu3tez71ZZwp0O9gys4knjrI-9w46l_vFuRtjL6XEeFfHEZFaNJpz-lcnb3w0okrbM',
|
|
58
|
+
q: '3I1qeEDslZFB8iNfpKAdWtz_Wzm6-jayT_V6aIvhvMj5mnU-Xpj75zLPQSGa9wunMlOoZW9w1wDO1FVuDhwzeOJaTm-Ds0MezeC4U6nVGyyDHb4CUA3ml2tzt4yLrqGYMT7XbADSvuWYADHw79OFjEi4T3s3tJymhaBvy1ulv8M',
|
|
59
|
+
qi: 'wSbXte9PcPtr788e713KHQ4waE26CzoXx-JNOgN0iqJMN6C4_XJEX-cSvCZDf4rh7xpXN6SGLVd5ibIyDJi7bbi5EQ5AXjazPbLBjRthcGXsIuZ3AtQyR0CEWNSdM7EyM5TRdyZQ9kftfz9nI03guW3iKKASETqX2vh0Z8XRjyU',
|
|
60
|
+
use: 'sig',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
crv: 'P-256',
|
|
64
|
+
d: 'K9xfPv773dZR22TVUB80xouzdF7qCg5cWjPjkHyv7Ws',
|
|
65
|
+
kty: 'EC',
|
|
66
|
+
use: 'sig',
|
|
67
|
+
x: 'FWZ9rSkLt6Dx9E3pxLybhdM6xgR5obGsj5_pqmnz5J4',
|
|
68
|
+
y: '_n8G69C-A2Xl4xUW2lF0i8ZGZnk_KPYrhv4GbTGu5G4',
|
|
69
|
+
},
|
|
70
|
+
],
|
|
71
|
+
/* eslint-enable max-len */
|
|
72
|
+
},
|
|
73
|
+
ttl: {
|
|
74
|
+
// Silence warnings.
|
|
75
|
+
AccessToken: 3600,
|
|
76
|
+
Grant: 3600,
|
|
77
|
+
IdToken: 3600,
|
|
78
|
+
Interaction: 3600,
|
|
79
|
+
Session: 3600,
|
|
80
|
+
},
|
|
81
|
+
}, options));
|
|
82
|
+
this._server.on('request', this._provider.callback());
|
|
83
|
+
|
|
84
|
+
// Install middleware that provides fake login and consent pages. This code would be prettier,
|
|
85
|
+
// but I wanted to avoid pulling in dependencies such as express or koa-route + koa-bodyparser.
|
|
86
|
+
this._provider.app.use(async (ctx, next) => {
|
|
87
|
+
const {request: {method, path, query}} = ctx;
|
|
88
|
+
if (!path.startsWith('/interaction/') || method !== 'GET') return await next();
|
|
89
|
+
const interactionDetails = await this._provider.interactionDetails(ctx.req, ctx.res);
|
|
90
|
+
if (Object.entries(query).length === 0) {
|
|
91
|
+
// This is where we would normally present a login or consent page. Just print the prompt
|
|
92
|
+
// name to help with debugging.
|
|
93
|
+
ctx.response.body = `waiting for ${interactionDetails.prompt.name}`;
|
|
94
|
+
ctx.response.status = 200;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
// Normally the login and consent pages would have a form that POSTs to the same URL, but for
|
|
98
|
+
// convenience this code just uses GET with a query parameter.
|
|
99
|
+
switch (interactionDetails.prompt.name) {
|
|
100
|
+
case 'login': {
|
|
101
|
+
const result = query.login.includes('fail')
|
|
102
|
+
? {error: 'access_denied', error_description: 'injected failure'}
|
|
103
|
+
: {login: {accountId: query.login, remember: false}};
|
|
104
|
+
// This is test code; no need for username or password checking.
|
|
105
|
+
return await this._provider.interactionFinished(ctx.req, ctx.res, result);
|
|
106
|
+
}
|
|
107
|
+
case 'consent': {
|
|
108
|
+
// The amount of boilerplate code required to handle basic consent interaction is much
|
|
109
|
+
// higher than I would have expected. I don't understand this code; it is based on
|
|
110
|
+
// https://github.com/panva/node-oidc-provider/blob/039a55a0ab24cd4f6aa5831b829cfb2cc1337866/example/routes/express.js#L116-L152
|
|
111
|
+
const grant = interactionDetails.grantId
|
|
112
|
+
? await this._provider.Grant.find(interactionDetails.grantId)
|
|
113
|
+
: new this._provider.Grant({
|
|
114
|
+
accountId: interactionDetails.session.accountId,
|
|
115
|
+
clientId: interactionDetails.params.client_id,
|
|
116
|
+
});
|
|
117
|
+
const {prompt: {details}} = interactionDetails;
|
|
118
|
+
if (details.missingOIDCScope) grant.addOIDCScope(details.missingOIDCScope.join(' '));
|
|
119
|
+
if (details.missingOIDCClaims) grant.addOIDCClaims(details.missingOIDCClaims);
|
|
120
|
+
if (details.missingResourceScopes) {
|
|
121
|
+
for (const [indicator, scopes] of Object.entries(details.missingResourceScopes)) {
|
|
122
|
+
grant.addResourceScope(indicator, scopes.join(' '));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const grantId = await grant.save();
|
|
126
|
+
return await this._provider.interactionFinished(ctx.req, ctx.res, {
|
|
127
|
+
consent: interactionDetails.grantId ? {} : {grantId},
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return await next();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
logger.info(`Test OpenID Connect provider listening at ${this.issuer}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async stop() {
|
|
138
|
+
if (this._server == null) return;
|
|
139
|
+
await httpClose.call(this._server);
|
|
140
|
+
this._server = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
module.exports = OidcProvider;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const OidcProvider = require('../oidc-provider');
|
|
4
|
+
const assert = require('assert').strict;
|
|
5
|
+
const common = require('ep_etherpad-lite/tests/backend/common');
|
|
6
|
+
const epOpenidConnect = require('../../../../index');
|
|
7
|
+
const login = require('../login');
|
|
8
|
+
const padManager = require('ep_etherpad-lite/node/db/PadManager');
|
|
9
|
+
const settings = require('ep_etherpad-lite/node/utils/Settings');
|
|
10
|
+
const supertest = require('supertest');
|
|
11
|
+
|
|
12
|
+
describe(__filename, function () {
|
|
13
|
+
let agent;
|
|
14
|
+
const backup = {};
|
|
15
|
+
let provider;
|
|
16
|
+
let issuer;
|
|
17
|
+
const client = {
|
|
18
|
+
client_id: 'the_client_id',
|
|
19
|
+
client_secret: 'the_client_secret',
|
|
20
|
+
};
|
|
21
|
+
const pluginSettings = {
|
|
22
|
+
...epOpenidConnect.exportedForTestingOnly.defaultSettings,
|
|
23
|
+
...client,
|
|
24
|
+
// base_url and issuer will be filled in later.
|
|
25
|
+
scope: ['openid', 'etherpad'],
|
|
26
|
+
user_properties: {
|
|
27
|
+
is_admin: {claim: 'etherpad_is_admin'},
|
|
28
|
+
readOnly: {claim: 'etherpad_readOnly'},
|
|
29
|
+
canCreate: {claim: 'etherpad_canCreate'},
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
let socket;
|
|
33
|
+
|
|
34
|
+
before(async function () {
|
|
35
|
+
await common.init();
|
|
36
|
+
backup.settings = {...settings};
|
|
37
|
+
settings.requireAuthentication = true;
|
|
38
|
+
settings.requireAuthorization = false;
|
|
39
|
+
provider = new OidcProvider();
|
|
40
|
+
const clients =
|
|
41
|
+
[{...client, redirect_uris: [new URL('/ep_openid_connect/callback', common.baseUrl)]}];
|
|
42
|
+
await provider.start({clients});
|
|
43
|
+
issuer = provider.issuer;
|
|
44
|
+
pluginSettings.base_url = common.baseUrl;
|
|
45
|
+
pluginSettings.issuer = issuer;
|
|
46
|
+
await epOpenidConnect.loadSettings(
|
|
47
|
+
'loadSettings', {settings: {ep_openid_connect: pluginSettings}});
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeEach(async function () {
|
|
51
|
+
agent = supertest.agent('');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
afterEach(async function () {
|
|
55
|
+
if (socket != null) socket.close();
|
|
56
|
+
socket = null;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
after(async function () {
|
|
60
|
+
if (provider != null) await provider.stop();
|
|
61
|
+
provider = null;
|
|
62
|
+
Object.assign(settings, backup.settings);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('not logged in redirects to login endpoint', async function () {
|
|
66
|
+
const params = new URLSearchParams();
|
|
67
|
+
params.append('redirect_uri', new URL('/', common.baseUrl));
|
|
68
|
+
const wantRedirect = new URL(`/ep_openid_connect/login?${params}`, common.baseUrl).toString();
|
|
69
|
+
await agent.get(common.baseUrl)
|
|
70
|
+
.expect(302)
|
|
71
|
+
.expect('location', wantRedirect);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('login endpoint redirects to authorization URL', async function () {
|
|
75
|
+
const params = new URLSearchParams();
|
|
76
|
+
params.append('redirect_uri', new URL('/', common.baseUrl));
|
|
77
|
+
await agent.get(new URL(`/ep_openid_connect/login?${params}`, common.baseUrl))
|
|
78
|
+
.expect(302)
|
|
79
|
+
.expect('location', /.*/)
|
|
80
|
+
.expect((res) => {
|
|
81
|
+
assert(res.headers.location.startsWith(`${new URL('/auth', issuer)}?`));
|
|
82
|
+
const location = new URL(res.headers.location);
|
|
83
|
+
const params = location.searchParams;
|
|
84
|
+
assert.deepEqual(params.getAll('redirect_uri'),
|
|
85
|
+
[new URL('/ep_openid_connect/callback', common.baseUrl).toString()]);
|
|
86
|
+
assert.deepEqual(params.getAll('client_id'), [pluginSettings.client_id]);
|
|
87
|
+
assert.deepEqual(params.getAll('response_type'), ['code']);
|
|
88
|
+
assert.deepEqual(params.getAll('scope'), [pluginSettings.scope.join(' ')]);
|
|
89
|
+
for (const param of ['code_challenge', 'nonce', 'state']) {
|
|
90
|
+
assert.equal(params.getAll(param).length, 1);
|
|
91
|
+
assert.notEqual(params.get(param), '');
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('normalUser can create and edit a pad', async function () {
|
|
97
|
+
const padId = common.randomString();
|
|
98
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
99
|
+
const res = await login(agent, issuer, url, 'normalUser');
|
|
100
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
101
|
+
assert.equal(res.status, 200);
|
|
102
|
+
socket = await common.connect(res);
|
|
103
|
+
const msg = await common.handshake(socket, padId);
|
|
104
|
+
assert.equal(msg.type, 'CLIENT_VARS', `not a CLIENT_VARS message: ${JSON.stringify(msg)}`);
|
|
105
|
+
assert.equal(msg.data.readonly, false);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('noCreate user is unable to create a pad', async function () {
|
|
109
|
+
const padId = common.randomString();
|
|
110
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
111
|
+
const res = await login(agent, issuer, url, 'noCreate');
|
|
112
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
113
|
+
assert.equal(res.status, 200);
|
|
114
|
+
socket = await common.connect(res);
|
|
115
|
+
const message = await common.handshake(socket, padId);
|
|
116
|
+
assert.equal(message.accessStatus, 'deny');
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('noCreate user can edit an existing pad', async function () {
|
|
120
|
+
const padId = common.randomString();
|
|
121
|
+
await padManager.getPad(padId, 'this is a test');
|
|
122
|
+
assert(await padManager.doesPadExist(padId));
|
|
123
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
124
|
+
const res = await login(agent, issuer, url, 'noCreate');
|
|
125
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
126
|
+
assert.equal(res.status, 200);
|
|
127
|
+
socket = await common.connect(res);
|
|
128
|
+
const msg = await common.handshake(socket, padId);
|
|
129
|
+
assert.equal(msg.type, 'CLIENT_VARS', `not a CLIENT_VARS message: ${JSON.stringify(msg)}`);
|
|
130
|
+
assert.equal(msg.data.readonly, false);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('readOnly user is unable to create a pad', async function () {
|
|
134
|
+
const padId = common.randomString();
|
|
135
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
136
|
+
const res = await login(agent, issuer, url, 'readOnly');
|
|
137
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
138
|
+
assert.equal(res.status, 200);
|
|
139
|
+
socket = await common.connect(res);
|
|
140
|
+
const message = await common.handshake(socket, padId);
|
|
141
|
+
assert.equal(message.accessStatus, 'deny');
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('readOnly user can view but not edit an existing pad', async function () {
|
|
145
|
+
const padId = common.randomString();
|
|
146
|
+
await padManager.getPad(padId, 'this is a test');
|
|
147
|
+
assert(await padManager.doesPadExist(padId));
|
|
148
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
149
|
+
const res = await login(agent, issuer, url, 'readOnly');
|
|
150
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
151
|
+
assert.equal(res.status, 200);
|
|
152
|
+
socket = await common.connect(res);
|
|
153
|
+
const msg = await common.handshake(socket, padId);
|
|
154
|
+
assert.equal(msg.type, 'CLIENT_VARS', `not a CLIENT_VARS message: ${JSON.stringify(msg)}`);
|
|
155
|
+
assert.equal(msg.data.readonly, true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('authentication failure', async function () {
|
|
159
|
+
const res = await login(agent, issuer, new URL('/', common.baseUrl).toString(), 'fail');
|
|
160
|
+
assert.equal(res.status, 500);
|
|
161
|
+
const gotUrl = res.request.url;
|
|
162
|
+
const wantUrlBase = new URL('/ep_openid_connect/callback?', common.baseUrl).toString();
|
|
163
|
+
assert(gotUrl.startsWith(wantUrlBase), `expected ${gotUrl} to start with ${wantUrlBase}`);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe('prohibited usernames', function () {
|
|
167
|
+
// The OIDC provider rejects '' as a username so that can't be tested here.
|
|
168
|
+
for (const username of [...pluginSettings.prohibited_usernames, '__proto__']) {
|
|
169
|
+
it(JSON.stringify(username), async function () {
|
|
170
|
+
const res = await login(agent, issuer, new URL('/', common.baseUrl).toString(), username);
|
|
171
|
+
assert(res.request.url.startsWith(new URL('/ep_openid_connect/callback?', common.baseUrl)));
|
|
172
|
+
assert.equal(res.status, 500);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe('admin access', function () {
|
|
178
|
+
it('adminUser can access /admin/', async function () {
|
|
179
|
+
const url = new URL('/admin/', common.baseUrl).toString();
|
|
180
|
+
// Note that the username is "adminUser", not "admin", because "admin" is prohibited.
|
|
181
|
+
const res = await login(agent, issuer, url, 'adminUser');
|
|
182
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
183
|
+
assert.equal(res.status, 200);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('normalUser is unable to access /admin/', async function () {
|
|
187
|
+
const url = new URL('/admin/', common.baseUrl).toString();
|
|
188
|
+
const res = await login(agent, issuer, url, 'normalUser');
|
|
189
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
190
|
+
assert.equal(res.status, 403);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('visiting callback endpoint without session does not crash', async function () {
|
|
195
|
+
await agent.get(new URL('/ep_openid_connect/callback', common.baseUrl))
|
|
196
|
+
.expect(500);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('visiting login endpoint directly redirects to base URL', async function () {
|
|
200
|
+
const url = new URL('/ep_openid_connect/login', common.baseUrl).toString();
|
|
201
|
+
const res = await login(agent, issuer, url, 'normalUser');
|
|
202
|
+
assert.equal(res.request.url, new URL('/', common.baseUrl).toString());
|
|
203
|
+
assert.equal(res.status, 200);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('visiting logout endpoint destroys session, redirects to base URL', async function () {
|
|
207
|
+
const padId = common.randomString();
|
|
208
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
209
|
+
const res = await login(agent, issuer, url, 'normalUser');
|
|
210
|
+
assert.equal(res.request.url, url);
|
|
211
|
+
assert.equal(res.status, 200);
|
|
212
|
+
await agent.get(new URL('/ep_openid_connect/logout', common.baseUrl))
|
|
213
|
+
.expect(302)
|
|
214
|
+
.expect('location', new URL('/', common.baseUrl).toString());
|
|
215
|
+
// Visiting any page that requires authentication should now redirect to the login page.
|
|
216
|
+
await agent.get(new URL('/', common.baseUrl))
|
|
217
|
+
.expect(302)
|
|
218
|
+
.expect('location', /./)
|
|
219
|
+
.expect((res) => assert(res.headers.location.startsWith(
|
|
220
|
+
new URL('/ep_openid_connect/login?', common.baseUrl).toString())));
|
|
221
|
+
});
|
|
222
|
+
});
|