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 =
|
|
127
|
-
|
|
128
|
-
|
|
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(
|
|
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 =
|
|
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.
|
|
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
|
+
});
|