ep_openid_connect 3.0.28 → 3.0.30

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/README.md CHANGED
@@ -60,6 +60,14 @@ Etherpad's `requireAuthentication` setting must be `true`.
60
60
  * `base_url` (required): The public base Etherpad URL. When registering Etherpad
61
61
  with your identity provider, the redirect URL (a.k.a. callback URL) is this
62
62
  base URL plus `/ep_openid_connect/callback`.
63
+ * `ca` (optional): Custom Certificate Authority bundle for verifying TLS
64
+ connections to the identity provider — useful when the provider's
65
+ certificate is signed by a private or internal CA rather than a public
66
+ one. The value can be either a filesystem path to a PEM file or the PEM
67
+ content itself (recognised by a leading `-----BEGIN`). If you control the
68
+ Node startup environment you may instead set the
69
+ [`NODE_EXTRA_CA_CERTS`](https://nodejs.org/api/cli.html#node_extra_ca_certsfile)
70
+ environment variable, which has the same effect for the whole process.
63
71
  * `scope` (optional; defaults to `["openid"]`): List of OAuth2 scope strings.
64
72
  * `prohibited_usernames` (optional; defaults to `["admin", "guest"]`): List of
65
73
  strings that will trigger an authentication error if any match the `sub`
@@ -111,6 +119,13 @@ describes how the property's value is obtained:
111
119
  * If the descriptor object has a `claim` property that names an existing
112
120
  OpenID Connect claim, the value is set to the value of the claim. (If there
113
121
  is no such claim, `claim` has no effect.)
122
+ * If the descriptor object has a `role` property and the userinfo's `roles`
123
+ claim is an array containing that role string, the value is set to boolean
124
+ `true`. Useful for identity providers such as Azure/Entra ID and Keycloak
125
+ that publish role assignments through a single `roles` array claim rather
126
+ than a dedicated claim per property. Has no effect if the `roles` claim is
127
+ missing or doesn't include the named role. The `claim` rule is evaluated
128
+ first when both are present.
114
129
  * If the descriptor object has a `default` property and the account object
115
130
  property would otherwise be unset, the property is set to the given value.
116
131
  (Note that a property set to `undefined` is not the same as unset.)
@@ -198,6 +213,20 @@ provides suitable claims:
198
213
  },
199
214
  ```
200
215
 
216
+ For identity providers that surface role assignments through a `roles` array
217
+ claim instead (Azure/Entra ID, Keycloak, …), use `role`:
218
+
219
+ ```json
220
+ "ep_openid_connect": {
221
+ "scope": ["openid", "profile", "roles"],
222
+ "user_properties": {
223
+ "is_admin": {"role": "etherpad_admin"},
224
+ "readOnly": {"role": "etherpad_readonly", "default": false},
225
+ "canCreate": {"role": "etherpad_writer", "default": true}
226
+ }
227
+ },
228
+ ```
229
+
201
230
  To avoid breaking assumptions made by Etherpad, the `username` property cannot
202
231
  be altered via the `user_properties` setting.
203
232
 
package/index.js CHANGED
@@ -1,6 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const Ajv = require('ajv/dist/jtd');
4
+ const fs = require('fs');
5
+ const undici = require('undici');
4
6
  const {URL} = require('url');
5
7
 
6
8
  let logger = {};
@@ -29,6 +31,14 @@ const validSettings = new Ajv().compile({
29
31
  client_secret: {type: 'string'},
30
32
  },
31
33
  optionalProperties: {
34
+ // Path to a PEM-encoded certificate authority bundle (or the PEM
35
+ // content itself, recognised by a leading `-----BEGIN`). Used to verify
36
+ // TLS connections to identity providers that present a certificate
37
+ // signed by a private/internal CA. Operators who control the Node
38
+ // process can equivalently set NODE_EXTRA_CA_CERTS — this option is for
39
+ // settings.json-driven deployments where touching the Node startup
40
+ // command isn't an option.
41
+ ca: {type: 'string'},
32
42
  issuer: {type: 'string'},
33
43
  issuer_metadata: {},
34
44
  prohibited_usernames: {elements: {type: 'string'}},
@@ -37,6 +47,11 @@ const validSettings = new Ajv().compile({
37
47
  user_properties: {values: {
38
48
  optionalProperties: {
39
49
  claim: {type: 'string'},
50
+ // `role` looks the named string up inside the `roles` claim's array
51
+ // value. Used by IdPs like Azure/Entra that surface role assignments
52
+ // via a single `roles` array claim instead of a dedicated claim per
53
+ // property. Set the property to `true` when the role is present.
54
+ role: {type: 'string'},
40
55
  // `default` is assigned verbatim to `req.session.user[propName]`,
41
56
  // so any JSON value (boolean for is_admin/readOnly/canCreate,
42
57
  // number, string, …) is fine. Use the JTD empty form so we don't
@@ -77,6 +92,24 @@ const isHttp = (urlString) => {
77
92
  }
78
93
  };
79
94
 
95
+ // Load a CA bundle from either a path or an inline PEM string. Returns the
96
+ // PEM content as a string, or `null` for an empty/missing setting.
97
+ // Exported for unit testing.
98
+ const loadCaBundle = (caSetting) => {
99
+ if (typeof caSetting !== 'string' || !caSetting) return null;
100
+ if (caSetting.startsWith('-----BEGIN ')) return caSetting;
101
+ return fs.readFileSync(caSetting, 'utf8');
102
+ };
103
+
104
+ // Build a `fetch` implementation that trusts the given PEM-encoded CA
105
+ // bundle, suitable for assigning to `config[oidc.customFetch]`. Each call
106
+ // creates a single shared undici dispatcher so HTTP connections to the IdP
107
+ // can be reused across discovery, token-exchange, and userinfo calls.
108
+ const buildCustomFetch = (caBundle) => {
109
+ const dispatcher = new undici.Agent({connect: {ca: caBundle}});
110
+ return (url, options) => undici.fetch(url, {...options, dispatcher});
111
+ };
112
+
80
113
  // Pick the token endpoint auth method to use, given the IdP's advertised
81
114
  // `token_endpoint_auth_methods_supported` and any explicit override from
82
115
  // settings. Pure; exported for unit testing.
@@ -111,7 +144,7 @@ const clientAuthFor = (method, secret) => {
111
144
  }
112
145
  };
113
146
 
114
- const fetchServerMetadata = async (issuerUrl, clientId) => {
147
+ const fetchServerMetadata = async (issuerUrl, clientId, customFetch) => {
115
148
  const url = new URL(issuerUrl);
116
149
  // https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery says that the URI
117
150
  // must not have query or fragment components.
@@ -123,16 +156,22 @@ const fetchServerMetadata = async (issuerUrl, clientId) => {
123
156
  }
124
157
  // openid-client@6 rejects http:// issuers by default; opt back in for
125
158
  // localhost / private-network providers (matches v5 behaviour).
126
- const options = url.protocol === 'http:'
127
- ? {execute: [oidc.allowInsecureRequests]}
128
- : undefined;
159
+ const options = {};
160
+ if (url.protocol === 'http:') options.execute = [oidc.allowInsecureRequests];
161
+ // `customFetch` must be set on the options bag (not on `execute`) so that
162
+ // discovery's OWN HTTPS request to /.well-known/openid-configuration uses
163
+ // it. `execute` callbacks only run AFTER discovery finishes.
164
+ if (customFetch != null) options[oidc.customFetch] = customFetch;
129
165
  try {
130
166
  // Discovery fetches the .well-known/openid-configuration document. The
131
167
  // clientAuthentication argument is irrelevant for that HTTP request — it
132
168
  // only matters when the Configuration is later used to exchange a code
133
169
  // for a token — so we can pass `undefined` here and bind the real method
134
170
  // below once we know what the IdP supports.
135
- const tempConfig = await oidc.discovery(url, clientId, undefined, undefined, options);
171
+ const tempConfig = await oidc.discovery(
172
+ url, clientId, undefined, undefined,
173
+ Object.keys(options).length || Object.getOwnPropertySymbols(options).length
174
+ ? options : undefined);
136
175
  return tempConfig.serverMetadata();
137
176
  } catch (err) {
138
177
  // The URL used to get the issuer metadata doesn't exactly follow RFC 8615; see:
@@ -151,13 +190,22 @@ const fetchServerMetadata = async (issuerUrl, clientId) => {
151
190
  };
152
191
 
153
192
  const buildConfig = async (settings) => {
193
+ // If the operator gave us a custom CA bundle, build a fetch that trusts
194
+ // it; otherwise leave the default fetch in place. We deliberately build
195
+ // the customFetch BEFORE discovery so the discovery HTTP call itself
196
+ // uses the trusted CA — the configured IdP is typically the same host
197
+ // that serves the well-known document.
198
+ const caBundle = loadCaBundle(settings.ca);
199
+ const customFetch = caBundle ? buildCustomFetch(caBundle) : null;
200
+
154
201
  // Resolve the server metadata (either via discovery or from the inline
155
202
  // `issuer_metadata` blob) BEFORE choosing an auth method, so we can pick
156
203
  // one the IdP actually advertises.
157
204
  let serverMetadata;
158
205
  let phaseLog;
159
206
  if (settings.issuer) {
160
- serverMetadata = await fetchServerMetadata(settings.issuer, settings.client_id);
207
+ serverMetadata =
208
+ await fetchServerMetadata(settings.issuer, settings.client_id, customFetch);
161
209
  phaseLog = 'OpenID Connect Discovery complete.';
162
210
  } else {
163
211
  serverMetadata = settings.issuer_metadata;
@@ -172,8 +220,10 @@ const buildConfig = async (settings) => {
172
220
  if (isHttp(serverMetadata && serverMetadata.issuer)) {
173
221
  oidc.allowInsecureRequests(config);
174
222
  }
223
+ if (customFetch != null) config[oidc.customFetch] = customFetch;
175
224
  const source = settings.token_endpoint_auth_method ? 'configured' : 'auto-picked';
176
- logger.info(`${phaseLog} Token endpoint auth method: ${method} (${source}).`);
225
+ logger.info(`${phaseLog} Token endpoint auth method: ${method} (${source})` +
226
+ (caBundle ? ' with custom CA bundle.' : '.'));
177
227
  return config;
178
228
  };
179
229
 
@@ -311,6 +361,11 @@ exports.authenticate = (hookName, {req, res, users}) => {
311
361
  delete req.session.user[propName];
312
362
  } else if (descriptor.claim != null && descriptor.claim in userinfo) {
313
363
  req.session.user[propName] = userinfo[descriptor.claim];
364
+ } else if (descriptor.role != null && Array.isArray(userinfo.roles) &&
365
+ userinfo.roles.includes(descriptor.role)) {
366
+ // Boolean `true` (not the string `"true"`) so that the value works
367
+ // directly with Etherpad's is_admin/readOnly/canCreate checks.
368
+ req.session.user[propName] = true;
314
369
  } else if ('default' in descriptor && !(propName in req.session.user)) {
315
370
  req.session.user[propName] = descriptor.default;
316
371
  }
@@ -368,6 +423,7 @@ exports.preAuthorize = (hookName, {req}) => {
368
423
  exports.exportedForTestingOnly = {
369
424
  callbackUrlFromRequest,
370
425
  defaultSettings,
426
+ loadCaBundle,
371
427
  pickAuthMethod,
372
428
  validSettings,
373
429
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_openid_connect",
3
- "version": "3.0.28",
3
+ "version": "3.0.30",
4
4
  "description": "Etherpad plugin to authenticate users against an OpenID Connect provider.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -24,7 +24,8 @@
24
24
  "homepage": "https://github.com/ether/ep_openid_connect#readme",
25
25
  "dependencies": {
26
26
  "ajv": "^8.20.0",
27
- "openid-client": "^6.8.4"
27
+ "openid-client": "^6.8.4",
28
+ "undici": "^8.3.0"
28
29
  },
29
30
  "devDependencies": {
30
31
  "eslint": "^8.57.1",
@@ -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,243 @@
1
+ 'use strict';
2
+
3
+ // End-to-end coverage for the `ca` setting: stand up an HTTPS-fronted OIDC
4
+ // provider with a self-signed certificate, point the plugin at it, and
5
+ // confirm a real login completes when the matching CA is configured (and
6
+ // fails when it isn't). This is the only sound way to prove that the
7
+ // custom-fetch wiring actually replaces Node's default fetch — a unit test
8
+ // that just checks `config[customFetch]` is set would miss the case where
9
+ // undici and openid-client disagree on the option shape.
10
+
11
+ const Provider = require('oidc-provider').default;
12
+ const MemoryAdapter = require('oidc-provider/lib/adapters/memory_adapter').default;
13
+ const assert = require('assert').strict;
14
+ const child_process = require('child_process');
15
+ const common = require('ep_etherpad-lite/tests/backend/common');
16
+ const epOpenidConnect = require('../../../../index');
17
+ const fs = require('fs');
18
+ const https = require('https');
19
+ const os = require('os');
20
+ const path = require('path');
21
+ const settings = require('ep_etherpad-lite/node/utils/Settings');
22
+ const util = require('util');
23
+
24
+ // Generate a self-signed cert valid for localhost via the OpenSSL CLI.
25
+ // Using a CLI avoids pulling in `selfsigned`+`@peculiar/x509`+`node-forge`
26
+ // as devDependencies just for one test, and `openssl` is available on every
27
+ // CI runner and dev machine.
28
+ const generateSelfSignedCert = () => {
29
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ep-openid-cert-'));
30
+ const keyPath = path.join(dir, 'key.pem');
31
+ const certPath = path.join(dir, 'cert.pem');
32
+ child_process.execFileSync('openssl', [
33
+ 'req', '-x509',
34
+ '-newkey', 'rsa:2048',
35
+ '-nodes',
36
+ '-keyout', keyPath,
37
+ '-out', certPath,
38
+ '-days', '1',
39
+ '-subj', '/CN=localhost',
40
+ '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1',
41
+ ], {stdio: 'pipe'});
42
+ return {
43
+ dir,
44
+ key: fs.readFileSync(keyPath, 'utf8'),
45
+ cert: fs.readFileSync(certPath, 'utf8'),
46
+ keyPath,
47
+ certPath,
48
+ };
49
+ };
50
+
51
+ const httpsListen = util.promisify(https.Server.prototype.listen);
52
+ const httpsClose = util.promisify(https.Server.prototype.close);
53
+
54
+ // No-op subclass mirrors the helper in ../oidc-provider.js — silences the
55
+ // MemoryAdapter "not for production" warning.
56
+ class Adapter extends MemoryAdapter {}
57
+
58
+ // Minimal HTTPS-fronted OIDC provider for the CA-trust test. Mirrors the
59
+ // happy path of static/tests/backend/oidc-provider.js but listens on TLS.
60
+ class HttpsOidcProvider {
61
+ async start({clients, tls}) {
62
+ this._server = https.createServer(tls);
63
+ await httpsListen.call(this._server, 0, 'localhost');
64
+ this.issuer = `https://localhost:${this._server.address().port}/`;
65
+ this._provider = new Provider(this.issuer, {
66
+ adapter: Adapter,
67
+ claims: {etherpad: ['name']},
68
+ clients,
69
+ cookies: {keys: [common.randomString()]},
70
+ features: {devInteractions: {enabled: false}},
71
+ findAccount: async (ctx, sub) => ({
72
+ accountId: sub,
73
+ claims: async () => ({sub, name: 'Firstname Lastname'}),
74
+ }),
75
+ jwks: {keys: [{
76
+ kty: 'EC',
77
+ crv: 'P-256',
78
+ d: 'K9xfPv773dZR22TVUB80xouzdF7qCg5cWjPjkHyv7Ws',
79
+ x: 'FWZ9rSkLt6Dx9E3pxLybhdM6xgR5obGsj5_pqmnz5J4',
80
+ y: '_n8G69C-A2Xl4xUW2lF0i8ZGZnk_KPYrhv4GbTGu5G4',
81
+ use: 'sig',
82
+ }]},
83
+ ttl: {AccessToken: 3600, Grant: 3600, IdToken: 3600, Interaction: 3600, Session: 3600},
84
+ });
85
+ this._server.on('request', this._provider.callback());
86
+ // Same shortcut as the HTTP helper: skip past the login + consent UI by
87
+ // accepting the username in a query parameter.
88
+ this._provider.app.use(async (ctx, next) => {
89
+ const {request: {method, path: p, query}} = ctx;
90
+ if (!p.startsWith('/interaction/') || method !== 'GET') return await next();
91
+ const details = await this._provider.interactionDetails(ctx.req, ctx.res);
92
+ if (Object.entries(query).length === 0) {
93
+ ctx.response.body = `waiting for ${details.prompt.name}`;
94
+ ctx.response.status = 200;
95
+ return;
96
+ }
97
+ switch (details.prompt.name) {
98
+ case 'login':
99
+ return await this._provider.interactionFinished(ctx.req, ctx.res,
100
+ {login: {accountId: query.login, remember: false}});
101
+ case 'consent': {
102
+ const grant = details.grantId
103
+ ? await this._provider.Grant.find(details.grantId)
104
+ : new this._provider.Grant({
105
+ accountId: details.session.accountId,
106
+ clientId: details.params.client_id,
107
+ });
108
+ const {prompt: {details: d}} = details;
109
+ if (d.missingOIDCScope) grant.addOIDCScope(d.missingOIDCScope.join(' '));
110
+ if (d.missingOIDCClaims) grant.addOIDCClaims(d.missingOIDCClaims);
111
+ if (d.missingResourceScopes) {
112
+ for (const [r, s] of Object.entries(d.missingResourceScopes)) {
113
+ grant.addResourceScope(r, s.join(' '));
114
+ }
115
+ }
116
+ const grantId = await grant.save();
117
+ return await this._provider.interactionFinished(ctx.req, ctx.res,
118
+ {consent: details.grantId ? {} : {grantId}});
119
+ }
120
+ }
121
+ return await next();
122
+ });
123
+ }
124
+
125
+ async stop() {
126
+ if (this._server == null) return;
127
+ await httpsClose.call(this._server);
128
+ this._server = null;
129
+ }
130
+ }
131
+
132
+ describe(__filename, function () {
133
+ let provider;
134
+ let pems; // {key, cert, certPath} from generateSelfSignedCert()
135
+ const backup = {};
136
+ const client = {client_id: 'the_client_id', client_secret: 'the_client_secret'};
137
+
138
+ before(async function () {
139
+ await common.init();
140
+ backup.settings = {
141
+ requireAuthentication: settings.requireAuthentication,
142
+ requireAuthorization: settings.requireAuthorization,
143
+ users: settings.users,
144
+ };
145
+ // Self-signed cert valid for localhost. The same PEM is used as both
146
+ // the server cert AND the CA bundle the plugin trusts.
147
+ pems = generateSelfSignedCert();
148
+
149
+ provider = new HttpsOidcProvider();
150
+ await provider.start({
151
+ tls: {key: pems.key, cert: pems.cert},
152
+ clients: [{
153
+ ...client,
154
+ redirect_uris: [new URL('/ep_openid_connect/callback', common.baseUrl).href],
155
+ }],
156
+ });
157
+ });
158
+
159
+ beforeEach(function () {
160
+ settings.requireAuthentication = true;
161
+ settings.requireAuthorization = false;
162
+ settings.users = {};
163
+ });
164
+
165
+ afterEach(function () {
166
+ settings.requireAuthentication = backup.settings.requireAuthentication;
167
+ settings.requireAuthorization = backup.settings.requireAuthorization;
168
+ settings.users = backup.settings.users;
169
+ });
170
+
171
+ after(async function () {
172
+ if (provider != null) await provider.stop();
173
+ provider = null;
174
+ if (pems && pems.dir) try { fs.rmSync(pems.dir, {recursive: true, force: true}); } catch (_) { /* ignore */ }
175
+ });
176
+
177
+ describe('loadCaBundle', function () {
178
+ const {loadCaBundle} = epOpenidConnect.exportedForTestingOnly;
179
+
180
+ it('returns null for an empty / missing value', function () {
181
+ assert.equal(loadCaBundle(undefined), null);
182
+ assert.equal(loadCaBundle(null), null);
183
+ assert.equal(loadCaBundle(''), null);
184
+ });
185
+
186
+ it('returns inline PEM content unchanged', function () {
187
+ assert.equal(loadCaBundle(pems.cert), pems.cert);
188
+ });
189
+
190
+ it('reads PEM content from a path', function () {
191
+ assert.equal(loadCaBundle(pems.certPath), pems.cert);
192
+ });
193
+
194
+ it('throws on a non-existent path', function () {
195
+ assert.throws(() => loadCaBundle('/this/path/does/not/exist.pem'),
196
+ /ENOENT|no such file/);
197
+ });
198
+ });
199
+
200
+ describe('discovery against a self-signed HTTPS provider', function () {
201
+ const baseSettings = () => ({
202
+ ...client,
203
+ base_url: common.baseUrl,
204
+ issuer: provider.issuer,
205
+ scope: ['openid', 'etherpad'],
206
+ });
207
+
208
+ it('fails without the ca setting', async function () {
209
+ // Discovery has to hit https://localhost:PORT/.well-known/... and
210
+ // there's no CA on the system trust store that signed our cert. The
211
+ // outer error from openid-client is the generic `TypeError: fetch
212
+ // failed`; the underlying TLS reason lives in err.cause.
213
+ await assert.rejects(
214
+ epOpenidConnect.loadSettings('loadSettings', {settings: {ep_openid_connect: {
215
+ ...baseSettings(),
216
+ }}}),
217
+ (err) => {
218
+ const messages = [];
219
+ for (let e = err; e != null; e = e.cause) {
220
+ if (e.message) messages.push(String(e.message));
221
+ if (e.code) messages.push(String(e.code));
222
+ }
223
+ const joined = messages.join(' | ');
224
+ return /self-signed|self signed|unable to verify|UNABLE_TO_VERIFY_LEAF_SIGNATURE|CERT|TLS/i.test(joined);
225
+ });
226
+ });
227
+
228
+ it('succeeds when ca points to a file', async function () {
229
+ await epOpenidConnect.loadSettings('loadSettings', {settings: {ep_openid_connect: {
230
+ ...baseSettings(),
231
+ ca: pems.certPath,
232
+ }}});
233
+ // No throw means discovery completed against the self-signed IdP.
234
+ });
235
+
236
+ it('succeeds when ca contains inline PEM content', async function () {
237
+ await epOpenidConnect.loadSettings('loadSettings', {settings: {ep_openid_connect: {
238
+ ...baseSettings(),
239
+ ca: pems.cert,
240
+ }}});
241
+ });
242
+ });
243
+ });
@@ -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
+ });