ep_openid_connect 3.0.20 → 3.0.22
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/index.js +90 -54
- package/package.json +5 -5
package/index.js
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
const Ajv = require('ajv/dist/jtd');
|
|
4
4
|
const {URL} = require('url');
|
|
5
|
-
const {Issuer, generators} = require('openid-client');
|
|
6
5
|
|
|
7
6
|
let logger = {};
|
|
8
7
|
for (const level of ['debug', 'info', 'warn', 'error']) {
|
|
@@ -14,7 +13,14 @@ const defaultSettings = {
|
|
|
14
13
|
user_properties: {},
|
|
15
14
|
};
|
|
16
15
|
let settings;
|
|
17
|
-
let
|
|
16
|
+
let oidcConfig = null;
|
|
17
|
+
// openid-client@6 is ESM-only, so it must be loaded with a dynamic import from
|
|
18
|
+
// this CommonJS module. The reference is cached after the first call.
|
|
19
|
+
let oidc = null;
|
|
20
|
+
const loadOidc = async () => {
|
|
21
|
+
if (oidc == null) oidc = await import('openid-client');
|
|
22
|
+
return oidc;
|
|
23
|
+
};
|
|
18
24
|
|
|
19
25
|
const validSettings = new Ajv().compile({
|
|
20
26
|
properties: {
|
|
@@ -53,23 +59,35 @@ const validateSubClaim = (sub) => {
|
|
|
53
59
|
}
|
|
54
60
|
};
|
|
55
61
|
|
|
56
|
-
const
|
|
57
|
-
|
|
62
|
+
const isHttp = (urlString) => {
|
|
63
|
+
try {
|
|
64
|
+
return new URL(urlString).protocol === 'http:';
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const discoverConfig = async (issuerUrl, clientId, clientAuth) => {
|
|
71
|
+
const url = new URL(issuerUrl);
|
|
58
72
|
// https://openid.net/specs/openid-connect-discovery-1_0.html#IssuerDiscovery says that the URI
|
|
59
73
|
// must not have query or fragment components.
|
|
60
|
-
if (
|
|
61
|
-
throw new Error(`Unexpected query in issuer URL (${
|
|
74
|
+
if (url.search) {
|
|
75
|
+
throw new Error(`Unexpected query in issuer URL (${url}): ${url.search}`);
|
|
62
76
|
}
|
|
63
|
-
if (
|
|
64
|
-
throw new Error(`Unexpected fragment in issuer URL (${
|
|
77
|
+
if (url.hash) {
|
|
78
|
+
throw new Error(`Unexpected fragment in issuer URL (${url}): ${url.hash}`);
|
|
65
79
|
}
|
|
66
|
-
|
|
80
|
+
// openid-client@6 rejects http:// issuers by default; opt back in for
|
|
81
|
+
// localhost / private-network providers (matches v5 behaviour).
|
|
82
|
+
const options = url.protocol === 'http:'
|
|
83
|
+
? {execute: [oidc.allowInsecureRequests]}
|
|
84
|
+
: undefined;
|
|
67
85
|
try {
|
|
68
|
-
|
|
86
|
+
return await oidc.discovery(url, clientId, undefined, clientAuth, options);
|
|
69
87
|
} catch (err) {
|
|
70
88
|
// The URL used to get the issuer metadata doesn't exactly follow RFC 8615; see:
|
|
71
89
|
// https://openid.net/specs/openid-connect-discovery-1_0.html#ProviderConfig
|
|
72
|
-
const discoveryUrl = new URL(
|
|
90
|
+
const discoveryUrl = new URL(url);
|
|
73
91
|
if (!discoveryUrl.pathname.includes('/.well-known/')) {
|
|
74
92
|
discoveryUrl.pathname =
|
|
75
93
|
`${discoveryUrl.pathname.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
@@ -80,22 +98,36 @@ const discoverIssuer = async (issuerUrl) => {
|
|
|
80
98
|
`Does your issuer support Discovery? (hint: ${discoveryUrl})`);
|
|
81
99
|
throw err;
|
|
82
100
|
}
|
|
83
|
-
logger.info('OpenID Connect Discovery complete.');
|
|
84
|
-
return issuer;
|
|
85
101
|
};
|
|
86
102
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
103
|
+
const buildConfig = async (settings) => {
|
|
104
|
+
// openid-client@5 defaulted the token endpoint auth method to
|
|
105
|
+
// `client_secret_basic`; v6 defaults to `client_secret_post`. Pin the
|
|
106
|
+
// method explicitly so existing IdP registrations keep working.
|
|
107
|
+
const clientAuth = oidc.ClientSecretBasic(settings.client_secret);
|
|
108
|
+
if (settings.issuer) {
|
|
109
|
+
const config =
|
|
110
|
+
await discoverConfig(settings.issuer, settings.client_id, clientAuth);
|
|
111
|
+
logger.info('OpenID Connect Discovery complete.');
|
|
112
|
+
return config;
|
|
113
|
+
}
|
|
114
|
+
const config =
|
|
115
|
+
new oidc.Configuration(settings.issuer_metadata, settings.client_id, undefined, clientAuth);
|
|
116
|
+
if (isHttp(settings.issuer_metadata && settings.issuer_metadata.issuer)) {
|
|
117
|
+
oidc.allowInsecureRequests(config);
|
|
118
|
+
}
|
|
119
|
+
return config;
|
|
90
120
|
};
|
|
91
121
|
|
|
92
122
|
exports.init_ep_openid_connect = async (hookName, {logger: l}) => {
|
|
93
123
|
if (l != null) logger = l;
|
|
124
|
+
await loadOidc();
|
|
94
125
|
};
|
|
95
126
|
|
|
96
127
|
exports.loadSettings = async (hookName, {settings: {ep_openid_connect: s = {}}}) => {
|
|
97
|
-
|
|
128
|
+
oidcConfig = null;
|
|
98
129
|
settings = null;
|
|
130
|
+
await loadOidc();
|
|
99
131
|
if (!validSettings(s)) {
|
|
100
132
|
logger.error('Invalid settings. Detailed validation errors:', validSettings.errors);
|
|
101
133
|
return;
|
|
@@ -121,12 +153,7 @@ exports.loadSettings = async (hookName, {settings: {ep_openid_connect: s = {}}})
|
|
|
121
153
|
// Make sure base_url ends with '/' so that relative URLs are appended:
|
|
122
154
|
if (!settings.base_url.endsWith('/')) settings.base_url += '/';
|
|
123
155
|
logger.debug('Settings:', {...settings, client_secret: '********'});
|
|
124
|
-
|
|
125
|
-
client_id: settings.client_id,
|
|
126
|
-
client_secret: settings.client_secret,
|
|
127
|
-
response_types: ['code'],
|
|
128
|
-
redirect_uris: [endpointUrl('callback')],
|
|
129
|
-
});
|
|
156
|
+
oidcConfig = await buildConfig(settings);
|
|
130
157
|
logger.info('Configured.');
|
|
131
158
|
};
|
|
132
159
|
|
|
@@ -137,16 +164,22 @@ exports.expressCreateServer = (hookName, {app}) => {
|
|
|
137
164
|
// otherwise the user could be caught in an infinite redirect loop.
|
|
138
165
|
try {
|
|
139
166
|
logger.debug(`Processing ${req.url}`);
|
|
140
|
-
if (
|
|
167
|
+
if (oidcConfig == null) {
|
|
141
168
|
logger.warn('Not configured; ignoring request.');
|
|
142
169
|
return next();
|
|
143
170
|
}
|
|
144
|
-
const params = oidcClient.callbackParams(req);
|
|
145
171
|
const oidcSession = req.session.ep_openid_connect || {};
|
|
146
172
|
if (oidcSession.callbackChecks == null) throw new Error('missing authentication checks');
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
173
|
+
const currentUrl = new URL(req.originalUrl || req.url, settings.base_url);
|
|
174
|
+
const tokens = await oidc.authorizationCodeGrant(oidcConfig, currentUrl, {
|
|
175
|
+
expectedNonce: oidcSession.callbackChecks.nonce,
|
|
176
|
+
expectedState: oidcSession.callbackChecks.state,
|
|
177
|
+
pkceCodeVerifier: oidcSession.callbackChecks.code_verifier,
|
|
178
|
+
idTokenExpected: true,
|
|
179
|
+
});
|
|
180
|
+
const claims = tokens.claims();
|
|
181
|
+
const userinfo =
|
|
182
|
+
await oidc.fetchUserInfo(oidcConfig, tokens.access_token, claims && claims.sub);
|
|
150
183
|
validateSubClaim(userinfo.sub);
|
|
151
184
|
// The user has successfully authenticated, but don't set req.session.user here -- do it in
|
|
152
185
|
// the authenticate hook so that Etherpad can log the authentication success. However, DO "log
|
|
@@ -165,33 +198,36 @@ exports.expressCreateServer = (hookName, {app}) => {
|
|
|
165
198
|
return next(err);
|
|
166
199
|
}
|
|
167
200
|
});
|
|
168
|
-
app.get(ep('login'), (req, res, next) => {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
201
|
+
app.get(ep('login'), async (req, res, next) => {
|
|
202
|
+
try {
|
|
203
|
+
logger.debug(`Processing ${req.url}`);
|
|
204
|
+
if (oidcConfig == null) {
|
|
205
|
+
logger.warn('Not configured; ignoring request.');
|
|
206
|
+
return next();
|
|
207
|
+
}
|
|
208
|
+
if (req.session.ep_openid_connect == null) req.session.ep_openid_connect = {};
|
|
209
|
+
const oidcSession = req.session.ep_openid_connect;
|
|
210
|
+
const code_verifier = oidc.randomPKCECodeVerifier(); // RFC7636
|
|
211
|
+
const code_challenge = await oidc.calculatePKCECodeChallenge(code_verifier);
|
|
212
|
+
const nonce = oidc.randomNonce();
|
|
213
|
+
const state = oidc.randomState();
|
|
214
|
+
oidcSession.callbackChecks = {nonce, state, code_verifier};
|
|
215
|
+
const url = oidc.buildAuthorizationUrl(oidcConfig, {
|
|
216
|
+
redirect_uri: endpointUrl('callback'),
|
|
217
|
+
scope: settings.scope.join(' '),
|
|
218
|
+
nonce,
|
|
219
|
+
state,
|
|
220
|
+
code_challenge,
|
|
221
|
+
code_challenge_method: 'S256',
|
|
222
|
+
});
|
|
223
|
+
res.redirect(303, url.toString());
|
|
224
|
+
} catch (err) {
|
|
225
|
+
return next(err);
|
|
173
226
|
}
|
|
174
|
-
if (req.session.ep_openid_connect == null) req.session.ep_openid_connect = {};
|
|
175
|
-
const oidcSession = req.session.ep_openid_connect;
|
|
176
|
-
const commonParams = {
|
|
177
|
-
nonce: generators.nonce(),
|
|
178
|
-
scope: settings.scope.join(' '),
|
|
179
|
-
state: generators.state(),
|
|
180
|
-
};
|
|
181
|
-
oidcSession.callbackChecks = {
|
|
182
|
-
...commonParams,
|
|
183
|
-
code_verifier: generators.codeVerifier(), // RFC7636
|
|
184
|
-
};
|
|
185
|
-
res.redirect(303, oidcClient.authorizationUrl({
|
|
186
|
-
...commonParams,
|
|
187
|
-
// RFC7636
|
|
188
|
-
code_challenge: generators.codeChallenge(oidcSession.callbackChecks.code_verifier),
|
|
189
|
-
code_challenge_method: 'S256',
|
|
190
|
-
}));
|
|
191
227
|
});
|
|
192
228
|
app.get(ep('logout'), (req, res, next) => {
|
|
193
229
|
logger.debug(`Processing ${req.url}`);
|
|
194
|
-
if (
|
|
230
|
+
if (oidcConfig == null) {
|
|
195
231
|
logger.warn('Not configured; ignoring request.');
|
|
196
232
|
return next();
|
|
197
233
|
}
|
|
@@ -200,7 +236,7 @@ exports.expressCreateServer = (hookName, {app}) => {
|
|
|
200
236
|
};
|
|
201
237
|
|
|
202
238
|
exports.authenticate = (hookName, {req, res, users}) => {
|
|
203
|
-
if (
|
|
239
|
+
if (oidcConfig == null) return;
|
|
204
240
|
logger.debug('authenticate hook for', req.url);
|
|
205
241
|
const {ep_openid_connect: {userinfo} = {}} = req.session;
|
|
206
242
|
if (userinfo == null) { // Nullish means the user isn't authenticated.
|
|
@@ -228,7 +264,7 @@ exports.authenticate = (hookName, {req, res, users}) => {
|
|
|
228
264
|
};
|
|
229
265
|
|
|
230
266
|
exports.authnFailure = (hookName, {req, res}) => {
|
|
231
|
-
if (
|
|
267
|
+
if (oidcConfig == null) return;
|
|
232
268
|
// Normally the user is redirected to the login page which would then redirect the user back once
|
|
233
269
|
// authenticated. For non-GET requests, send a 401 instead because users can't be redirected back.
|
|
234
270
|
// Also send a 401 if an Authorization header is present to facilitate API error handling.
|
|
@@ -268,7 +304,7 @@ exports.authnFailure = (hookName, {req, res}) => {
|
|
|
268
304
|
};
|
|
269
305
|
|
|
270
306
|
exports.preAuthorize = (hookName, {req}) => {
|
|
271
|
-
if (
|
|
307
|
+
if (oidcConfig == null) return;
|
|
272
308
|
if (req.path.startsWith(ep(''))) return true;
|
|
273
309
|
return;
|
|
274
310
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ep_openid_connect",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.22",
|
|
4
4
|
"description": "Etherpad plugin to authenticate users against an OpenID Connect provider.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"repository": {
|
|
@@ -23,15 +23,15 @@
|
|
|
23
23
|
},
|
|
24
24
|
"homepage": "https://github.com/ether/ep_openid_connect#readme",
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"ajv": "^8.
|
|
27
|
-
"openid-client": "^
|
|
26
|
+
"ajv": "^8.20.0",
|
|
27
|
+
"openid-client": "^6.8.4"
|
|
28
28
|
},
|
|
29
29
|
"devDependencies": {
|
|
30
30
|
"eslint": "^8.57.1",
|
|
31
31
|
"eslint-config-etherpad": "^4.0.5",
|
|
32
|
-
"oidc-provider": "^
|
|
32
|
+
"oidc-provider": "^9.8.3",
|
|
33
33
|
"supertest": "^6.3.4",
|
|
34
|
-
"typescript": "^
|
|
34
|
+
"typescript": "^6.0.3"
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
37
|
"lint": "eslint .",
|