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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ep_openid_connect",
3
- "version": "3.0.28",
3
+ "version": "3.0.29",
4
4
  "description": "Etherpad plugin to authenticate users against an OpenID Connect provider.",
5
5
  "main": "index.js",
6
6
  "repository": {
@@ -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
+ });