ep_openid_connect 3.0.27 → 3.0.29

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.
@@ -0,0 +1,30 @@
1
+ name: Dependabot Automerge
2
+ permissions:
3
+ contents: write
4
+ pull-requests: write
5
+ on:
6
+ workflow_run:
7
+ workflows:
8
+ - Node.js Package
9
+ types:
10
+ - completed
11
+
12
+ jobs:
13
+ automerge:
14
+ if: >
15
+ github.event.workflow_run.conclusion == 'success' &&
16
+ github.event.workflow_run.event == 'pull_request' &&
17
+ github.event.workflow_run.actor.login == 'dependabot[bot]'
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - name: Checkout
21
+ uses: actions/checkout@v6
22
+
23
+ - name: Automerge
24
+ uses: "pascalgn/automerge-action@v0.16.4"
25
+ env:
26
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27
+ MERGE_METHOD: squash
28
+ MERGE_LABELS: ""
29
+ MERGE_RETRY_SLEEP: "100000"
30
+
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`.
@@ -108,6 +111,13 @@ describes how the property's value is obtained:
108
111
  * If the descriptor object has a `claim` property that names an existing
109
112
  OpenID Connect claim, the value is set to the value of the claim. (If there
110
113
  is no such claim, `claim` has no effect.)
114
+ * If the descriptor object has a `role` property and the userinfo's `roles`
115
+ claim is an array containing that role string, the value is set to boolean
116
+ `true`. Useful for identity providers such as Azure/Entra ID and Keycloak
117
+ that publish role assignments through a single `roles` array claim rather
118
+ than a dedicated claim per property. Has no effect if the `roles` claim is
119
+ missing or doesn't include the named role. The `claim` rule is evaluated
120
+ first when both are present.
111
121
  * If the descriptor object has a `default` property and the account object
112
122
  property would otherwise be unset, the property is set to the given value.
113
123
  (Note that a property set to `undefined` is not the same as unset.)
@@ -195,6 +205,20 @@ provides suitable claims:
195
205
  },
196
206
  ```
197
207
 
208
+ For identity providers that surface role assignments through a `roles` array
209
+ claim instead (Azure/Entra ID, Keycloak, …), use `role`:
210
+
211
+ ```json
212
+ "ep_openid_connect": {
213
+ "scope": ["openid", "profile", "roles"],
214
+ "user_properties": {
215
+ "is_admin": {"role": "etherpad_admin"},
216
+ "readOnly": {"role": "etherpad_readonly", "default": false},
217
+ "canCreate": {"role": "etherpad_writer", "default": true}
218
+ }
219
+ },
220
+ ```
221
+
198
222
  To avoid breaking assumptions made by Etherpad, the `username` property cannot
199
223
  be altered via the `user_properties` setting.
200
224
 
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;
@@ -38,6 +37,11 @@ const validSettings = new Ajv().compile({
38
37
  user_properties: {values: {
39
38
  optionalProperties: {
40
39
  claim: {type: 'string'},
40
+ // `role` looks the named string up inside the `roles` claim's array
41
+ // value. Used by IdPs like Azure/Entra that surface role assignments
42
+ // via a single `roles` array claim instead of a dedicated claim per
43
+ // property. Set the property to `true` when the role is present.
44
+ role: {type: 'string'},
41
45
  // `default` is assigned verbatim to `req.session.user[propName]`,
42
46
  // so any JSON value (boolean for is_admin/readOnly/canCreate,
43
47
  // number, string, …) is fine. Use the JTD empty form so we don't
@@ -78,7 +82,41 @@ const isHttp = (urlString) => {
78
82
  }
79
83
  };
80
84
 
81
- const discoverConfig = async (issuerUrl, clientId, clientAuth) => {
85
+ // Pick the token endpoint auth method to use, given the IdP's advertised
86
+ // `token_endpoint_auth_methods_supported` and any explicit override from
87
+ // settings. Pure; exported for unit testing.
88
+ //
89
+ // Preference order when no override is set:
90
+ // 1. `client_secret_post` if the IdP advertises it (matches openid-client@6's
91
+ // own default and works with every public IdP we've tested — notably
92
+ // GitLab.com, which rejects `client_secret_basic` at the token endpoint
93
+ // even though it lists it in discovery).
94
+ // 2. `client_secret_basic` if the IdP advertises only that.
95
+ // 3. `client_secret_basic` if the IdP advertises nothing we recognise (RFC
96
+ // 8414 §2 says the absence of the field defaults to `client_secret_basic`).
97
+ const pickAuthMethod = (supported, override) => {
98
+ if (override) return override;
99
+ if (Array.isArray(supported)) {
100
+ if (supported.includes('client_secret_post')) return 'client_secret_post';
101
+ if (supported.includes('client_secret_basic')) return 'client_secret_basic';
102
+ }
103
+ return 'client_secret_basic';
104
+ };
105
+
106
+ const clientAuthFor = (method, secret) => {
107
+ switch (method) {
108
+ case 'client_secret_basic':
109
+ // eslint-disable-next-line new-cap
110
+ return oidc.ClientSecretBasic(secret);
111
+ case 'client_secret_post':
112
+ // eslint-disable-next-line new-cap
113
+ return oidc.ClientSecretPost(secret);
114
+ default:
115
+ throw new Error(`Unsupported token endpoint auth method: ${method}`);
116
+ }
117
+ };
118
+
119
+ const fetchServerMetadata = async (issuerUrl, clientId) => {
82
120
  const url = new URL(issuerUrl);
83
121
  // https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery says that the URI
84
122
  // must not have query or fragment components.
@@ -94,7 +132,13 @@ const discoverConfig = async (issuerUrl, clientId, clientAuth) => {
94
132
  ? {execute: [oidc.allowInsecureRequests]}
95
133
  : undefined;
96
134
  try {
97
- return await oidc.discovery(url, clientId, undefined, clientAuth, options);
135
+ // Discovery fetches the .well-known/openid-configuration document. The
136
+ // clientAuthentication argument is irrelevant for that HTTP request — it
137
+ // only matters when the Configuration is later used to exchange a code
138
+ // for a token — so we can pass `undefined` here and bind the real method
139
+ // below once we know what the IdP supports.
140
+ const tempConfig = await oidc.discovery(url, clientId, undefined, undefined, options);
141
+ return tempConfig.serverMetadata();
98
142
  } catch (err) {
99
143
  // The URL used to get the issuer metadata doesn't exactly follow RFC 8615; see:
100
144
  // https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
@@ -111,36 +155,30 @@ const discoverConfig = async (issuerUrl, clientId, clientAuth) => {
111
155
  }
112
156
  };
113
157
 
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
158
  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);
159
+ // Resolve the server metadata (either via discovery or from the inline
160
+ // `issuer_metadata` blob) BEFORE choosing an auth method, so we can pick
161
+ // one the IdP actually advertises.
162
+ let serverMetadata;
163
+ let phaseLog;
133
164
  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;
165
+ serverMetadata = await fetchServerMetadata(settings.issuer, settings.client_id);
166
+ phaseLog = 'OpenID Connect Discovery complete.';
167
+ } else {
168
+ serverMetadata = settings.issuer_metadata;
169
+ phaseLog = 'Configured from issuer_metadata.';
138
170
  }
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)) {
171
+ const method = pickAuthMethod(
172
+ serverMetadata && serverMetadata.token_endpoint_auth_methods_supported,
173
+ settings.token_endpoint_auth_method);
174
+ const clientAuth = clientAuthFor(method, settings.client_secret);
175
+ const config = new oidc.Configuration(
176
+ serverMetadata, settings.client_id, undefined, clientAuth);
177
+ if (isHttp(serverMetadata && serverMetadata.issuer)) {
142
178
  oidc.allowInsecureRequests(config);
143
179
  }
180
+ const source = settings.token_endpoint_auth_method ? 'configured' : 'auto-picked';
181
+ logger.info(`${phaseLog} Token endpoint auth method: ${method} (${source}).`);
144
182
  return config;
145
183
  };
146
184
 
@@ -179,7 +217,6 @@ exports.loadSettings = async (hookName, {settings: {ep_openid_connect: s = {}}})
179
217
  if (!settings.base_url.endsWith('/')) settings.base_url += '/';
180
218
  logger.debug('Settings:', {...settings, client_secret: '********'});
181
219
  oidcConfig = await buildConfig(settings);
182
- logger.info('Configured.');
183
220
  };
184
221
 
185
222
  exports.expressCreateServer = (hookName, {app}) => {
@@ -279,6 +316,11 @@ exports.authenticate = (hookName, {req, res, users}) => {
279
316
  delete req.session.user[propName];
280
317
  } else if (descriptor.claim != null && descriptor.claim in userinfo) {
281
318
  req.session.user[propName] = userinfo[descriptor.claim];
319
+ } else if (descriptor.role != null && Array.isArray(userinfo.roles) &&
320
+ userinfo.roles.includes(descriptor.role)) {
321
+ // Boolean `true` (not the string `"true"`) so that the value works
322
+ // directly with Etherpad's is_admin/readOnly/canCreate checks.
323
+ req.session.user[propName] = true;
282
324
  } else if ('default' in descriptor && !(propName in req.session.user)) {
283
325
  req.session.user[propName] = descriptor.default;
284
326
  }
@@ -336,5 +378,6 @@ exports.preAuthorize = (hookName, {req}) => {
336
378
  exports.exportedForTestingOnly = {
337
379
  callbackUrlFromRequest,
338
380
  defaultSettings,
381
+ pickAuthMethod,
339
382
  validSettings,
340
383
  };
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.29",
4
4
  "description": "Etherpad plugin to authenticate users against an OpenID Connect provider.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -31,6 +31,12 @@ class OidcProvider {
31
31
  'etherpad_canCreate',
32
32
  'name',
33
33
  'prop',
34
+ // `roles` is an array claim — Azure/Entra and Keycloak surface
35
+ // role assignments this way. The test account synthesises a
36
+ // `roles` array when the sub contains "role" (see findAccount
37
+ // below) so we can drive user_properties.X.role tests without
38
+ // disturbing the other test users' claim shape.
39
+ 'roles',
34
40
  ],
35
41
  },
36
42
  cookies: {
@@ -49,6 +55,13 @@ class OidcProvider {
49
55
  etherpad_canCreate:
50
56
  sub.includes('admin') || (!sub.includes('readOnly') && !sub.includes('noCreate')),
51
57
  ...(sub === 'claimNull' ? {prop: null} : sub === 'claimVal' ? {prop: 'claimValue'} : {}),
58
+ // Synthesise a `roles` claim only for usernames that explicitly opt
59
+ // in via the "role" substring, so the other user_properties tests
60
+ // are unaffected. `emptyRoles` gets an empty array to exercise the
61
+ // "no matching role" path.
62
+ ...(sub.includes('emptyRoles') ? {roles: []}
63
+ : sub.includes('role') ? {roles: ['etherpad_admin', 'unrelated_role']}
64
+ : {}),
52
65
  }),
53
66
  }),
54
67
  jwks: {
@@ -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
+ });
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ // Coverage for `user_properties.<name>.role`: when the named role string is
4
+ // present in the userinfo's `roles` array, the property is set to boolean
5
+ // `true`. Useful for IdPs (Azure/Entra ID, Keycloak) that publish role
6
+ // assignments through a single `roles` array claim rather than a dedicated
7
+ // claim per property.
8
+
9
+ const OidcProvider = require('../oidc-provider');
10
+ const assert = require('assert').strict;
11
+ const common = require('ep_etherpad-lite/tests/backend/common');
12
+ const epOpenidConnect = require('../../../../index');
13
+ const login = require('../login');
14
+ const plugins = require('ep_etherpad-lite/static/js/pluginfw/plugin_defs');
15
+ const settings = require('ep_etherpad-lite/node/utils/Settings');
16
+ const supertest = require('supertest');
17
+
18
+ describe(__filename, function () {
19
+ let agent;
20
+ const backup = {};
21
+ let provider;
22
+ let issuer;
23
+ const client = {
24
+ client_id: 'the_client_id',
25
+ client_secret: 'the_client_secret',
26
+ };
27
+ const basePluginSettings = {
28
+ ...client,
29
+ scope: ['openid', 'etherpad'],
30
+ };
31
+ let socket;
32
+
33
+ before(async function () {
34
+ await common.init();
35
+ if (!plugins.hooks.clientVars) plugins.hooks.clientVars = [];
36
+ backup.hooks = {clientVars: plugins.hooks.clientVars};
37
+ backup.settings = {
38
+ requireAuthentication: settings.requireAuthentication,
39
+ requireAuthorization: settings.requireAuthorization,
40
+ users: settings.users,
41
+ };
42
+ provider = new OidcProvider();
43
+ const clients =
44
+ [{...client, redirect_uris: [new URL('/ep_openid_connect/callback', common.baseUrl).href]}];
45
+ await provider.start({clients});
46
+ });
47
+
48
+ beforeEach(async function () {
49
+ agent = supertest.agent('');
50
+ issuer = provider.issuer;
51
+ settings.requireAuthentication = true;
52
+ settings.requireAuthorization = false;
53
+ settings.users = {};
54
+ });
55
+
56
+ afterEach(async function () {
57
+ if (socket != null) socket.close();
58
+ socket = null;
59
+ Object.assign(plugins.hooks, backup.hooks);
60
+ settings.requireAuthentication = backup.settings.requireAuthentication;
61
+ settings.requireAuthorization = backup.settings.requireAuthorization;
62
+ settings.users = backup.settings.users;
63
+ });
64
+
65
+ after(async function () {
66
+ if (provider != null) await provider.stop();
67
+ provider = null;
68
+ });
69
+
70
+ const loadWithUserProperties = async (user_properties) => {
71
+ await epOpenidConnect.loadSettings('loadSettings', {settings: {ep_openid_connect: {
72
+ ...basePluginSettings,
73
+ base_url: common.baseUrl,
74
+ issuer,
75
+ user_properties,
76
+ }}});
77
+ };
78
+
79
+ // Login that captures the post-handshake session.user and returns it.
80
+ const loginAndGetUser = async (username) => {
81
+ const padId = common.randomString();
82
+ const url = new URL(`/p/${padId}`, common.baseUrl).toString();
83
+ const res = await login(agent, issuer, url, username);
84
+ assert.equal(res.request.url, url);
85
+ assert.equal(res.status, 200);
86
+ socket = await common.connect(res);
87
+ const userP = new Promise((resolve) => {
88
+ plugins.hooks.clientVars =
89
+ [{hook_fn: (hn, ctx) => resolve(ctx.socket.client.request.session.user)}];
90
+ });
91
+ const msg = await common.handshake(socket, padId);
92
+ assert.equal(msg.type, 'CLIENT_VARS', `not a CLIENT_VARS message: ${JSON.stringify(msg)}`);
93
+ return userP;
94
+ };
95
+
96
+ it('sets the property to boolean true when the role is present', async function () {
97
+ await loadWithUserProperties({is_admin: {role: 'etherpad_admin'}});
98
+ const user = await loginAndGetUser('roleAdmin');
99
+ assert.equal(user.is_admin, true, 'is_admin must be the boolean true, not a string');
100
+ });
101
+
102
+ it('leaves the property unset when the role is not in the roles array', async function () {
103
+ await loadWithUserProperties({is_admin: {role: 'missing_role'}});
104
+ const user = await loginAndGetUser('roleAdmin'); // has roles, just not this one
105
+ assert(!('is_admin' in user),
106
+ `is_admin should not be set when role is absent; got ${JSON.stringify(user.is_admin)}`);
107
+ });
108
+
109
+ it('leaves the property unset when roles array is empty', async function () {
110
+ await loadWithUserProperties({is_admin: {role: 'etherpad_admin'}});
111
+ const user = await loginAndGetUser('emptyRolesUser');
112
+ assert(!('is_admin' in user));
113
+ });
114
+
115
+ it('does not crash when the roles claim is entirely missing', async function () {
116
+ // `normalUser` doesn't have "role" in its sub, so the test account
117
+ // synthesises no `roles` claim at all. Pre-#111 code would have thrown
118
+ // `Cannot read properties of undefined (reading 'indexOf')`.
119
+ await loadWithUserProperties({is_admin: {role: 'etherpad_admin'}});
120
+ const user = await loginAndGetUser('normalUser');
121
+ assert(!('is_admin' in user));
122
+ });
123
+
124
+ it('falls through to default when role is absent', async function () {
125
+ await loadWithUserProperties({
126
+ is_admin: {role: 'missing_role', default: false},
127
+ });
128
+ const user = await loginAndGetUser('roleAdmin');
129
+ assert.equal(user.is_admin, false);
130
+ });
131
+
132
+ it('uses claim value in preference to role match', async function () {
133
+ // Both `claim` and `role` set: the explicit claim wins. The test account
134
+ // for `adminroleUser` emits `etherpad_is_admin: true` (sub contains
135
+ // "admin") AND `roles: ['etherpad_admin', ...]`. With a `claim`
136
+ // descriptor the role branch must not overwrite the claim's value.
137
+ await loadWithUserProperties({
138
+ is_admin: {claim: 'etherpad_is_admin', role: 'should_not_match'},
139
+ });
140
+ const user = await loginAndGetUser('adminroleUser');
141
+ assert.equal(user.is_admin, true); // came from claim, not role fallback
142
+ });
143
+
144
+ it('role can grant a property whose claim is absent', async function () {
145
+ // `claim` is checked first but only fires when the claim key is *in*
146
+ // userinfo. If it isn't, the role branch should run.
147
+ await loadWithUserProperties({
148
+ is_admin: {claim: 'definitely_not_a_claim', role: 'etherpad_admin'},
149
+ });
150
+ const user = await loginAndGetUser('roleUser');
151
+ assert.equal(user.is_admin, true);
152
+ });
153
+ });