ep_openid_connect 2.2.0 → 2.5.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/.github/workflows/npmpublish.yml +4 -0
- package/README.md +0 -2
- package/index.js +65 -10
- 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 +228 -0
package/README.md
CHANGED
|
@@ -55,8 +55,6 @@ Etherpad's `requireAuthentication` setting must be `true`.
|
|
|
55
55
|
* `displayname_claim` (optional; defaults to `"name"`): The default value of
|
|
56
56
|
`user_properties.displayname.claim`. **Deprecated:** Set
|
|
57
57
|
`user_properties.displayname.claim` instead.
|
|
58
|
-
* `response_types` (optional; defaults to `["code"]`): List of OpenID Connect
|
|
59
|
-
response types.
|
|
60
58
|
* `scope` (optional; defaults to `["openid"]`): List of OAuth2 scope strings.
|
|
61
59
|
* `permit_displayname_change` (optional; defaults to `false`): Whether users may
|
|
62
60
|
change their displayed name.
|
package/index.js
CHANGED
|
@@ -6,14 +6,14 @@ const {Issuer, generators} = require('openid-client');
|
|
|
6
6
|
const authorManager = require('ep_etherpad-lite/node/db/AuthorManager');
|
|
7
7
|
|
|
8
8
|
const logger = log4js.getLogger('ep_openid_connect');
|
|
9
|
-
const
|
|
9
|
+
const defaultSettings = {
|
|
10
10
|
displayname_claim: 'name',
|
|
11
|
-
response_types: ['code'],
|
|
12
11
|
permit_displayname_change: false,
|
|
13
12
|
prohibited_usernames: ['admin', 'guest'],
|
|
14
13
|
scope: ['openid'],
|
|
15
14
|
user_properties: {},
|
|
16
15
|
};
|
|
16
|
+
const settings = {...defaultSettings};
|
|
17
17
|
let oidcClient = null;
|
|
18
18
|
|
|
19
19
|
const ep = (endpoint) => `/ep_openid_connect/${endpoint}`;
|
|
@@ -81,6 +81,10 @@ exports.loadSettings = async (hookName, {settings: {ep_openid_connect: config =
|
|
|
81
81
|
logger.warn('Ignoring ep_openid_connect.issuer_metadata setting ' +
|
|
82
82
|
'because ep_openid_connect.issuer is set');
|
|
83
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;
|
|
87
|
+
}
|
|
84
88
|
// Make sure base_url ends with '/' so that relative URLs are appended:
|
|
85
89
|
if (!settings.base_url.endsWith('/')) settings.base_url += '/';
|
|
86
90
|
settings.user_properties = {
|
|
@@ -89,10 +93,11 @@ exports.loadSettings = async (hookName, {settings: {ep_openid_connect: config =
|
|
|
89
93
|
// The username property must always match the key used in settings.users.
|
|
90
94
|
username: {claim: 'sub'},
|
|
91
95
|
};
|
|
96
|
+
logger.debug('Settings:', {...settings, client_secret: '********'});
|
|
92
97
|
oidcClient = new (await getIssuer(settings)).Client({
|
|
93
98
|
client_id: settings.client_id,
|
|
94
99
|
client_secret: settings.client_secret,
|
|
95
|
-
response_types:
|
|
100
|
+
response_types: ['code'],
|
|
96
101
|
redirect_uris: [endpointUrl('callback')],
|
|
97
102
|
});
|
|
98
103
|
logger.info('Configured.');
|
|
@@ -105,13 +110,16 @@ exports.clientVars = (hookName, context) => {
|
|
|
105
110
|
};
|
|
106
111
|
|
|
107
112
|
exports.expressCreateServer = (hookName, {app}) => {
|
|
108
|
-
if (oidcClient == null) return;
|
|
109
113
|
logger.debug('Configuring auth routes');
|
|
110
114
|
app.get(ep('callback'), async (req, res, next) => {
|
|
111
115
|
// This handler MUST NOT redirect to a page that requires authentication if there is a problem,
|
|
112
116
|
// otherwise the user could be caught in an infinite redirect loop.
|
|
113
117
|
try {
|
|
114
118
|
logger.debug(`Processing ${req.url}`);
|
|
119
|
+
if (oidcClient == null) {
|
|
120
|
+
logger.warn('Not configured; ignoring request.');
|
|
121
|
+
return next();
|
|
122
|
+
}
|
|
115
123
|
const params = oidcClient.callbackParams(req);
|
|
116
124
|
const oidcSession = req.session.ep_openid_connect || {};
|
|
117
125
|
if (oidcSession.callbackChecks == null) throw new Error('missing authentication checks');
|
|
@@ -127,7 +135,7 @@ exports.expressCreateServer = (hookName, {app}) => {
|
|
|
127
135
|
// userinfo should not be stored in req.session until after all checks have passed. (Otherwise
|
|
128
136
|
// it would be too easy to accidentally introduce a vulnerability.)
|
|
129
137
|
oidcSession.userinfo = userinfo;
|
|
130
|
-
res.redirect(oidcSession.next || settings.base_url);
|
|
138
|
+
res.redirect(303, oidcSession.next || settings.base_url);
|
|
131
139
|
// Defer deletion of state until success so that the user can reload the page to retry after a
|
|
132
140
|
// transient backchannel failure.
|
|
133
141
|
delete oidcSession.callbackChecks;
|
|
@@ -138,9 +146,12 @@ exports.expressCreateServer = (hookName, {app}) => {
|
|
|
138
146
|
});
|
|
139
147
|
app.get(ep('login'), (req, res, next) => {
|
|
140
148
|
logger.debug(`Processing ${req.url}`);
|
|
149
|
+
if (oidcClient == null) {
|
|
150
|
+
logger.warn('Not configured; ignoring request.');
|
|
151
|
+
return next();
|
|
152
|
+
}
|
|
141
153
|
if (req.session.ep_openid_connect == null) req.session.ep_openid_connect = {};
|
|
142
154
|
const oidcSession = req.session.ep_openid_connect;
|
|
143
|
-
oidcSession.next = req.query.redirect_uri || settings.base_url;
|
|
144
155
|
const commonParams = {
|
|
145
156
|
nonce: generators.nonce(),
|
|
146
157
|
scope: settings.scope.join(' '),
|
|
@@ -150,14 +161,21 @@ exports.expressCreateServer = (hookName, {app}) => {
|
|
|
150
161
|
...commonParams,
|
|
151
162
|
code_verifier: generators.codeVerifier(), // RFC7636
|
|
152
163
|
};
|
|
153
|
-
res.redirect(oidcClient.authorizationUrl({
|
|
164
|
+
res.redirect(303, oidcClient.authorizationUrl({
|
|
154
165
|
...commonParams,
|
|
155
166
|
// RFC7636
|
|
156
167
|
code_challenge: generators.codeChallenge(oidcSession.callbackChecks.code_verifier),
|
|
157
168
|
code_challenge_method: 'S256',
|
|
158
169
|
}));
|
|
159
170
|
});
|
|
160
|
-
app.get(ep('logout'), (req, res
|
|
171
|
+
app.get(ep('logout'), (req, res, next) => {
|
|
172
|
+
logger.debug(`Processing ${req.url}`);
|
|
173
|
+
if (oidcClient == null) {
|
|
174
|
+
logger.warn('Not configured; ignoring request.');
|
|
175
|
+
return next();
|
|
176
|
+
}
|
|
177
|
+
req.session.destroy(() => res.redirect(303, settings.base_url));
|
|
178
|
+
});
|
|
161
179
|
};
|
|
162
180
|
|
|
163
181
|
exports.authenticate = (hookName, {req, res, users}) => {
|
|
@@ -188,8 +206,41 @@ exports.authenticate = (hookName, {req, res, users}) => {
|
|
|
188
206
|
|
|
189
207
|
exports.authnFailure = (hookName, {req, res}) => {
|
|
190
208
|
if (oidcClient == null) return;
|
|
191
|
-
|
|
192
|
-
|
|
209
|
+
// Normally the user is redirected to the login page which would then redirect the user back once
|
|
210
|
+
// authenticated. For non-GET requests, send a 401 instead because users can't be redirected back.
|
|
211
|
+
// Also send a 401 if an Authorization header is present to facilitate API error handling.
|
|
212
|
+
//
|
|
213
|
+
// 401 is the status that most closely matches the desired semantics. However, RFC7235 section
|
|
214
|
+
// 3.1 says, "The server generating a 401 response MUST send a WWW-Authenticate header field
|
|
215
|
+
// containing at least one challenge applicable to the target resource." Etherpad uses a token
|
|
216
|
+
// (signed session identifier) transmitted via cookie for authentication, but there is no
|
|
217
|
+
// standard authentication scheme name for that. So we use a non-standard name here.
|
|
218
|
+
//
|
|
219
|
+
// We could theoretically implement Bearer authorization (RFC6750), but it's unclear to me how
|
|
220
|
+
// to do this correctly and securely:
|
|
221
|
+
// * The userinfo endpoint is meant for the OAuth client, not the resource server, so it
|
|
222
|
+
// shouldn't be used to look up claims.
|
|
223
|
+
// * In general, access tokens might be opaque (not JWTs) so we can't get claims by parsing
|
|
224
|
+
// them.
|
|
225
|
+
// * The token introspection endpoint should return scope and subject (I think?), but probably
|
|
226
|
+
// not claims.
|
|
227
|
+
// * If claims can't be used to convey access level, how is it conveyed? Scope? Resource
|
|
228
|
+
// indicators (RFC8707)?
|
|
229
|
+
// * How is intended audience checked? Or is introspection guaranteed to do that for us?
|
|
230
|
+
// * Should tokens be limited to a particular pad?
|
|
231
|
+
// * Bearer tokens are only meant to convey authorization; authentication is handled by the
|
|
232
|
+
// authorization server. Should Bearer tokens be processed during the authorize hook?
|
|
233
|
+
// * How should bearer authentication interact with authorization plugins?
|
|
234
|
+
// * How should bearer authentication interact with plugins that add new endpoints?
|
|
235
|
+
// * Would we have to implement our own OAuth server to issue access tokens?
|
|
236
|
+
res.header('WWW-Authenticate', 'Etherpad');
|
|
237
|
+
if (!['GET', 'HEAD'].includes(req.method) || req.headers.authorization) {
|
|
238
|
+
res.status(401).end();
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
if (req.session.ep_openid_connect == null) req.session.ep_openid_connect = {};
|
|
242
|
+
req.session.ep_openid_connect.next = new URL(req.url.slice(1), settings.base_url).toString();
|
|
243
|
+
res.redirect(303, endpointUrl('login'));
|
|
193
244
|
return true;
|
|
194
245
|
};
|
|
195
246
|
|
|
@@ -217,3 +268,7 @@ exports.preAuthorize = (hookName, {req}) => {
|
|
|
217
268
|
if (req.path.startsWith(ep(''))) return true;
|
|
218
269
|
return;
|
|
219
270
|
};
|
|
271
|
+
|
|
272
|
+
exports.exportedForTestingOnly = {
|
|
273
|
+
defaultSettings,
|
|
274
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ep_openid_connect",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.1",
|
|
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 (res.status !== 303) 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,228 @@
|
|
|
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
|
+
await agent.get(common.baseUrl)
|
|
67
|
+
.expect(303)
|
|
68
|
+
.expect('location', new URL('/ep_openid_connect/login', common.baseUrl).toString());
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('login endpoint redirects to authorization URL', async function () {
|
|
72
|
+
await agent.get(new URL('/ep_openid_connect/login', common.baseUrl))
|
|
73
|
+
.expect(303)
|
|
74
|
+
.expect('location', /.*/)
|
|
75
|
+
.expect((res) => {
|
|
76
|
+
assert(res.headers.location.startsWith(`${new URL('/auth', issuer)}?`));
|
|
77
|
+
const location = new URL(res.headers.location);
|
|
78
|
+
const params = location.searchParams;
|
|
79
|
+
assert.deepEqual(params.getAll('redirect_uri'),
|
|
80
|
+
[new URL('/ep_openid_connect/callback', common.baseUrl).toString()]);
|
|
81
|
+
assert.deepEqual(params.getAll('client_id'), [pluginSettings.client_id]);
|
|
82
|
+
assert.deepEqual(params.getAll('response_type'), ['code']);
|
|
83
|
+
assert.deepEqual(params.getAll('scope'), [pluginSettings.scope.join(' ')]);
|
|
84
|
+
for (const param of ['code_challenge', 'nonce', 'state']) {
|
|
85
|
+
assert.equal(params.getAll(param).length, 1);
|
|
86
|
+
assert.notEqual(params.get(param), '');
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('normalUser can create and edit a pad', async function () {
|
|
92
|
+
const padId = common.randomString();
|
|
93
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
94
|
+
const res = await login(agent, issuer, url, 'normalUser');
|
|
95
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
96
|
+
assert.equal(res.status, 200);
|
|
97
|
+
socket = await common.connect(res);
|
|
98
|
+
const msg = await common.handshake(socket, padId);
|
|
99
|
+
assert.equal(msg.type, 'CLIENT_VARS', `not a CLIENT_VARS message: ${JSON.stringify(msg)}`);
|
|
100
|
+
assert.equal(msg.data.readonly, false);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('noCreate user is unable to create a pad', async function () {
|
|
104
|
+
const padId = common.randomString();
|
|
105
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
106
|
+
const res = await login(agent, issuer, url, 'noCreate');
|
|
107
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
108
|
+
assert.equal(res.status, 200);
|
|
109
|
+
socket = await common.connect(res);
|
|
110
|
+
const message = await common.handshake(socket, padId);
|
|
111
|
+
assert.equal(message.accessStatus, 'deny');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('noCreate user can edit an existing pad', async function () {
|
|
115
|
+
const padId = common.randomString();
|
|
116
|
+
await padManager.getPad(padId, 'this is a test');
|
|
117
|
+
assert(await padManager.doesPadExist(padId));
|
|
118
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
119
|
+
const res = await login(agent, issuer, url, 'noCreate');
|
|
120
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
121
|
+
assert.equal(res.status, 200);
|
|
122
|
+
socket = await common.connect(res);
|
|
123
|
+
const msg = await common.handshake(socket, padId);
|
|
124
|
+
assert.equal(msg.type, 'CLIENT_VARS', `not a CLIENT_VARS message: ${JSON.stringify(msg)}`);
|
|
125
|
+
assert.equal(msg.data.readonly, false);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('readOnly user is unable to create a pad', async function () {
|
|
129
|
+
const padId = common.randomString();
|
|
130
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
131
|
+
const res = await login(agent, issuer, url, 'readOnly');
|
|
132
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
133
|
+
assert.equal(res.status, 200);
|
|
134
|
+
socket = await common.connect(res);
|
|
135
|
+
const message = await common.handshake(socket, padId);
|
|
136
|
+
assert.equal(message.accessStatus, 'deny');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('readOnly user can view but not edit an existing pad', async function () {
|
|
140
|
+
const padId = common.randomString();
|
|
141
|
+
await padManager.getPad(padId, 'this is a test');
|
|
142
|
+
assert(await padManager.doesPadExist(padId));
|
|
143
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
144
|
+
const res = await login(agent, issuer, url, 'readOnly');
|
|
145
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
146
|
+
assert.equal(res.status, 200);
|
|
147
|
+
socket = await common.connect(res);
|
|
148
|
+
const msg = await common.handshake(socket, padId);
|
|
149
|
+
assert.equal(msg.type, 'CLIENT_VARS', `not a CLIENT_VARS message: ${JSON.stringify(msg)}`);
|
|
150
|
+
assert.equal(msg.data.readonly, true);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('authentication failure', async function () {
|
|
154
|
+
const res = await login(agent, issuer, new URL('/', common.baseUrl).toString(), 'fail');
|
|
155
|
+
assert.equal(res.status, 500);
|
|
156
|
+
const gotUrl = res.request.url;
|
|
157
|
+
const wantUrlBase = new URL('/ep_openid_connect/callback?', common.baseUrl).toString();
|
|
158
|
+
assert(gotUrl.startsWith(wantUrlBase), `expected ${gotUrl} to start with ${wantUrlBase}`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe('prohibited usernames', function () {
|
|
162
|
+
// The OIDC provider rejects '' as a username so that can't be tested here.
|
|
163
|
+
for (const username of [...pluginSettings.prohibited_usernames, '__proto__']) {
|
|
164
|
+
it(JSON.stringify(username), async function () {
|
|
165
|
+
const res = await login(agent, issuer, new URL('/', common.baseUrl).toString(), username);
|
|
166
|
+
assert(res.request.url.startsWith(new URL('/ep_openid_connect/callback?', common.baseUrl)));
|
|
167
|
+
assert.equal(res.status, 500);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('admin access', function () {
|
|
173
|
+
it('adminUser can access /admin/', async function () {
|
|
174
|
+
const url = new URL('/admin/', common.baseUrl).toString();
|
|
175
|
+
// Note that the username is "adminUser", not "admin", because "admin" is prohibited.
|
|
176
|
+
const res = await login(agent, issuer, url, 'adminUser');
|
|
177
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
178
|
+
assert.equal(res.status, 200);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('normalUser is unable to access /admin/', async function () {
|
|
182
|
+
const url = new URL('/admin/', common.baseUrl).toString();
|
|
183
|
+
const res = await login(agent, issuer, url, 'normalUser');
|
|
184
|
+
assert.equal(res.request.url, url); // Should have been redirected back after authenticating.
|
|
185
|
+
assert.equal(res.status, 403);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('visiting callback endpoint without session does not crash', async function () {
|
|
190
|
+
await agent.get(new URL('/ep_openid_connect/callback', common.baseUrl))
|
|
191
|
+
.expect(500);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it('visiting login endpoint directly redirects to base URL', async function () {
|
|
195
|
+
const url = new URL('/ep_openid_connect/login', common.baseUrl).toString();
|
|
196
|
+
const res = await login(agent, issuer, url, 'normalUser');
|
|
197
|
+
assert.equal(res.request.url, new URL('/', common.baseUrl).toString());
|
|
198
|
+
assert.equal(res.status, 200);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('visiting logout endpoint destroys session, redirects to base URL', async function () {
|
|
202
|
+
const padId = common.randomString();
|
|
203
|
+
const url = new URL(`/p/${padId}`, common.baseUrl).toString();
|
|
204
|
+
const res = await login(agent, issuer, url, 'normalUser');
|
|
205
|
+
assert.equal(res.request.url, url);
|
|
206
|
+
assert.equal(res.status, 200);
|
|
207
|
+
await agent.get(new URL('/ep_openid_connect/logout', common.baseUrl))
|
|
208
|
+
.expect(303)
|
|
209
|
+
.expect('location', new URL('/', common.baseUrl).toString());
|
|
210
|
+
// Visiting any page that requires authentication should now redirect to the login page.
|
|
211
|
+
await agent.get(new URL('/', common.baseUrl))
|
|
212
|
+
.expect(303)
|
|
213
|
+
.expect('location', new URL('/ep_openid_connect/login', common.baseUrl).toString());
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('HTTP PUT gives 401 instead of redirect', async function () {
|
|
217
|
+
await agent.put(common.baseUrl)
|
|
218
|
+
.expect(401)
|
|
219
|
+
.expect('www-authenticate', 'Etherpad');
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('Authorization header results in 401 instead of redirect', async function () {
|
|
223
|
+
await agent.get(common.baseUrl)
|
|
224
|
+
.set('Authorization', 'Basic dXNlcm5hbWU6cGFzc3dvcmQ=')
|
|
225
|
+
.expect(401)
|
|
226
|
+
.expect('www-authenticate', 'Etherpad');
|
|
227
|
+
});
|
|
228
|
+
});
|