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 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; defaults to `client_secret_basic`):
53
- Client authentication method to use for token endpoint requests. Supported
54
- values are `client_secret_basic` and `client_secret_post`. Use
55
- `client_secret_post` if your identity provider expects the client secret in
56
- the token request body instead of the HTTP `Authorization` header.
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
- const discoverConfig = async (issuerUrl, clientId, clientAuth) => {
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
- return await oidc.discovery(url, clientId, undefined, clientAuth, options);
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
- // openid-client@5 defaulted the token endpoint auth method to
130
- // `client_secret_basic`; v6 defaults to `client_secret_post`. Pin the
131
- // default method explicitly so existing IdP registrations keep working.
132
- const clientAuth = getClientAuth(settings);
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
- const config =
135
- await discoverConfig(settings.issuer, settings.client_id, clientAuth);
136
- logger.info('OpenID Connect Discovery complete.');
137
- return config;
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 config =
140
- new oidc.Configuration(settings.issuer_metadata, settings.client_id, undefined, clientAuth);
141
- if (isHttp(settings.issuer_metadata && settings.issuer_metadata.issuer)) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_openid_connect",
3
- "version": "3.0.27",
3
+ "version": "3.0.28",
4
4
  "description": "Etherpad plugin to authenticate users against an OpenID Connect provider.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -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
+ });