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.
- package/.github/workflows/automerge.yml +30 -0
- package/CHANGELOG.md +14 -0
- package/README.md +29 -5
- package/index.js +72 -29
- package/package.json +1 -1
- package/static/tests/backend/oidc-provider.js +13 -0
- package/static/tests/backend/specs/auto_pick_auth_method.js +117 -0
- package/static/tests/backend/specs/pick_auth_method.js +71 -0
- package/static/tests/backend/specs/roles_claim.js +153 -0
|
@@ -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;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
130
|
-
// `
|
|
131
|
-
//
|
|
132
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
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
|
@@ -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
|
+
});
|