ep_openid_connect 3.0.29 → 3.0.31

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/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`
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'}},
@@ -82,6 +92,24 @@ const isHttp = (urlString) => {
82
92
  }
83
93
  };
84
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
+
85
113
  // Pick the token endpoint auth method to use, given the IdP's advertised
86
114
  // `token_endpoint_auth_methods_supported` and any explicit override from
87
115
  // settings. Pure; exported for unit testing.
@@ -116,7 +144,7 @@ const clientAuthFor = (method, secret) => {
116
144
  }
117
145
  };
118
146
 
119
- const fetchServerMetadata = async (issuerUrl, clientId) => {
147
+ const fetchServerMetadata = async (issuerUrl, clientId, customFetch) => {
120
148
  const url = new URL(issuerUrl);
121
149
  // https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery says that the URI
122
150
  // must not have query or fragment components.
@@ -128,16 +156,22 @@ const fetchServerMetadata = async (issuerUrl, clientId) => {
128
156
  }
129
157
  // openid-client@6 rejects http:// issuers by default; opt back in for
130
158
  // localhost / private-network providers (matches v5 behaviour).
131
- const options = url.protocol === 'http:'
132
- ? {execute: [oidc.allowInsecureRequests]}
133
- : 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;
134
165
  try {
135
166
  // Discovery fetches the .well-known/openid-configuration document. The
136
167
  // clientAuthentication argument is irrelevant for that HTTP request — it
137
168
  // only matters when the Configuration is later used to exchange a code
138
169
  // for a token — so we can pass `undefined` here and bind the real method
139
170
  // below once we know what the IdP supports.
140
- 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);
141
175
  return tempConfig.serverMetadata();
142
176
  } catch (err) {
143
177
  // The URL used to get the issuer metadata doesn't exactly follow RFC 8615; see:
@@ -156,13 +190,22 @@ const fetchServerMetadata = async (issuerUrl, clientId) => {
156
190
  };
157
191
 
158
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
+
159
201
  // Resolve the server metadata (either via discovery or from the inline
160
202
  // `issuer_metadata` blob) BEFORE choosing an auth method, so we can pick
161
203
  // one the IdP actually advertises.
162
204
  let serverMetadata;
163
205
  let phaseLog;
164
206
  if (settings.issuer) {
165
- serverMetadata = await fetchServerMetadata(settings.issuer, settings.client_id);
207
+ serverMetadata =
208
+ await fetchServerMetadata(settings.issuer, settings.client_id, customFetch);
166
209
  phaseLog = 'OpenID Connect Discovery complete.';
167
210
  } else {
168
211
  serverMetadata = settings.issuer_metadata;
@@ -177,8 +220,10 @@ const buildConfig = async (settings) => {
177
220
  if (isHttp(serverMetadata && serverMetadata.issuer)) {
178
221
  oidc.allowInsecureRequests(config);
179
222
  }
223
+ if (customFetch != null) config[oidc.customFetch] = customFetch;
180
224
  const source = settings.token_endpoint_auth_method ? 'configured' : 'auto-picked';
181
- 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.' : '.'));
182
227
  return config;
183
228
  };
184
229
 
@@ -378,6 +423,7 @@ exports.preAuthorize = (hookName, {req}) => {
378
423
  exports.exportedForTestingOnly = {
379
424
  callbackUrlFromRequest,
380
425
  defaultSettings,
426
+ loadCaBundle,
381
427
  pickAuthMethod,
382
428
  validSettings,
383
429
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_openid_connect",
3
- "version": "3.0.29",
3
+ "version": "3.0.31",
4
4
  "description": "Etherpad plugin to authenticate users against an OpenID Connect provider.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -24,12 +24,13 @@
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
32
  "eslint-config-etherpad": "^4.0.5",
32
- "oidc-provider": "^9.8.3",
33
+ "oidc-provider": "^9.8.4",
33
34
  "supertest": "^6.3.4",
34
35
  "typescript": "^6.0.3"
35
36
  },
@@ -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
+ });