@adobe/helix-html-pipeline 6.2.0 → 6.3.0
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/CHANGELOG.md +7 -0
- package/README.md +1 -2
- package/package.json +2 -1
- package/src/auth-pipe.js +14 -25
- package/src/utils/auth.js +41 -112
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
# [6.3.0](https://github.com/adobe/helix-html-pipeline/compare/v6.2.0...v6.3.0) (2024-01-17)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* simplify auth route ([#496](https://github.com/adobe/helix-html-pipeline/issues/496)) ([396aa22](https://github.com/adobe/helix-html-pipeline/commit/396aa22d0e2509d4706ee475d032f094c913f59d))
|
|
7
|
+
|
|
1
8
|
# [6.2.0](https://github.com/adobe/helix-html-pipeline/compare/v6.1.2...v6.2.0) (2024-01-15)
|
|
2
9
|
|
|
3
10
|
|
package/README.md
CHANGED
|
@@ -8,10 +8,9 @@ This package contains the common code for `helix-pipeline-service` and `helix-cl
|
|
|
8
8
|
|
|
9
9
|
## Status
|
|
10
10
|
[](https://codecov.io/gh/adobe/helix-html-pipeline)
|
|
11
|
-
|
|
11
|
+

|
|
12
12
|
[](https://github.com/adobe/helix-html-pipeline/blob/master/LICENSE.txt)
|
|
13
13
|
[](https://github.com/adobe/helix-html-pipeline/issues)
|
|
14
|
-
[](https://lgtm.com/projects/g/adobe/helix-html-pipeline)
|
|
15
14
|
[](https://github.com/semantic-release/semantic-release)
|
|
16
15
|
|
|
17
16
|
## Installation
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe/helix-html-pipeline",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.0",
|
|
4
4
|
"description": "Helix HTML Pipeline",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"test": "c8 mocha",
|
|
17
17
|
"lint": "eslint .",
|
|
18
18
|
"semantic-release": "semantic-release",
|
|
19
|
+
"semantic-release-dry": "semantic-release --dry-run --branches $CI_BRANCH",
|
|
19
20
|
"prepare": "husky install"
|
|
20
21
|
},
|
|
21
22
|
"repository": {
|
package/src/auth-pipe.js
CHANGED
|
@@ -10,47 +10,36 @@
|
|
|
10
10
|
* governing permissions and limitations under the License.
|
|
11
11
|
*/
|
|
12
12
|
import { cleanupHeaderValue } from '@adobe/helix-shared-utils';
|
|
13
|
-
import setCustomResponseHeaders from './steps/set-custom-response-headers.js';
|
|
14
13
|
import { PipelineResponse } from './PipelineResponse.js';
|
|
15
|
-
import { validateAuthState,
|
|
14
|
+
import { validateAuthState, getRequestHostAndProto, AuthInfo } from './utils/auth.js';
|
|
15
|
+
import { clearAuthCookie } from './utils/auth-cookie.js';
|
|
16
|
+
import idpMicrosoft from './utils/idp-configs/microsoft.js';
|
|
16
17
|
|
|
17
18
|
/**
|
|
18
19
|
* Runs the auth pipeline that handles the token exchange. this is separated from the main pipeline
|
|
19
|
-
* since it doesn't need the configuration.
|
|
20
|
+
* since it doesn't need the configuration (yet).
|
|
20
21
|
*
|
|
21
22
|
* @param {PipelineState} state
|
|
22
23
|
* @param {PipelineRequest} req
|
|
23
24
|
* @returns {PipelineResponse}
|
|
24
25
|
*/
|
|
25
|
-
export async function authPipe(
|
|
26
|
-
const { log } = state;
|
|
27
|
-
|
|
28
|
-
/** @type PipelineResponse */
|
|
29
|
-
const res = new PipelineResponse('', {
|
|
30
|
-
headers: {
|
|
31
|
-
'content-type': 'text/html; charset=utf-8',
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
|
|
26
|
+
export async function authPipe(ctx, req) {
|
|
35
27
|
try {
|
|
36
|
-
await validateAuthState(
|
|
37
|
-
const authInfo =
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
res.headers.set('x-error', cleanupHeaderValue(res.error));
|
|
43
|
-
if (res.status < 500) {
|
|
44
|
-
await setCustomResponseHeaders(state, req, res);
|
|
45
|
-
}
|
|
46
|
-
return res;
|
|
28
|
+
await validateAuthState(ctx, req);
|
|
29
|
+
const authInfo = AuthInfo
|
|
30
|
+
.Default()
|
|
31
|
+
// todo: select idp from config
|
|
32
|
+
.withIdp(idpMicrosoft);
|
|
33
|
+
return await authInfo.exchangeToken(ctx, req);
|
|
47
34
|
} catch (e) {
|
|
35
|
+
const { proto } = getRequestHostAndProto(ctx, req);
|
|
48
36
|
return new PipelineResponse('', {
|
|
49
37
|
status: 401,
|
|
50
38
|
headers: {
|
|
51
39
|
'cache-control': 'no-store, private, must-revalidate',
|
|
52
40
|
'content-type': 'text/html; charset=utf-8',
|
|
53
|
-
'x-error': e.message,
|
|
41
|
+
'x-error': cleanupHeaderValue(e.message),
|
|
42
|
+
'set-cookie': clearAuthCookie(proto === 'https'),
|
|
54
43
|
},
|
|
55
44
|
});
|
|
56
45
|
}
|
package/src/utils/auth.js
CHANGED
|
@@ -23,6 +23,7 @@ import idpMicrosoft from './idp-configs/microsoft.js';
|
|
|
23
23
|
|
|
24
24
|
// eslint-disable-next-line import/no-unresolved
|
|
25
25
|
import cryptoImpl from '#crypto';
|
|
26
|
+
import { PipelineResponse } from '../PipelineResponse.js';
|
|
26
27
|
|
|
27
28
|
const AUTH_REDIRECT_URL = 'https://login.aem.page/.auth';
|
|
28
29
|
|
|
@@ -59,23 +60,6 @@ async function signJWT(state, jwt) {
|
|
|
59
60
|
.sign(privateKey);
|
|
60
61
|
}
|
|
61
62
|
|
|
62
|
-
/**
|
|
63
|
-
* Creates the auth state JWT for redirecting back to the initial page
|
|
64
|
-
* @param {PipelineState} state
|
|
65
|
-
* @param {SignJWT} jwt
|
|
66
|
-
* @returns {Promise<string>}
|
|
67
|
-
*/
|
|
68
|
-
async function createStateJWT(state, jwt) {
|
|
69
|
-
const { privateKey, publicKey } = await getAdminKeyPair(state);
|
|
70
|
-
return jwt
|
|
71
|
-
.setProtectedHeader({
|
|
72
|
-
alg: 'RS256',
|
|
73
|
-
kid: publicKey.kid,
|
|
74
|
-
})
|
|
75
|
-
.setIssuer(publicKey.issuer)
|
|
76
|
-
.sign(privateKey);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
63
|
/**
|
|
80
64
|
* Verifies and decodes the given jwt using the admin public key
|
|
81
65
|
* @param {PipelineState} state
|
|
@@ -96,23 +80,6 @@ async function verifyJwt(state, jwt, lenient = false) {
|
|
|
96
80
|
return payload;
|
|
97
81
|
}
|
|
98
82
|
|
|
99
|
-
/**
|
|
100
|
-
* Verifies and decodes the given state jwt using the admin public key
|
|
101
|
-
* @param {PipelineState} state
|
|
102
|
-
* @param {string} jwt
|
|
103
|
-
* @returns {Promise<JWTPayload>}
|
|
104
|
-
*/
|
|
105
|
-
async function verifyStateJwt(state, jwt) {
|
|
106
|
-
const publicKey = JSON.parse(state.env.HLX_ADMIN_IDP_PUBLIC_KEY);
|
|
107
|
-
const jwks = createLocalJWKSet({
|
|
108
|
-
keys: [publicKey],
|
|
109
|
-
});
|
|
110
|
-
const { payload } = await jwtVerify(jwt, jwks, {
|
|
111
|
-
issuer: publicKey.issuer,
|
|
112
|
-
});
|
|
113
|
-
return payload;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
83
|
/**
|
|
117
84
|
* Decodes the given id_token for the given idp. if `lenient` is `true`, the clock tolerance
|
|
118
85
|
* is set to 1 week. this allows to extract some profile information that can be used as login_hint.
|
|
@@ -144,7 +111,7 @@ export async function decodeIdToken(state, idToken, lenient = false) {
|
|
|
144
111
|
* @param {PipelineRequest} req
|
|
145
112
|
* @returns {{proto: (*|string), host: string}} the request host and protocol.
|
|
146
113
|
*/
|
|
147
|
-
function getRequestHostAndProto(state, req) {
|
|
114
|
+
export function getRequestHostAndProto(state, req) {
|
|
148
115
|
// determine the location of 'this' document based on the xfh header. so that logins to
|
|
149
116
|
// .page stay on .page. etc. but fallback to the config.host if non set
|
|
150
117
|
const xfh = req.headers.get('x-forwarded-host');
|
|
@@ -266,7 +233,7 @@ export class AuthInfo {
|
|
|
266
233
|
}
|
|
267
234
|
|
|
268
235
|
// determine the location of 'this' document based on the xfh header. so that logins to
|
|
269
|
-
// .page stay on .page. etc. but fallback to the
|
|
236
|
+
// .page stay on .page. etc. but fallback to the config.host if non set
|
|
270
237
|
const { host, proto } = getRequestHostAndProto(state, req);
|
|
271
238
|
if (!host) {
|
|
272
239
|
log.error('[auth] unable to create login redirect: no xfh or config.host.');
|
|
@@ -276,17 +243,9 @@ export class AuthInfo {
|
|
|
276
243
|
|
|
277
244
|
// create the token state, so stat we know where to redirect back after the token exchange
|
|
278
245
|
const payload = {
|
|
279
|
-
url: `${proto}://${host}${state.info.path}
|
|
246
|
+
url: state.createExternalLocation(`${proto}://${host}${state.info.path}`),
|
|
280
247
|
};
|
|
281
|
-
|
|
282
|
-
// normally, this is not needed as the host is used to determine that information
|
|
283
|
-
if (state.authIncludeRSO) {
|
|
284
|
-
payload.org = state.org;
|
|
285
|
-
payload.site = state.site;
|
|
286
|
-
payload.ref = state.ref;
|
|
287
|
-
payload.partition = state.partition;
|
|
288
|
-
}
|
|
289
|
-
const tokenState = await createStateJWT(state, new SignJWT(payload));
|
|
248
|
+
const tokenState = await signJWT(state, new SignJWT(payload));
|
|
290
249
|
|
|
291
250
|
const url = new URL(idp.discovery.authorization_endpoint);
|
|
292
251
|
url.searchParams.append('client_id', clientId);
|
|
@@ -294,7 +253,7 @@ export class AuthInfo {
|
|
|
294
253
|
url.searchParams.append('scope', idp.scope);
|
|
295
254
|
url.searchParams.append('nonce', cryptoImpl.randomUUID());
|
|
296
255
|
url.searchParams.append('state', tokenState);
|
|
297
|
-
url.searchParams.append('redirect_uri',
|
|
256
|
+
url.searchParams.append('redirect_uri', AUTH_REDIRECT_URL);
|
|
298
257
|
url.searchParams.append('prompt', 'select_account');
|
|
299
258
|
|
|
300
259
|
log.info('[auth] redirecting to login page', url.href);
|
|
@@ -309,53 +268,31 @@ export class AuthInfo {
|
|
|
309
268
|
/**
|
|
310
269
|
* Performs a token exchange from the code flow and redirects to the root page
|
|
311
270
|
*
|
|
312
|
-
* @param {
|
|
271
|
+
* @param {universalContext} ctx
|
|
313
272
|
* @param {PipelineRequest} req
|
|
314
|
-
* @
|
|
273
|
+
* @return {PipelineResponse} res
|
|
274
|
+
* @throws {Error} if the token exchange fails
|
|
315
275
|
*/
|
|
316
|
-
async exchangeToken(
|
|
317
|
-
const { log } =
|
|
276
|
+
async exchangeToken(ctx, req) {
|
|
277
|
+
const { log } = ctx;
|
|
318
278
|
const { idp } = this;
|
|
319
279
|
|
|
320
280
|
const { code } = req.params;
|
|
321
281
|
if (!code) {
|
|
322
282
|
log.warn('[auth] code exchange failed: code parameter missing.');
|
|
323
|
-
|
|
324
|
-
return;
|
|
283
|
+
throw new Error('code exchange failed.');
|
|
325
284
|
}
|
|
326
285
|
|
|
327
|
-
|
|
328
|
-
// and then again set-cookie on the request host
|
|
329
|
-
|
|
330
|
-
// ensure that the request is made to the target host
|
|
331
|
-
if (req.params.state?.requestHost) {
|
|
332
|
-
const { host } = getRequestHostAndProto(state, req);
|
|
333
|
-
if (host !== req.params.state.requestHost) {
|
|
334
|
-
const url = new URL(`${req.params.state.requestProto}://${req.params.state.requestHost}/.auth`);
|
|
335
|
-
url.searchParams.append('state', req.params.rawState);
|
|
336
|
-
url.searchParams.append('code', req.params.code);
|
|
337
|
-
const location = state.createExternalLocation(url.href);
|
|
338
|
-
log.info('[auth] redirecting to initial host', location);
|
|
339
|
-
res.status = 302;
|
|
340
|
-
res.body = `please go to <a href="${location}">${location}</a>`;
|
|
341
|
-
res.headers.set('location', location);
|
|
342
|
-
res.headers.set('cache-control', 'no-store, private, must-revalidate');
|
|
343
|
-
res.error = 'moved';
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
await state.authEnvLoader.load(state);
|
|
349
|
-
const { clientId, clientSecret } = idp.client(state);
|
|
286
|
+
const { clientId, clientSecret } = idp.client(ctx);
|
|
350
287
|
const url = new URL(idp.discovery.token_endpoint);
|
|
351
288
|
const body = {
|
|
352
289
|
client_id: clientId,
|
|
353
290
|
client_secret: clientSecret,
|
|
354
291
|
code,
|
|
355
292
|
grant_type: 'authorization_code',
|
|
356
|
-
redirect_uri:
|
|
293
|
+
redirect_uri: AUTH_REDIRECT_URL,
|
|
357
294
|
};
|
|
358
|
-
const { fetch } =
|
|
295
|
+
const { fetch } = ctx;
|
|
359
296
|
const ret = await fetch(url.href, {
|
|
360
297
|
method: 'POST',
|
|
361
298
|
body: new URLSearchParams(body).toString(),
|
|
@@ -365,8 +302,7 @@ export class AuthInfo {
|
|
|
365
302
|
});
|
|
366
303
|
if (!ret.ok) {
|
|
367
304
|
log.warn(`[auth] code exchange failed: ${ret.status}`, await ret.text());
|
|
368
|
-
|
|
369
|
-
return;
|
|
305
|
+
throw new Error('code exchange failed.');
|
|
370
306
|
}
|
|
371
307
|
|
|
372
308
|
const tokenResponse = await ret.json();
|
|
@@ -376,15 +312,13 @@ export class AuthInfo {
|
|
|
376
312
|
payload = decodeJwt(idToken);
|
|
377
313
|
} catch (e) {
|
|
378
314
|
log.warn(`[auth] id token from ${idp.name} is invalid: ${e.message}`);
|
|
379
|
-
|
|
380
|
-
return;
|
|
315
|
+
throw new Error('id token invalid.');
|
|
381
316
|
}
|
|
382
317
|
|
|
383
318
|
const email = payload.email || payload.preferred_username;
|
|
384
319
|
if (!email) {
|
|
385
320
|
log.warn(`[auth] id token from ${idp.name} is missing email or preferred_username`);
|
|
386
|
-
|
|
387
|
-
return;
|
|
321
|
+
throw new Error('id token invalid.');
|
|
388
322
|
}
|
|
389
323
|
|
|
390
324
|
// create new token
|
|
@@ -394,25 +328,31 @@ export class AuthInfo {
|
|
|
394
328
|
})
|
|
395
329
|
.setIssuedAt()
|
|
396
330
|
.setExpirationTime('12 hours');
|
|
397
|
-
const authToken = await signJWT(
|
|
398
|
-
|
|
399
|
-
// ensure that auth cookie is not cleared again in `index.js`
|
|
400
|
-
// ctx.attributes.authInfo?.withCookieInvalid(false);
|
|
331
|
+
const authToken = await signJWT(ctx, jwt);
|
|
401
332
|
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
333
|
+
// redirect to original page
|
|
334
|
+
const location = req.params.state.url;
|
|
335
|
+
log.info('[auth] redirecting to original page with hlx-auth-token cookie:', location);
|
|
336
|
+
return new PipelineResponse(`please go to <a href="${location}">${location}</a>`, {
|
|
337
|
+
status: 302,
|
|
338
|
+
headers: {
|
|
339
|
+
'content-type': 'text/html; charset=utf-8',
|
|
340
|
+
'set-cookie': setAuthCookie(authToken, location.startsWith('https://')),
|
|
341
|
+
'cache-control': 'no-store, private, must-revalidate',
|
|
342
|
+
location,
|
|
343
|
+
},
|
|
344
|
+
});
|
|
411
345
|
}
|
|
412
346
|
}
|
|
413
347
|
|
|
414
|
-
|
|
415
|
-
|
|
348
|
+
/**
|
|
349
|
+
* Validates the auth state and code either with from query parameter or request header.
|
|
350
|
+
* @param {UniversalContext} ctx
|
|
351
|
+
* @param {PipelineRequest} req
|
|
352
|
+
* @returns {Promise<void>}
|
|
353
|
+
*/
|
|
354
|
+
export async function validateAuthState(ctx, req) {
|
|
355
|
+
const { log } = ctx;
|
|
416
356
|
// use request headers if present
|
|
417
357
|
if (req.headers.get('x-hlx-auth-state')) {
|
|
418
358
|
log.info('[auth] override params.state from header.');
|
|
@@ -429,21 +369,10 @@ export async function validateAuthState(state, req) {
|
|
|
429
369
|
}
|
|
430
370
|
|
|
431
371
|
try {
|
|
432
|
-
|
|
433
|
-
const payload = await verifyStateJwt(state, req.params.state);
|
|
434
|
-
const url = new URL(payload.url);
|
|
372
|
+
const payload = await verifyJwt(ctx, req.params.state);
|
|
435
373
|
req.params.state = {
|
|
436
|
-
|
|
437
|
-
requestHost: url.host,
|
|
438
|
-
requestProto: url.protocol.replace(/:$/, ''),
|
|
374
|
+
url: payload.url,
|
|
439
375
|
};
|
|
440
|
-
// for development server
|
|
441
|
-
if (payload.org && payload.site && payload.ref && payload.partition) {
|
|
442
|
-
state.org = payload.org;
|
|
443
|
-
state.site = payload.site;
|
|
444
|
-
state.ref = payload.ref;
|
|
445
|
-
state.partition = payload.partition;
|
|
446
|
-
}
|
|
447
376
|
} catch (e) {
|
|
448
377
|
log.warn(`[auth] error decoding state parameter: invalid state: ${e.message}`);
|
|
449
378
|
throw new Error('invalid state parameter.');
|