@adobe/helix-html-pipeline 3.0.0 → 3.1.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,24 @@
1
+ # [3.1.0](https://github.com/adobe/helix-html-pipeline/compare/v3.0.2...v3.1.0) (2022-06-24)
2
+
3
+
4
+ ### Features
5
+
6
+ * improve authentication implementation ([#90](https://github.com/adobe/helix-html-pipeline/issues/90)) ([def347c](https://github.com/adobe/helix-html-pipeline/commit/def347cd0d4860d2804d71be6b702a6be30d6095)), closes [#85](https://github.com/adobe/helix-html-pipeline/issues/85)
7
+
8
+ ## [3.0.2](https://github.com/adobe/helix-html-pipeline/compare/v3.0.1...v3.0.2) (2022-06-16)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * make crypto.randomUUID() portable ([d40ba5a](https://github.com/adobe/helix-html-pipeline/commit/d40ba5ab67c764726d923061d4844e5adb162c86))
14
+
15
+ ## [3.0.1](https://github.com/adobe/helix-html-pipeline/compare/v3.0.0...v3.0.1) (2022-06-14)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * handle xfh properly and protect forms and json pipeline ([#83](https://github.com/adobe/helix-html-pipeline/issues/83)) ([9c13419](https://github.com/adobe/helix-html-pipeline/commit/9c1341987549fbc721a7d1bce12fe537a6f8c5ba))
21
+
1
22
  # [3.0.0](https://github.com/adobe/helix-html-pipeline/compare/v2.1.2...v3.0.0) (2022-06-14)
2
23
 
3
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-html-pipeline",
3
- "version": "3.0.0",
3
+ "version": "3.1.0",
4
4
  "description": "Helix HTML Pipeline",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -75,16 +75,16 @@
75
75
  "@semantic-release/git": "10.0.1",
76
76
  "@semantic-release/npm": "9.0.1",
77
77
  "c8": "7.11.3",
78
- "eslint": "8.17.0",
78
+ "eslint": "8.18.0",
79
79
  "eslint-plugin-header": "3.1.1",
80
80
  "eslint-plugin-import": "2.26.0",
81
81
  "esmock": "1.7.5",
82
82
  "husky": "8.0.1",
83
83
  "js-yaml": "4.1.0",
84
84
  "jsdoc-to-markdown": "7.1.1",
85
- "jsdom": "19.0.0",
85
+ "jsdom": "20.0.0",
86
86
  "junit-report-builder": "3.0.0",
87
- "lint-staged": "13.0.1",
87
+ "lint-staged": "13.0.2",
88
88
  "mocha": "10.0.0",
89
89
  "mocha-multi-reporters": "1.5.1",
90
90
  "remark-gfm": "3.0.1",
@@ -44,11 +44,13 @@ declare interface PipelineOptions {
44
44
  path: string;
45
45
  contentBusId: string;
46
46
  timer: PipelineTimer;
47
+ env: object;
47
48
  }
48
49
 
49
50
  declare class PipelineState {
50
51
  constructor(opts: PipelineOptions);
51
52
  log: Console;
53
+ env: object;
52
54
  info: PathInfo;
53
55
  content: PipelineContent;
54
56
  contentBusId: string;
@@ -26,6 +26,7 @@ export class PipelineState {
26
26
  constructor(opts) {
27
27
  Object.assign(this, {
28
28
  log: opts.log ?? console,
29
+ env: opts.env,
29
30
  info: getPathInfo(opts.path),
30
31
  content: new PipelineContent(),
31
32
  // todo: compute content-bus id from fstab
package/src/forms-pipe.js CHANGED
@@ -13,6 +13,7 @@ import { cleanupHeaderValue } from '@adobe/helix-shared-utils';
13
13
  import { PipelineResponse } from './PipelineResponse.js';
14
14
  import fetchConfigAll from './steps/fetch-config-all.js';
15
15
  import setCustomResponseHeaders from './steps/set-custom-response-headers.js';
16
+ import { authenticate } from './steps/authenticate.js';
16
17
 
17
18
  function error(log, msg, status, response) {
18
19
  log.error(msg);
@@ -96,6 +97,10 @@ export async function formsPipe(state, request) {
96
97
  },
97
98
  });
98
99
  await fetchConfigAll(state, request, response);
100
+ await authenticate(state, request, response);
101
+ if (response.error) {
102
+ return response;
103
+ }
99
104
  await setCustomResponseHeaders(state, request, response);
100
105
 
101
106
  const {
package/src/json-pipe.js CHANGED
@@ -14,6 +14,7 @@ import setCustomResponseHeaders from './steps/set-custom-response-headers.js';
14
14
  import { PipelineResponse } from './PipelineResponse.js';
15
15
  import jsonFilter from './utils/json-filter.js';
16
16
  import { extractLastModified, updateLastModified } from './utils/last-modified.js';
17
+ import { authenticate } from './steps/authenticate.js';
17
18
 
18
19
  /**
19
20
  * Runs the default pipeline and returns the response.
@@ -80,6 +81,7 @@ export async function jsonPipe(state, req) {
80
81
 
81
82
  // Load config-all and set response headers
82
83
  await fetchConfigAll(state, req, response);
84
+ await authenticate(state, req, response);
83
85
  await setCustomResponseHeaders(state, req, response);
84
86
 
85
87
  return response;
@@ -37,7 +37,7 @@ export function isAllowed(email = '', allows = []) {
37
37
  */
38
38
  export async function authenticate(state, req, res) {
39
39
  // get auth info
40
- const authInfo = await getAuthInfo(state, req, res);
40
+ const authInfo = await getAuthInfo(state, req);
41
41
 
42
42
  // check if `.auth` route to validate and exchange token
43
43
  if (state.info.path === '/.auth') {
@@ -52,12 +52,17 @@ export async function authenticate(state, req, res) {
52
52
 
53
53
  // if not authenticated, redirect to login screen
54
54
  if (!authInfo.authenticated) {
55
+ // send 401 for plain requests
56
+ if (state.info.selector || state.type !== 'html') {
57
+ state.log.warn('[auth] unauthorized. redirect to login only for extension less html.');
58
+ res.status = 401;
59
+ res.error = 'unauthorized.';
60
+ return;
61
+ }
55
62
  authInfo.redirectToLogin(state, req, res);
56
63
  return;
57
64
  }
58
65
 
59
- // console.log(authInfo.profile);
60
-
61
66
  // check profile is allowed
62
67
  const { allow } = state.config.access;
63
68
  const allows = Array.isArray(allow) ? allow : [allow];
@@ -73,6 +78,6 @@ export async function authenticate(state, req, res) {
73
78
  res.headers.set('x-hlx-auth-iss', authInfo.profile.iss);
74
79
  res.headers.set('x-hlx-auth-kid', authInfo.profile.kid);
75
80
  res.headers.set('x-hlx-auth-aud', authInfo.profile.aud);
76
- res.headers.set('x-hlx-auth-jwk', JSON.stringify(authInfo.profile.jwk));
81
+ res.headers.set('x-hlx-auth-key', authInfo.profile.pem);
77
82
  }
78
83
  }
package/src/utils/auth.js CHANGED
@@ -10,14 +10,24 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
  // eslint-disable-next-line max-classes-per-file
13
- import crypto from 'crypto';
14
13
  import {
15
- createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify, UnsecuredJWT,
14
+ createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify, UnsecuredJWT, exportSPKI,
16
15
  } from 'jose';
17
16
  import { clearAuthCookie, getAuthCookie, setAuthCookie } from './auth-cookie.js';
18
17
 
19
18
  import idpMicrosoft from './idp-configs/microsoft.js';
20
19
 
20
+ let cryptoImpl;
21
+ import('crypto')
22
+ .then((crypto) => {
23
+ cryptoImpl = crypto;
24
+ })
25
+ /* c8 ignore next 3 */
26
+ .catch(() => {
27
+ // eslint-disable-next-line no-undef
28
+ cryptoImpl = crypto;
29
+ });
30
+
21
31
  export const IDPS = [
22
32
  idpMicrosoft,
23
33
  ];
@@ -54,16 +64,38 @@ export async function decodeIdToken(state, idp, idToken, lenient = false) {
54
64
  payload.ttl = payload.exp - Math.floor(Date.now() / 1000);
55
65
 
56
66
  // export the public key
57
- payload.jwk = key.export({
58
- type: 'pkcs1',
59
- format: 'jwk',
60
- });
67
+ payload.pem = await exportSPKI(key);
68
+ // and encode it base64 url
69
+ payload.pem = Buffer.from(payload.pem, 'utf-8').toString('base64url');
61
70
  payload.kid = protectedHeader.kid;
62
71
 
63
72
  log.info(`[auth] decoded id_token${lenient ? ' (lenient)' : ''} from ${payload.iss} and validated payload.`);
64
73
  return payload;
65
74
  }
66
75
 
76
+ /**
77
+ * Returns the host of the request; falls back to the configured `host`.
78
+ * Note that this is different from the `config.host` calculation in `fetch-config-all`,
79
+ * as this prefers the xfh over the config.
80
+ *
81
+ * @param {PipelineState} state
82
+ * @param {PipelineRequest} req
83
+ * @return {string}
84
+ */
85
+ function getRequestHost(state, req) {
86
+ // determine the location of 'this' document based on the xfh header. so that logins to
87
+ // .page stay on .page. etc. but fallback to the config.host if non set
88
+ let host = req.headers.get('x-forwarded-host');
89
+ if (host) {
90
+ host = host.split(',')[0].trim();
91
+ }
92
+ if (!host) {
93
+ host = state.config.host;
94
+ }
95
+ state.log.info(`request host is: ${host}`);
96
+ return host;
97
+ }
98
+
67
99
  /**
68
100
  * AuthInfo class
69
101
  */
@@ -150,7 +182,13 @@ export class AuthInfo {
150
182
 
151
183
  // determine the location of 'this' document based on the xfh header. so that logins to
152
184
  // .page stay on .page. etc. but fallback to the config.host if non set
153
- const host = req.headers.get('x-forwarded-host') || state.config.host;
185
+ const host = getRequestHost(state, req);
186
+ if (!host) {
187
+ log.error('[auth] unable to create login redirect: no xfh or config.host.');
188
+ res.status = 401;
189
+ res.error = 'no host information.';
190
+ return;
191
+ }
154
192
 
155
193
  const url = new URL(idp.discovery.authorization_endpoint);
156
194
 
@@ -167,7 +205,7 @@ export class AuthInfo {
167
205
  url.searchParams.append('client_id', clientId);
168
206
  url.searchParams.append('response_type', 'code');
169
207
  url.searchParams.append('scope', idp.scope);
170
- url.searchParams.append('nonce', crypto.randomUUID());
208
+ url.searchParams.append('nonce', cryptoImpl.randomUUID());
171
209
  url.searchParams.append('state', tokenState);
172
210
  url.searchParams.append('redirect_uri', state.createExternalLocation(AUTH_REDIRECT_URL));
173
211
  url.searchParams.append('prompt', 'select_account');
@@ -202,7 +240,7 @@ export class AuthInfo {
202
240
 
203
241
  // ensure that the request is made to the target host
204
242
  if (req.params.state?.requestHost) {
205
- const host = req.headers.get('x-forwarded-host') || state.config.host;
243
+ const host = getRequestHost(state, req);
206
244
  if (host !== req.params.state.requestHost) {
207
245
  const url = new URL(`https://${req.params.state.requestHost}/.auth`);
208
246
  url.searchParams.append('state', req.params.rawState);
@@ -308,15 +346,25 @@ export function initAuthRoute(state, req, res) {
308
346
  }
309
347
 
310
348
  /**
311
- * Extracts the authentication info from the cookie. Returns {@code null} if missing or invalid.
349
+ * Extracts the authentication info from the cookie or 'authorization' header.
350
+ * Returns {@code null} if missing or invalid.
312
351
  *
313
352
  * @param {PipelineState} state
314
353
  * @param {PipelineRequest} req
315
354
  * @returns {Promise<AuthInfo>} the authentication info or null if the request is not authenticated
316
355
  */
317
- async function getAuthInfoFromCookie(state, req) {
356
+ async function getAuthInfoFromCookieOrHeader(state, req) {
318
357
  const { log } = state;
319
- const idToken = getAuthCookie(req);
358
+ let idToken = getAuthCookie(req);
359
+ if (!idToken) {
360
+ log.info('no auth cookie');
361
+ const [marker, value] = (req.headers.get('authorization') || '').split(' ');
362
+ if (marker.toLowerCase() === 'token' && value) {
363
+ idToken = value.trim();
364
+ } else {
365
+ log.info('no auth header');
366
+ }
367
+ }
320
368
  if (idToken) {
321
369
  let idp;
322
370
  try {
@@ -353,6 +401,7 @@ async function getAuthInfoFromCookie(state, req) {
353
401
  return AuthInfo.Default().withCookieInvalid(true);
354
402
  }
355
403
  }
404
+ log.info('no id_token');
356
405
  return null;
357
406
  }
358
407
 
@@ -364,7 +413,7 @@ async function getAuthInfoFromCookie(state, req) {
364
413
  */
365
414
  export async function getAuthInfo(state, req) {
366
415
  const { log } = state;
367
- const auth = await getAuthInfoFromCookie(state, req);
416
+ const auth = await getAuthInfoFromCookieOrHeader(state, req);
368
417
  if (auth) {
369
418
  if (auth.authenticated) {
370
419
  log.info(`[auth] id-token valid: iss=${auth.profile.iss}`);
@@ -12,9 +12,9 @@
12
12
  export default {
13
13
  name: 'microsoft',
14
14
  mountType: 'onedrive',
15
- client: () => ({
16
- clientId: process.env.HLX_SITE_APP_AZURE_CLIENT_ID,
17
- clientSecret: process.env.HLX_SITE_APP_AZURE_CLIENT_SECRET,
15
+ client: (state) => ({
16
+ clientId: state.env.HLX_SITE_APP_AZURE_CLIENT_ID,
17
+ clientSecret: state.env.HLX_SITE_APP_AZURE_CLIENT_SECRET,
18
18
  }),
19
19
  scope: 'openid profile email',
20
20
  validateIssuer: (iss) => iss.startsWith('https://login.microsoftonline.com/'),