ep_openid_connect 3.0.28 → 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/README.md +21 -0
- package/index.js +10 -0
- package/package.json +1 -1
- package/static/tests/backend/oidc-provider.js +13 -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/README.md
CHANGED
|
@@ -111,6 +111,13 @@ describes how the property's value is obtained:
|
|
|
111
111
|
* If the descriptor object has a `claim` property that names an existing
|
|
112
112
|
OpenID Connect claim, the value is set to the value of the claim. (If there
|
|
113
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.
|
|
114
121
|
* If the descriptor object has a `default` property and the account object
|
|
115
122
|
property would otherwise be unset, the property is set to the given value.
|
|
116
123
|
(Note that a property set to `undefined` is not the same as unset.)
|
|
@@ -198,6 +205,20 @@ provides suitable claims:
|
|
|
198
205
|
},
|
|
199
206
|
```
|
|
200
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
|
+
|
|
201
222
|
To avoid breaking assumptions made by Etherpad, the `username` property cannot
|
|
202
223
|
be altered via the `user_properties` setting.
|
|
203
224
|
|
package/index.js
CHANGED
|
@@ -37,6 +37,11 @@ const validSettings = new Ajv().compile({
|
|
|
37
37
|
user_properties: {values: {
|
|
38
38
|
optionalProperties: {
|
|
39
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'},
|
|
40
45
|
// `default` is assigned verbatim to `req.session.user[propName]`,
|
|
41
46
|
// so any JSON value (boolean for is_admin/readOnly/canCreate,
|
|
42
47
|
// number, string, …) is fine. Use the JTD empty form so we don't
|
|
@@ -311,6 +316,11 @@ exports.authenticate = (hookName, {req, res, users}) => {
|
|
|
311
316
|
delete req.session.user[propName];
|
|
312
317
|
} else if (descriptor.claim != null && descriptor.claim in userinfo) {
|
|
313
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;
|
|
314
324
|
} else if ('default' in descriptor && !(propName in req.session.user)) {
|
|
315
325
|
req.session.user[propName] = descriptor.default;
|
|
316
326
|
}
|
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,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
|
+
});
|