@adobe/helix-html-pipeline 6.1.2 → 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 CHANGED
@@ -1,3 +1,17 @@
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
+
8
+ # [6.2.0](https://github.com/adobe/helix-html-pipeline/compare/v6.1.2...v6.2.0) (2024-01-15)
9
+
10
+
11
+ ### Features
12
+
13
+ * use aem page ([#494](https://github.com/adobe/helix-html-pipeline/issues/494)) ([a04453b](https://github.com/adobe/helix-html-pipeline/commit/a04453b57476c3c3dde9d1d270a26ffe83d78523))
14
+
1
15
  ## [6.1.2](https://github.com/adobe/helix-html-pipeline/compare/v6.1.1...v6.1.2) (2024-01-15)
2
16
 
3
17
 
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
  [![codecov](https://img.shields.io/codecov/c/github/adobe/helix-html-pipeline.svg)](https://codecov.io/gh/adobe/helix-html-pipeline)
11
- [![CircleCI](https://img.shields.io/circleci/project/github/adobe/helix-html-pipeline.svg)](https://circleci.com/gh/adobe/helix-html-pipeline)
11
+ ![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/adobe/helix-html-pipeline/main.yaml)
12
12
  [![GitHub license](https://img.shields.io/github/license/adobe/helix-html-pipeline.svg)](https://github.com/adobe/helix-html-pipeline/blob/master/LICENSE.txt)
13
13
  [![GitHub issues](https://img.shields.io/github/issues/adobe/helix-html-pipeline.svg)](https://github.com/adobe/helix-html-pipeline/issues)
14
- [![LGTM Code Quality Grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/adobe/helix-html-pipeline.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/adobe/helix-html-pipeline)
15
14
  [![semantic-release](https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg)](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.1.2",
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, getAuthInfo } from './utils/auth.js';
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(state, req) {
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(state, req);
37
- const authInfo = await getAuthInfo(state, req);
38
- await authInfo.exchangeToken(state, req, res);
39
- /* c8 ignore next */
40
- const level = res.status >= 500 ? 'error' : 'info';
41
- log[level](`pipeline status: ${res.status} ${res.error}`);
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
  }
@@ -12,9 +12,9 @@
12
12
 
13
13
  const AZURE_BLOB_REGEXP = /^https:\/\/hlx\.blob\.core\.windows\.net\/external\//;
14
14
 
15
- const MEDIA_BLOB_REGEXP = /^https:\/\/.*\.hlx3?\.(live|page)\/media_.*/;
15
+ const MEDIA_BLOB_REGEXP = /^https:\/\/.*\.(aem|hlx3?)\.(live|page)\/media_.*/;
16
16
 
17
- const HELIX_URL_REGEXP = /^https:\/\/(?!admin\.|www\.)[^.]+\.hlx3?\.(live|page)\/?.*/;
17
+ const HELIX_URL_REGEXP = /^https:\/\/(?!admin\.|www\.)[^.]+\.(aem|hlx3?)\.(live|page)\/?.*/;
18
18
 
19
19
  /**
20
20
  * Returns the original host name from the request to the outer CDN.
package/src/utils/auth.js CHANGED
@@ -23,8 +23,9 @@ 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
- const AUTH_REDIRECT_URL = 'https://login.hlx.page/.auth';
28
+ const AUTH_REDIRECT_URL = 'https://login.aem.page/.auth';
28
29
 
29
30
  let ADMIN_KEY_PAIR = null;
30
31
 
@@ -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 production host if not set
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
- // this is for the development server to remember the org, site, ref and partition
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', state.createExternalLocation(AUTH_REDIRECT_URL));
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 {PipelineState} state
271
+ * @param {universalContext} ctx
313
272
  * @param {PipelineRequest} req
314
- * @param {PipelineResponse} res
273
+ * @return {PipelineResponse} res
274
+ * @throws {Error} if the token exchange fails
315
275
  */
316
- async exchangeToken(state, req, res) {
317
- const { log } = state;
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
- makeAuthError(state, req, res, 'code exchange failed.');
324
- return;
283
+ throw new Error('code exchange failed.');
325
284
  }
326
285
 
327
- // TODO: exchange token on the login host, set-cookie,
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: state.createExternalLocation(AUTH_REDIRECT_URL),
293
+ redirect_uri: AUTH_REDIRECT_URL,
357
294
  };
358
- const { fetch } = state;
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
- makeAuthError(state, req, res, 'code exchange failed.');
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
- makeAuthError(state, req, res, 'id token invalid.');
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
- makeAuthError(state, req, res, 'id token invalid.');
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(state, jwt);
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
- const location = state.createExternalLocation(req.params.state.requestPath || '/');
403
- log.info('[auth] redirecting to original page with hlx-auth-token cookie: ', location);
404
- res.status = 302;
405
- res.body = `please go to <a href="${location}">${location}</a>`;
406
- res.headers.set('location', location);
407
- res.headers.set('content-tye', 'text/plain');
408
- res.headers.set('set-cookie', setAuthCookie(authToken, req.params.state.requestProto === 'https'));
409
- res.headers.set('cache-control', 'no-store, private, must-revalidate');
410
- res.error = 'moved';
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
- export async function validateAuthState(state, req) {
415
- const { log } = state;
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
- req.params.rawState = req.params.state;
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
- requestPath: url.pathname,
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.');