ep_openid_connect 3.0.27 → 3.0.28
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/CHANGELOG.md +14 -0
- package/README.md +8 -5
- package/index.js +62 -29
- package/package.json +1 -1
- package/static/tests/backend/specs/auto_pick_auth_method.js +117 -0
- package/static/tests/backend/specs/pick_auth_method.js +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
#### Behaviour changes
|
|
6
|
+
|
|
7
|
+
* `token_endpoint_auth_method` now defaults to auto-detection from the
|
|
8
|
+
identity provider's discovery document. The plugin prefers
|
|
9
|
+
`client_secret_post` when advertised and falls back to
|
|
10
|
+
`client_secret_basic`. This fixes silent token-endpoint
|
|
11
|
+
`WWWAuthenticateChallengeError` failures on GitLab.com (which advertises
|
|
12
|
+
both methods but rejects `client_secret_basic`). Users who previously
|
|
13
|
+
relied on the `client_secret_basic` default without setting it explicitly
|
|
14
|
+
may now exchange tokens via `client_secret_post`; set the option
|
|
15
|
+
explicitly to pin a specific method.
|
|
16
|
+
|
|
3
17
|
## v3.0.0
|
|
4
18
|
|
|
5
19
|
Released 2022-03-04.
|
package/README.md
CHANGED
|
@@ -49,11 +49,14 @@ Etherpad's `requireAuthentication` setting must be `true`.
|
|
|
49
49
|
* `client_id` (required): The OAuth2 client ID issued by the identity provider.
|
|
50
50
|
* `client_secret` (required): The OAuth2 client secret issued by the identity
|
|
51
51
|
provider.
|
|
52
|
-
* `token_endpoint_auth_method` (optional;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
52
|
+
* `token_endpoint_auth_method` (optional; auto-detected by default): Client
|
|
53
|
+
authentication method to use for token endpoint requests. Supported values
|
|
54
|
+
are `client_secret_basic` and `client_secret_post`. When not set, the plugin
|
|
55
|
+
inspects the identity provider's discovery document
|
|
56
|
+
(`token_endpoint_auth_methods_supported`) and picks `client_secret_post` if
|
|
57
|
+
the provider advertises it, otherwise `client_secret_basic`. Set this
|
|
58
|
+
explicitly to force a specific method — for example, if your identity
|
|
59
|
+
provider advertises both but your client is registered for only one.
|
|
57
60
|
* `base_url` (required): The public base Etherpad URL. When registering Etherpad
|
|
58
61
|
with your identity provider, the redirect URL (a.k.a. callback URL) is this
|
|
59
62
|
base URL plus `/ep_openid_connect/callback`.
|
package/index.js
CHANGED
|
@@ -10,7 +10,6 @@ for (const level of ['debug', 'info', 'warn', 'error']) {
|
|
|
10
10
|
const defaultSettings = {
|
|
11
11
|
prohibited_usernames: ['admin', 'guest'],
|
|
12
12
|
scope: ['openid'],
|
|
13
|
-
token_endpoint_auth_method: 'client_secret_basic',
|
|
14
13
|
user_properties: {},
|
|
15
14
|
};
|
|
16
15
|
let settings;
|
|
@@ -78,7 +77,41 @@ const isHttp = (urlString) => {
|
|
|
78
77
|
}
|
|
79
78
|
};
|
|
80
79
|
|
|
81
|
-
|
|
80
|
+
// Pick the token endpoint auth method to use, given the IdP's advertised
|
|
81
|
+
// `token_endpoint_auth_methods_supported` and any explicit override from
|
|
82
|
+
// settings. Pure; exported for unit testing.
|
|
83
|
+
//
|
|
84
|
+
// Preference order when no override is set:
|
|
85
|
+
// 1. `client_secret_post` if the IdP advertises it (matches openid-client@6's
|
|
86
|
+
// own default and works with every public IdP we've tested — notably
|
|
87
|
+
// GitLab.com, which rejects `client_secret_basic` at the token endpoint
|
|
88
|
+
// even though it lists it in discovery).
|
|
89
|
+
// 2. `client_secret_basic` if the IdP advertises only that.
|
|
90
|
+
// 3. `client_secret_basic` if the IdP advertises nothing we recognise (RFC
|
|
91
|
+
// 8414 §2 says the absence of the field defaults to `client_secret_basic`).
|
|
92
|
+
const pickAuthMethod = (supported, override) => {
|
|
93
|
+
if (override) return override;
|
|
94
|
+
if (Array.isArray(supported)) {
|
|
95
|
+
if (supported.includes('client_secret_post')) return 'client_secret_post';
|
|
96
|
+
if (supported.includes('client_secret_basic')) return 'client_secret_basic';
|
|
97
|
+
}
|
|
98
|
+
return 'client_secret_basic';
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const clientAuthFor = (method, secret) => {
|
|
102
|
+
switch (method) {
|
|
103
|
+
case 'client_secret_basic':
|
|
104
|
+
// eslint-disable-next-line new-cap
|
|
105
|
+
return oidc.ClientSecretBasic(secret);
|
|
106
|
+
case 'client_secret_post':
|
|
107
|
+
// eslint-disable-next-line new-cap
|
|
108
|
+
return oidc.ClientSecretPost(secret);
|
|
109
|
+
default:
|
|
110
|
+
throw new Error(`Unsupported token endpoint auth method: ${method}`);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const fetchServerMetadata = async (issuerUrl, clientId) => {
|
|
82
115
|
const url = new URL(issuerUrl);
|
|
83
116
|
// https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery says that the URI
|
|
84
117
|
// must not have query or fragment components.
|
|
@@ -94,7 +127,13 @@ const discoverConfig = async (issuerUrl, clientId, clientAuth) => {
|
|
|
94
127
|
? {execute: [oidc.allowInsecureRequests]}
|
|
95
128
|
: undefined;
|
|
96
129
|
try {
|
|
97
|
-
|
|
130
|
+
// Discovery fetches the .well-known/openid-configuration document. The
|
|
131
|
+
// clientAuthentication argument is irrelevant for that HTTP request — it
|
|
132
|
+
// only matters when the Configuration is later used to exchange a code
|
|
133
|
+
// for a token — so we can pass `undefined` here and bind the real method
|
|
134
|
+
// below once we know what the IdP supports.
|
|
135
|
+
const tempConfig = await oidc.discovery(url, clientId, undefined, undefined, options);
|
|
136
|
+
return tempConfig.serverMetadata();
|
|
98
137
|
} catch (err) {
|
|
99
138
|
// The URL used to get the issuer metadata doesn't exactly follow RFC 8615; see:
|
|
100
139
|
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
|
|
@@ -111,36 +150,30 @@ const discoverConfig = async (issuerUrl, clientId, clientAuth) => {
|
|
|
111
150
|
}
|
|
112
151
|
};
|
|
113
152
|
|
|
114
|
-
const getClientAuth = (settings) => {
|
|
115
|
-
switch (settings.token_endpoint_auth_method) {
|
|
116
|
-
case 'client_secret_basic':
|
|
117
|
-
// eslint-disable-next-line new-cap
|
|
118
|
-
return oidc.ClientSecretBasic(settings.client_secret);
|
|
119
|
-
case 'client_secret_post':
|
|
120
|
-
// eslint-disable-next-line new-cap
|
|
121
|
-
return oidc.ClientSecretPost(settings.client_secret);
|
|
122
|
-
default:
|
|
123
|
-
throw new Error(
|
|
124
|
-
`Unsupported token endpoint auth method: ${settings.token_endpoint_auth_method}`);
|
|
125
|
-
}
|
|
126
|
-
};
|
|
127
|
-
|
|
128
153
|
const buildConfig = async (settings) => {
|
|
129
|
-
//
|
|
130
|
-
// `
|
|
131
|
-
//
|
|
132
|
-
|
|
154
|
+
// Resolve the server metadata (either via discovery or from the inline
|
|
155
|
+
// `issuer_metadata` blob) BEFORE choosing an auth method, so we can pick
|
|
156
|
+
// one the IdP actually advertises.
|
|
157
|
+
let serverMetadata;
|
|
158
|
+
let phaseLog;
|
|
133
159
|
if (settings.issuer) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
160
|
+
serverMetadata = await fetchServerMetadata(settings.issuer, settings.client_id);
|
|
161
|
+
phaseLog = 'OpenID Connect Discovery complete.';
|
|
162
|
+
} else {
|
|
163
|
+
serverMetadata = settings.issuer_metadata;
|
|
164
|
+
phaseLog = 'Configured from issuer_metadata.';
|
|
138
165
|
}
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
166
|
+
const method = pickAuthMethod(
|
|
167
|
+
serverMetadata && serverMetadata.token_endpoint_auth_methods_supported,
|
|
168
|
+
settings.token_endpoint_auth_method);
|
|
169
|
+
const clientAuth = clientAuthFor(method, settings.client_secret);
|
|
170
|
+
const config = new oidc.Configuration(
|
|
171
|
+
serverMetadata, settings.client_id, undefined, clientAuth);
|
|
172
|
+
if (isHttp(serverMetadata && serverMetadata.issuer)) {
|
|
142
173
|
oidc.allowInsecureRequests(config);
|
|
143
174
|
}
|
|
175
|
+
const source = settings.token_endpoint_auth_method ? 'configured' : 'auto-picked';
|
|
176
|
+
logger.info(`${phaseLog} Token endpoint auth method: ${method} (${source}).`);
|
|
144
177
|
return config;
|
|
145
178
|
};
|
|
146
179
|
|
|
@@ -179,7 +212,6 @@ exports.loadSettings = async (hookName, {settings: {ep_openid_connect: s = {}}})
|
|
|
179
212
|
if (!settings.base_url.endsWith('/')) settings.base_url += '/';
|
|
180
213
|
logger.debug('Settings:', {...settings, client_secret: '********'});
|
|
181
214
|
oidcConfig = await buildConfig(settings);
|
|
182
|
-
logger.info('Configured.');
|
|
183
215
|
};
|
|
184
216
|
|
|
185
217
|
exports.expressCreateServer = (hookName, {app}) => {
|
|
@@ -336,5 +368,6 @@ exports.preAuthorize = (hookName, {req}) => {
|
|
|
336
368
|
exports.exportedForTestingOnly = {
|
|
337
369
|
callbackUrlFromRequest,
|
|
338
370
|
defaultSettings,
|
|
371
|
+
pickAuthMethod,
|
|
339
372
|
validSettings,
|
|
340
373
|
};
|
package/package.json
CHANGED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// End-to-end coverage for the token_endpoint_auth_method auto-pick: spin up a
|
|
4
|
+
// dedicated OIDC provider whose discovery doc advertises only one auth method,
|
|
5
|
+
// load the plugin without an explicit override, and confirm a real login flow
|
|
6
|
+
// succeeds — i.e. the plugin chose the advertised method.
|
|
7
|
+
|
|
8
|
+
const OidcProvider = require('../oidc-provider');
|
|
9
|
+
const assert = require('assert').strict;
|
|
10
|
+
const common = require('ep_etherpad-lite/tests/backend/common');
|
|
11
|
+
const epOpenidConnect = require('../../../../index');
|
|
12
|
+
const login = require('../login');
|
|
13
|
+
const plugins = require('ep_etherpad-lite/static/js/pluginfw/plugin_defs');
|
|
14
|
+
const settings = require('ep_etherpad-lite/node/utils/Settings');
|
|
15
|
+
const supertest = require('supertest');
|
|
16
|
+
|
|
17
|
+
describe(__filename, function () {
|
|
18
|
+
let agent;
|
|
19
|
+
let provider;
|
|
20
|
+
const backup = {};
|
|
21
|
+
const client = {
|
|
22
|
+
client_id: 'the_client_id',
|
|
23
|
+
client_secret: 'the_client_secret',
|
|
24
|
+
};
|
|
25
|
+
const basePluginSettings = {
|
|
26
|
+
...client,
|
|
27
|
+
scope: ['openid', 'etherpad'],
|
|
28
|
+
user_properties: {
|
|
29
|
+
is_admin: {claim: 'etherpad_is_admin'},
|
|
30
|
+
readOnly: {claim: 'etherpad_readOnly'},
|
|
31
|
+
canCreate: {claim: 'etherpad_canCreate'},
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
before(async function () {
|
|
36
|
+
await common.init();
|
|
37
|
+
if (!plugins.hooks.clientVars) plugins.hooks.clientVars = [];
|
|
38
|
+
backup.hooks = {clientVars: plugins.hooks.clientVars};
|
|
39
|
+
backup.settings = {
|
|
40
|
+
requireAuthentication: settings.requireAuthentication,
|
|
41
|
+
requireAuthorization: settings.requireAuthorization,
|
|
42
|
+
users: settings.users,
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
beforeEach(function () {
|
|
47
|
+
agent = supertest.agent('');
|
|
48
|
+
settings.requireAuthentication = true;
|
|
49
|
+
settings.requireAuthorization = false;
|
|
50
|
+
settings.users = {};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
afterEach(async function () {
|
|
54
|
+
if (provider != null) await provider.stop();
|
|
55
|
+
provider = null;
|
|
56
|
+
Object.assign(plugins.hooks, backup.hooks);
|
|
57
|
+
settings.requireAuthentication = backup.settings.requireAuthentication;
|
|
58
|
+
settings.requireAuthorization = backup.settings.requireAuthorization;
|
|
59
|
+
settings.users = backup.settings.users;
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Restart `provider` with the given allowed auth methods, then load the
|
|
63
|
+
// plugin pointed at it with NO explicit token_endpoint_auth_method (so the
|
|
64
|
+
// picker runs).
|
|
65
|
+
const startProvider = async (allowedAuthMethods) => {
|
|
66
|
+
const redirectUri = new URL('/ep_openid_connect/callback', common.baseUrl).href;
|
|
67
|
+
provider = new OidcProvider();
|
|
68
|
+
await provider.start({
|
|
69
|
+
// oidc-provider v9: the global allow-list drives the advertised
|
|
70
|
+
// token_endpoint_auth_methods_supported and is enforced at the token
|
|
71
|
+
// endpoint. Each client must also declare which method it uses.
|
|
72
|
+
clientAuthMethods: allowedAuthMethods,
|
|
73
|
+
clients: [{
|
|
74
|
+
...client,
|
|
75
|
+
redirect_uris: [redirectUri],
|
|
76
|
+
token_endpoint_auth_method: allowedAuthMethods[0],
|
|
77
|
+
}],
|
|
78
|
+
});
|
|
79
|
+
await epOpenidConnect.loadSettings('loadSettings', {settings: {ep_openid_connect: {
|
|
80
|
+
...basePluginSettings,
|
|
81
|
+
base_url: common.baseUrl,
|
|
82
|
+
issuer: provider.issuer,
|
|
83
|
+
// Deliberately omit token_endpoint_auth_method — that's what we're testing.
|
|
84
|
+
}}});
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
it('picks client_secret_post when only that is advertised', async function () {
|
|
88
|
+
await startProvider(['client_secret_post']);
|
|
89
|
+
const url = new URL(`/p/${common.randomString()}`, common.baseUrl).toString();
|
|
90
|
+
const res = await login(agent, provider.issuer, url, 'normalUser');
|
|
91
|
+
assert.equal(res.request.url, url,
|
|
92
|
+
`expected redirect back to ${url}, got ${res.request.url}`);
|
|
93
|
+
assert.equal(res.status, 200);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('picks client_secret_basic when only that is advertised', async function () {
|
|
97
|
+
await startProvider(['client_secret_basic']);
|
|
98
|
+
const url = new URL(`/p/${common.randomString()}`, common.baseUrl).toString();
|
|
99
|
+
const res = await login(agent, provider.issuer, url, 'normalUser');
|
|
100
|
+
assert.equal(res.request.url, url,
|
|
101
|
+
`expected redirect back to ${url}, got ${res.request.url}`);
|
|
102
|
+
assert.equal(res.status, 200);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it('prefers client_secret_post when both are advertised', async function () {
|
|
106
|
+
// When both methods are on offer the plugin must pick _post (matches the
|
|
107
|
+
// openid-client@6 default and is the one GitLab.com actually accepts at
|
|
108
|
+
// the token endpoint). The test client is registered as _post-only, so a
|
|
109
|
+
// _basic attempt would 401 at the token endpoint.
|
|
110
|
+
await startProvider(['client_secret_post', 'client_secret_basic']);
|
|
111
|
+
const url = new URL(`/p/${common.randomString()}`, common.baseUrl).toString();
|
|
112
|
+
const res = await login(agent, provider.issuer, url, 'normalUser');
|
|
113
|
+
assert.equal(res.request.url, url,
|
|
114
|
+
`expected redirect back to ${url}, got ${res.request.url}`);
|
|
115
|
+
assert.equal(res.status, 200);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('assert').strict;
|
|
4
|
+
const {pickAuthMethod} = require('../../../..').exportedForTestingOnly;
|
|
5
|
+
|
|
6
|
+
describe(__filename, function () {
|
|
7
|
+
describe('with no override', function () {
|
|
8
|
+
it('prefers client_secret_post when advertised', function () {
|
|
9
|
+
assert.equal(
|
|
10
|
+
pickAuthMethod(['client_secret_basic', 'client_secret_post']),
|
|
11
|
+
'client_secret_post');
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('uses client_secret_basic when only that is advertised', function () {
|
|
15
|
+
assert.equal(pickAuthMethod(['client_secret_basic']), 'client_secret_basic');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('uses client_secret_post when only that is advertised', function () {
|
|
19
|
+
assert.equal(pickAuthMethod(['client_secret_post']), 'client_secret_post');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('ignores unsupported methods', function () {
|
|
23
|
+
// None of these are methods this plugin can implement, so the picker
|
|
24
|
+
// must fall back rather than pretend to support them.
|
|
25
|
+
assert.equal(
|
|
26
|
+
pickAuthMethod(['private_key_jwt', 'client_secret_jwt', 'none']),
|
|
27
|
+
'client_secret_basic');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('prefers client_secret_post even when other methods are advertised first', function () {
|
|
31
|
+
assert.equal(
|
|
32
|
+
pickAuthMethod(['private_key_jwt', 'client_secret_post', 'client_secret_basic']),
|
|
33
|
+
'client_secret_post');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('falls back to client_secret_basic when supported is undefined', function () {
|
|
37
|
+
// RFC 8414 §2: absence of token_endpoint_auth_methods_supported defaults
|
|
38
|
+
// to client_secret_basic.
|
|
39
|
+
assert.equal(pickAuthMethod(undefined), 'client_secret_basic');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('falls back to client_secret_basic when supported is empty', function () {
|
|
43
|
+
assert.equal(pickAuthMethod([]), 'client_secret_basic');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('falls back to client_secret_basic when supported is not an array', function () {
|
|
47
|
+
assert.equal(pickAuthMethod('client_secret_post'), 'client_secret_basic');
|
|
48
|
+
assert.equal(pickAuthMethod(null), 'client_secret_basic');
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('with override', function () {
|
|
53
|
+
it('honours an explicit client_secret_basic override', function () {
|
|
54
|
+
assert.equal(
|
|
55
|
+
pickAuthMethod(['client_secret_post'], 'client_secret_basic'),
|
|
56
|
+
'client_secret_basic');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('honours an explicit client_secret_post override', function () {
|
|
60
|
+
assert.equal(
|
|
61
|
+
pickAuthMethod(['client_secret_basic'], 'client_secret_post'),
|
|
62
|
+
'client_secret_post');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('honours override even when supported list is missing', function () {
|
|
66
|
+
assert.equal(
|
|
67
|
+
pickAuthMethod(undefined, 'client_secret_post'),
|
|
68
|
+
'client_secret_post');
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
});
|