@adobe/helix-html-pipeline 2.1.2 → 3.0.2

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,29 @@
1
+ ## [3.0.2](https://github.com/adobe/helix-html-pipeline/compare/v3.0.1...v3.0.2) (2022-06-16)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * make crypto.randomUUID() portable ([d40ba5a](https://github.com/adobe/helix-html-pipeline/commit/d40ba5ab67c764726d923061d4844e5adb162c86))
7
+
8
+ ## [3.0.1](https://github.com/adobe/helix-html-pipeline/compare/v3.0.0...v3.0.1) (2022-06-14)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * 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))
14
+
15
+ # [3.0.0](https://github.com/adobe/helix-html-pipeline/compare/v2.1.2...v3.0.0) (2022-06-14)
16
+
17
+
18
+ ### Features
19
+
20
+ * add site access control ([#80](https://github.com/adobe/helix-html-pipeline/issues/80)) ([2109d90](https://github.com/adobe/helix-html-pipeline/commit/2109d90a932b75a9de6996da2854d349613541b9))
21
+
22
+
23
+ ### BREAKING CHANGES
24
+
25
+ * PipelineState now need to implement fetch() and createExternalLocation()
26
+
1
27
  ## [2.1.2](https://github.com/adobe/helix-html-pipeline/compare/v2.1.1...v2.1.2) (2022-06-04)
2
28
 
3
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-html-pipeline",
3
- "version": "2.1.2",
3
+ "version": "3.0.2",
4
4
  "description": "Helix HTML Pipeline",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -35,12 +35,14 @@
35
35
  "dependencies": {
36
36
  "@adobe/helix-markdown-support": "3.1.6",
37
37
  "@adobe/helix-shared-utils": "2.0.11",
38
+ "cookie": "0.5.0",
38
39
  "github-slugger": "1.4.0",
39
40
  "hast-util-raw": "7.2.1",
40
41
  "hast-util-select": "5.0.2",
41
42
  "hast-util-to-html": "8.0.3",
42
43
  "hast-util-to-string": "2.0.0",
43
44
  "hastscript": "7.0.2",
45
+ "jose": "4.8.1",
44
46
  "mdast-util-gfm-footnote": "1.0.1",
45
47
  "mdast-util-gfm-strikethrough": "1.0.1",
46
48
  "mdast-util-gfm-table": "1.0.4",
@@ -82,11 +84,11 @@
82
84
  "jsdoc-to-markdown": "7.1.1",
83
85
  "jsdom": "19.0.0",
84
86
  "junit-report-builder": "3.0.0",
85
- "lint-staged": "12.5.0",
87
+ "lint-staged": "13.0.1",
86
88
  "mocha": "10.0.0",
87
89
  "mocha-multi-reporters": "1.5.1",
88
90
  "remark-gfm": "3.0.1",
89
- "semantic-release": "19.0.2"
91
+ "semantic-release": "19.0.3"
90
92
  },
91
93
  "lint-staged": {
92
94
  "*.js": "eslint",
@@ -23,4 +23,5 @@ declare class PipelineRequest {
23
23
  method: string;
24
24
  headers: Map<string, string>;
25
25
  body: string;
26
+ params: object;
26
27
  }
@@ -15,6 +15,31 @@
15
15
  * @class PipelineRequest
16
16
  */
17
17
  export class PipelineRequest {
18
+ /**
19
+ * request url
20
+ */
21
+ url;
22
+
23
+ /**
24
+ * uppercase request method
25
+ */
26
+ method;
27
+
28
+ /**
29
+ * request body
30
+ */
31
+ body;
32
+
33
+ /**
34
+ * request headers
35
+ */
36
+ headers;
37
+
38
+ /**
39
+ * request params;
40
+ */
41
+ params;
42
+
18
43
  /**
19
44
  * Creates the pipeline request
20
45
  * @param {URL|string} url
@@ -25,12 +50,12 @@ export class PipelineRequest {
25
50
  if (typeof headers.get !== 'function') {
26
51
  headers = new Map(Object.entries(init.headers));
27
52
  }
28
-
29
53
  Object.assign(this, {
30
54
  url: url instanceof URL ? url : new URL(url),
31
55
  method: init.method ?? 'GET',
32
56
  body: init.body,
33
57
  headers,
34
58
  });
59
+ this.params = Object.fromEntries(this.url.searchParams.entries());
35
60
  }
36
61
  }
@@ -19,9 +19,16 @@ declare enum PipelineType {
19
19
  form = 'form',
20
20
  }
21
21
 
22
+ type Fetch = (url: string|Request, options?: RequestOptions) => Promise<Response>;
23
+
24
+ declare interface AccessConfig {
25
+ allow:(string|string[]);
26
+ }
27
+
22
28
  declare interface HelixConfigAll {
23
29
  host:string;
24
30
  routes:RegExp[];
31
+ access?:AccessConfig;
25
32
  [string]:any;
26
33
  }
27
34
 
@@ -29,6 +36,7 @@ declare interface PipelineOptions {
29
36
  log: Console;
30
37
  s3Loader: S3Loader;
31
38
  messageDispatcher: FormsMessageDispatcher;
39
+ fetch: Fetch;
32
40
  owner: string;
33
41
  repo: string;
34
42
  ref: string;
@@ -46,6 +54,13 @@ declare class PipelineState {
46
54
  contentBusId: string;
47
55
  s3Loader: S3Loader;
48
56
  messageDispatcher: FormsMessageDispatcher;
57
+ fetch: Fetch;
58
+
59
+ /**
60
+ * Returns the external link representation for authentication related redirects and cookies.
61
+ * This is only used for local testing and is an identity operation in production.
62
+ */
63
+ createExternalLocation(value:string): string;
49
64
 
50
65
  /**
51
66
  * Content bus partition
@@ -98,5 +113,10 @@ declare class PipelineState {
98
113
  * pipeline type. 'html', 'json', 'forms'
99
114
  */
100
115
  type: PipelineType;
116
+
117
+ /**
118
+ * Authentication information
119
+ */
120
+ authInfo?: AuthInfo;
101
121
  }
102
122
 
@@ -40,8 +40,15 @@ export class PipelineState {
40
40
  config: {},
41
41
  s3Loader: opts.s3Loader,
42
42
  messageDispatcher: opts.messageDispatcher,
43
+ fetch: opts.fetch,
43
44
  timer: opts.timer,
44
45
  type: 'html',
46
+ authInfo: undefined,
45
47
  });
46
48
  }
49
+
50
+ // eslint-disable-next-line class-methods-use-this
51
+ createExternalLocation(value) {
52
+ return value;
53
+ }
47
54
  }
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/html-pipe.js CHANGED
@@ -10,6 +10,7 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
  import { cleanupHeaderValue } from '@adobe/helix-shared-utils';
13
+ import { authenticate } from './steps/authenticate.js';
13
14
  import addHeadingIds from './steps/add-heading-ids.js';
14
15
  import createPageBlocks from './steps/create-page-blocks.js';
15
16
  import createPictures from './steps/create-pictures.js';
@@ -35,6 +36,7 @@ import tohtml from './steps/stringify-response.js';
35
36
  import { PipelineStatusError } from './PipelineStatusError.js';
36
37
  import { PipelineResponse } from './PipelineResponse.js';
37
38
  import { validatePathInfo } from './utils/path.js';
39
+ import { initAuthRoute } from './utils/auth.js';
38
40
 
39
41
  /**
40
42
  * Runs the default pipeline and returns the response.
@@ -62,6 +64,19 @@ export async function htmlPipe(state, req) {
62
64
  },
63
65
  });
64
66
 
67
+ // check if .auth request
68
+ if (state.partition === '.auth' || state.info.path === '/.auth') {
69
+ if (!initAuthRoute(state, req, res)) {
70
+ return res;
71
+ }
72
+ }
73
+
74
+ if (!state.contentBusId) {
75
+ res.status = 400;
76
+ res.headers.set('x-error', 'contentBusId missing');
77
+ return res;
78
+ }
79
+
65
80
  try { // fetch config first, since we need to compute the content-bus-id from the fstab ...
66
81
  state.timer?.update('config-fetch');
67
82
  await fetchConfig(state, req, res);
@@ -76,9 +91,12 @@ export async function htmlPipe(state, req) {
76
91
  fetchContent(state, req, res),
77
92
  ]);
78
93
 
94
+ await authenticate(state, req, res);
95
+
79
96
  if (res.error) {
80
97
  // if content loading produced an error, we're done.
81
- log.error(`error running pipeline: ${res.status} ${res.error}`);
98
+ const level = res.status >= 500 ? 'error' : 'info';
99
+ log[level](`pipeline status: ${res.status} ${res.error}`);
82
100
  res.headers.set('x-error', cleanupHeaderValue(res.error));
83
101
  return res;
84
102
  }
@@ -116,7 +134,9 @@ export async function htmlPipe(state, req) {
116
134
  } else {
117
135
  res.status = 500;
118
136
  }
119
- log.error(`error running pipeline: ${res.status} ${res.error}`, e);
137
+
138
+ const level = res.status >= 500 ? 'error' : 'info';
139
+ log[level](`pipeline status: ${res.status} ${res.error}`, e);
120
140
  res.headers.set('x-error', cleanupHeaderValue(res.error));
121
141
 
122
142
  // turn any URL errors into a 400, since they are user input
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;
@@ -0,0 +1,83 @@
1
+ /*
2
+ * Copyright 2022 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import { getAuthInfo } from '../utils/auth.js';
13
+
14
+ /**
15
+ * Checks if the given email is allowed.
16
+ * @param {string} email
17
+ * @param {string[]} allows
18
+ * @returns {boolean}
19
+ */
20
+ export function isAllowed(email = '', allows = []) {
21
+ /** @type string[] */
22
+ const [, domain] = email.split('@');
23
+ if (!domain) {
24
+ return false;
25
+ }
26
+ const wild = `*@${domain}`;
27
+ return allows.findIndex((a) => a === email || a === wild) >= 0;
28
+ }
29
+
30
+ /**
31
+ * Handles authentication
32
+ * @type PipelineStep
33
+ * @param {PipelineState} state
34
+ * @param {PipelineRequest} req
35
+ * @param {PipelineResponse} res
36
+ * @returns {Promise<void>}
37
+ */
38
+ export async function authenticate(state, req, res) {
39
+ // get auth info
40
+ const authInfo = await getAuthInfo(state, req);
41
+
42
+ // check if `.auth` route to validate and exchange token
43
+ if (state.info.path === '/.auth') {
44
+ await authInfo.exchangeToken(state, req, res);
45
+ return;
46
+ }
47
+
48
+ // if not protected, do nothing
49
+ if (!state.config?.access?.allow) {
50
+ return;
51
+ }
52
+
53
+ // if not authenticated, redirect to login screen
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
+ }
62
+ authInfo.redirectToLogin(state, req, res);
63
+ return;
64
+ }
65
+
66
+ // check profile is allowed
67
+ const { allow } = state.config.access;
68
+ const allows = Array.isArray(allow) ? allow : [allow];
69
+ if (!isAllowed(authInfo.profile.email || authInfo.profile.preferred_username, allows)) {
70
+ state.log.warn(`[auth] profile not allowed for ${allows}`);
71
+ res.status = 403;
72
+ res.error = 'forbidden.';
73
+ }
74
+
75
+ // set some response headers
76
+ res.headers.set('x-hlx-auth-allow', allows.join(','));
77
+ if (authInfo.profile) {
78
+ res.headers.set('x-hlx-auth-iss', authInfo.profile.iss);
79
+ res.headers.set('x-hlx-auth-kid', authInfo.profile.kid);
80
+ res.headers.set('x-hlx-auth-aud', authInfo.profile.aud);
81
+ res.headers.set('x-hlx-auth-jwk', JSON.stringify(authInfo.profile.jwk));
82
+ }
83
+ }
@@ -23,6 +23,9 @@ export default async function fetchContent(state, req, res) {
23
23
  const {
24
24
  log, contentBusId, info, partition, owner, repo, ref,
25
25
  } = state;
26
+ if (info.resourcePath === '/.auth') {
27
+ return;
28
+ }
26
29
 
27
30
  const isCode = state.content.sourceBus === 'code';
28
31
  const key = isCode
@@ -0,0 +1,41 @@
1
+ /*
2
+ * Copyright 2022 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ import { parse, serialize } from 'cookie';
13
+
14
+ export function clearAuthCookie() {
15
+ return serialize('hlx-auth-token', '', {
16
+ path: '/',
17
+ httpOnly: true,
18
+ secure: true,
19
+ expires: new Date(0),
20
+ sameSite: 'lax',
21
+ });
22
+ }
23
+
24
+ export function setAuthCookie(idToken) {
25
+ return serialize('hlx-auth-token', idToken, {
26
+ path: '/',
27
+ httpOnly: true,
28
+ secure: true,
29
+ sameSite: 'lax',
30
+ });
31
+ }
32
+
33
+ export function getAuthCookie(req) {
34
+ // add cookies if not already present
35
+ if (!req.cookies) {
36
+ const hdr = req.headers.get('cookie');
37
+ // eslint-disable-next-line no-param-reassign
38
+ req.cookies = hdr ? parse(hdr) : {};
39
+ }
40
+ return req.cookies['hlx-auth-token'] || '';
41
+ }
@@ -0,0 +1,81 @@
1
+ /*
2
+ * Copyright 2022 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+
14
+ import {AdminContext} from "../index";
15
+
16
+ /**
17
+ * Path Info
18
+ */
19
+ export declare interface AccessDeniedError extends Error {}
20
+
21
+ export declare interface OAuthClientConfig {
22
+ clientID: string;
23
+ clientSecret: string;
24
+ }
25
+
26
+ export declare interface IDPConfig {
27
+ name:string;
28
+ scope:string;
29
+ mountType:string;
30
+ client(state: PipelineState):OAuthClientConfig;
31
+ validateIssuer?(issuer: string): boolean;
32
+ discoveryUrl:string;
33
+ loginPrompt:string;
34
+ discovery:any;
35
+ routes:AuthRoutes;
36
+ }
37
+
38
+ export declare interface UserProfile {
39
+ email:string;
40
+ // hlx_hash:string;
41
+ // picture:string;
42
+ iss:string;
43
+ }
44
+
45
+ export declare class AuthInfo {
46
+ /**
47
+ * Flag indicating of the request is authenticated
48
+ */
49
+ authenticated:boolean;
50
+
51
+ profile?:UserProfile;
52
+
53
+ expired?:boolean;
54
+
55
+ loginHint?:string;
56
+
57
+ idp?:IDPConfig;
58
+
59
+ /**
60
+ * Flag indicating that the auth cookie is invalid.
61
+ */
62
+ cookieInvalid?:boolean;
63
+
64
+ /**
65
+ * Sets a redirect (302) response to the IDPs login endpoint
66
+ *
67
+ * @param {PipelineState} state
68
+ * @param {PipelineRequest} req
69
+ * @param {PipelineResponse} res
70
+ */
71
+ redirectToLogin(state, req, res);
72
+
73
+ /**
74
+ * Performs a token exchange from the code flow and redirects to the root page
75
+ *
76
+ * @param {PipelineState} state
77
+ * @param {PipelineRequest} req
78
+ * @param {PipelineResponse} res
79
+ */
80
+ async exchangeToken(state, req, res);
81
+ }
@@ -0,0 +1,400 @@
1
+ /*
2
+ * Copyright 2022 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ // eslint-disable-next-line max-classes-per-file
13
+ import {
14
+ createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify, UnsecuredJWT,
15
+ } from 'jose';
16
+ import { clearAuthCookie, getAuthCookie, setAuthCookie } from './auth-cookie.js';
17
+
18
+ import idpMicrosoft from './idp-configs/microsoft.js';
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
+
31
+ export const IDPS = [
32
+ idpMicrosoft,
33
+ ];
34
+
35
+ const AUTH_REDIRECT_URL = 'https://login.hlx.page/.auth';
36
+
37
+ export class AccessDeniedError extends Error {
38
+ }
39
+
40
+ /**
41
+ * Decodes the given id_token for the given idp. if `lenient` is `true`, the clock tolerance
42
+ * is set to 1 week. this allows to extract some profile information that can be used as login_hint.
43
+ * @param {PipelineState} state
44
+ * @param {IDPConfig} idp
45
+ * @param {string} idToken
46
+ * @param {boolean} lenient
47
+ * @returns {Promise<JWTPayload>}
48
+ */
49
+ export async function decodeIdToken(state, idp, idToken, lenient = false) {
50
+ const { log } = state;
51
+ const jwks = idp.discovery.jwks
52
+ ? createLocalJWKSet(idp.discovery.jwks)
53
+ : /* c8 ignore next */ createRemoteJWKSet(new URL(idp.discovery.jwks_uri));
54
+
55
+ const { payload, key, protectedHeader } = await jwtVerify(idToken, jwks, {
56
+ audience: idp.client(state).clientId,
57
+ clockTolerance: lenient ? 7 * 24 * 60 * 60 : 0,
58
+ });
59
+
60
+ // delete from information not needed in the profile
61
+ ['azp', 'sub', 'at_hash', 'nonce', 'aio', 'c_hash'].forEach((prop) => delete payload[prop]);
62
+
63
+ // compute ttl
64
+ payload.ttl = payload.exp - Math.floor(Date.now() / 1000);
65
+
66
+ // export the public key
67
+ payload.jwk = key.export({
68
+ type: 'pkcs1',
69
+ format: 'jwk',
70
+ });
71
+ payload.kid = protectedHeader.kid;
72
+
73
+ log.info(`[auth] decoded id_token${lenient ? ' (lenient)' : ''} from ${payload.iss} and validated payload.`);
74
+ return payload;
75
+ }
76
+
77
+ /**
78
+ * AuthInfo class
79
+ */
80
+ export class AuthInfo {
81
+ /**
82
+ * AuthInfo constructor
83
+ * @constructor
84
+ */
85
+ constructor() {
86
+ Object.assign(this, {
87
+ authenticated: false,
88
+ idp: null,
89
+ profile: null,
90
+ loginHint: null,
91
+ expired: false,
92
+ idToken: null,
93
+ cookieInvalid: false,
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Creates the default AuthInfo that is not authenticated.
99
+ * @returns {AuthInfo}
100
+ */
101
+ static Default() {
102
+ return new AuthInfo()
103
+ .withAuthenticated(false);
104
+ }
105
+
106
+ withAuthenticated(value) {
107
+ this.authenticated = value;
108
+ return this;
109
+ }
110
+
111
+ withProfile(profile) {
112
+ this.profile = profile;
113
+ return this;
114
+ }
115
+
116
+ withLoginHint(value) {
117
+ this.loginHint = value;
118
+ return this;
119
+ }
120
+
121
+ withIdp(value) {
122
+ this.idp = value;
123
+ return this;
124
+ }
125
+
126
+ withExpired(value) {
127
+ this.expired = value;
128
+ return this;
129
+ }
130
+
131
+ withCookieInvalid(value) {
132
+ this.cookieInvalid = value;
133
+ return this;
134
+ }
135
+
136
+ withIdToken(value) {
137
+ this.idToken = value;
138
+ return this;
139
+ }
140
+
141
+ /**
142
+ * Sets a redirect (302) response to the IDPs login endpoint
143
+ *
144
+ * @param {PipelineState} state
145
+ * @param {PipelineRequest} req
146
+ * @param {PipelineResponse} res
147
+ * @param {IDPConfig} idp IDP config
148
+ */
149
+ redirectToLogin(state, req, res) {
150
+ const { log } = state;
151
+ const { idp } = this;
152
+
153
+ const { clientId, clientSecret } = idp.client(state);
154
+ if (!clientId || !clientSecret) {
155
+ log.error('[auth] unable to create login redirect: missing client_id or client_secret');
156
+ res.status = 500;
157
+ res.error = 'invalid auth config.';
158
+ return;
159
+ }
160
+
161
+ // determine the location of 'this' document based on the xfh header. so that logins to
162
+ // .page stay on .page. etc. but fallback to the config.host if non set
163
+ let host = req.headers.get('x-forwarded-host');
164
+ if (host) {
165
+ host = host.split(',')[0].trim();
166
+ }
167
+ if (!host) {
168
+ host = state.config.host;
169
+ }
170
+ if (!host) {
171
+ log.error('[auth] unable to create login redirect: no xfh or config.host.');
172
+ res.status = 401;
173
+ res.error = 'no host information.';
174
+ return;
175
+ }
176
+
177
+ const url = new URL(idp.discovery.authorization_endpoint);
178
+
179
+ // todo: properly sign to avoid CSRF
180
+ const tokenState = new UnsecuredJWT({
181
+ owner: state.owner,
182
+ repo: state.repo,
183
+ contentBusId: state.contentBusId,
184
+ // this is our own login redirect, i.e. the current document
185
+ requestPath: state.info.path,
186
+ requestHost: host,
187
+ }).encode();
188
+
189
+ url.searchParams.append('client_id', clientId);
190
+ url.searchParams.append('response_type', 'code');
191
+ url.searchParams.append('scope', idp.scope);
192
+ url.searchParams.append('nonce', cryptoImpl.randomUUID());
193
+ url.searchParams.append('state', tokenState);
194
+ url.searchParams.append('redirect_uri', state.createExternalLocation(AUTH_REDIRECT_URL));
195
+ url.searchParams.append('prompt', 'select_account');
196
+
197
+ log.info('[auth] redirecting to login page', url.href);
198
+ res.status = 302;
199
+ res.body = '';
200
+ res.headers.set('location', url.href);
201
+ res.headers.set('set-cookie', clearAuthCookie());
202
+ res.headers.set('cache-control', 'no-store, private, must-revalidate');
203
+ res.error = 'moved';
204
+ }
205
+
206
+ /**
207
+ * Performs a token exchange from the code flow and redirects to the root page
208
+ *
209
+ * @param {PipelineState} state
210
+ * @param {PipelineRequest} req
211
+ * @param {PipelineResponse} res
212
+ */
213
+ async exchangeToken(state, req, res) {
214
+ const { log } = state;
215
+ const { idp } = this;
216
+
217
+ const { code } = req.params;
218
+ if (!code) {
219
+ log.warn('[auth] code exchange failed: code parameter missing.');
220
+ res.status = 401;
221
+ res.error = 'code exchange failed.';
222
+ return;
223
+ }
224
+
225
+ // ensure that the request is made to the target host
226
+ if (req.params.state?.requestHost) {
227
+ const host = req.headers.get('x-forwarded-host') || state.config.host;
228
+ if (host !== req.params.state.requestHost) {
229
+ const url = new URL(`https://${req.params.state.requestHost}/.auth`);
230
+ url.searchParams.append('state', req.params.rawState);
231
+ url.searchParams.append('code', req.params.code);
232
+ const location = state.createExternalLocation(url.href);
233
+ log.info('[auth] redirecting to initial host', location);
234
+ res.status = 302;
235
+ res.body = `please go to <a href="${location}">${location}</a>`;
236
+ res.headers.set('location', location);
237
+ res.headers.set('cache-control', 'no-store, private, must-revalidate');
238
+ res.error = 'moved';
239
+ return;
240
+ }
241
+ }
242
+
243
+ const { clientId, clientSecret } = idp.client(state);
244
+ const url = new URL(idp.discovery.token_endpoint);
245
+ const body = {
246
+ client_id: clientId,
247
+ client_secret: clientSecret,
248
+ code,
249
+ grant_type: 'authorization_code',
250
+ redirect_uri: state.createExternalLocation(AUTH_REDIRECT_URL),
251
+ };
252
+ const ret = await state.fetch(url.href, {
253
+ method: 'POST',
254
+ body: new URLSearchParams(body).toString(),
255
+ headers: {
256
+ 'content-type': 'application/x-www-form-urlencoded',
257
+ },
258
+ });
259
+ if (!ret.ok) {
260
+ log.warn(`[auth] code exchange failed: ${ret.status}`, await ret.text());
261
+ res.status = 401;
262
+ res.error = 'code exchange failed.';
263
+ return;
264
+ }
265
+
266
+ const tokenResponse = await ret.json();
267
+ const { id_token: idToken } = tokenResponse;
268
+ try {
269
+ await decodeIdToken(state, idp, idToken);
270
+ } catch (e) {
271
+ log.warn(`[auth] id token from ${idp.name} is invalid: ${e.message}`);
272
+ res.status = 401;
273
+ res.error = 'id token invalid.';
274
+ return;
275
+ }
276
+
277
+ // ensure that auth cookie is not cleared again in `index.js`
278
+ // ctx.attributes.authInfo?.withCookieInvalid(false);
279
+
280
+ const location = state.createExternalLocation(req.params.state.requestPath || '/');
281
+ log.info('[auth] redirecting to home page with id_token cookie', location);
282
+ res.status = 302;
283
+ res.body = `please go to <a href="${location}">${location}</a>`;
284
+ res.headers.set('location', location);
285
+ res.headers.set('content-tye', 'text/plain');
286
+ res.headers.set('set-cookie', setAuthCookie(idToken));
287
+ res.headers.set('cache-control', 'no-store, private, must-revalidate');
288
+ res.error = 'moved';
289
+ }
290
+ }
291
+
292
+ export function initAuthRoute(state, req, res) {
293
+ const { log } = state;
294
+
295
+ // use request headers if present
296
+ if (req.headers.get('x-hlx-auth-state')) {
297
+ log.info('[auth] override params.state from header.');
298
+ req.params.state = req.headers.get('x-hlx-auth-state');
299
+ }
300
+ if (req.headers.get('x-hlx-auth-code')) {
301
+ log.info('[auth] override params.code from header.');
302
+ req.params.code = req.headers.get('x-hlx-auth-code');
303
+ }
304
+
305
+ if (!req.params.state) {
306
+ log.warn('[auth] unable to exchange token: no state.');
307
+ res.status = 401;
308
+ res.headers.set('x-error', 'missing state parameter.');
309
+ return false;
310
+ }
311
+
312
+ try {
313
+ req.params.rawState = req.params.state;
314
+ req.params.state = decodeJwt(req.params.state);
315
+ } catch (e) {
316
+ log.warn(`[auth] error decoding state parameter: invalid state: ${e.message}`);
317
+ res.status = 401;
318
+ res.headers.set('x-error', 'missing state parameter.');
319
+ return false;
320
+ }
321
+
322
+ // fixup pipeline state
323
+ state.owner = req.params.state.owner;
324
+ state.repo = req.params.state.repo;
325
+ state.ref = 'main';
326
+ state.contentBusId = req.params.state.contentBusId;
327
+ state.partition = 'preview';
328
+ state.info.path = '/.auth';
329
+ return true;
330
+ }
331
+
332
+ /**
333
+ * Extracts the authentication info from the cookie. Returns {@code null} if missing or invalid.
334
+ *
335
+ * @param {PipelineState} state
336
+ * @param {PipelineRequest} req
337
+ * @returns {Promise<AuthInfo>} the authentication info or null if the request is not authenticated
338
+ */
339
+ async function getAuthInfoFromCookie(state, req) {
340
+ const { log } = state;
341
+ const idToken = getAuthCookie(req);
342
+ if (idToken) {
343
+ let idp;
344
+ try {
345
+ const { iss } = decodeJwt(idToken);
346
+ if (!iss) {
347
+ log.warn('[auth] missing \'iss\' claim in id_token.');
348
+ return AuthInfo.Default().withCookieInvalid(true);
349
+ }
350
+ idp = IDPS.find((i) => i.validateIssuer(iss));
351
+ if (!idp) {
352
+ log.warn(`[auth] no IDP found for: ${iss}`);
353
+ return AuthInfo.Default().withCookieInvalid(true);
354
+ }
355
+ return AuthInfo.Default()
356
+ .withProfile(await decodeIdToken(state, idp, idToken))
357
+ .withAuthenticated(true)
358
+ .withIdp(idp)
359
+ .withIdToken(idToken);
360
+ } catch (e) {
361
+ if (e.code === 'ERR_JWT_EXPIRED' && idp) {
362
+ try {
363
+ const profile = await decodeIdToken(state, idp, idToken, true);
364
+ log.warn(`[auth] decoding the id_token failed: ${e.message}, using expired token as hint.`);
365
+ return AuthInfo.Default()
366
+ .withExpired(true)
367
+ .withIdp(idp)
368
+ .withLoginHint(profile.email);
369
+ } catch {
370
+ // ignore
371
+ }
372
+ }
373
+ // wrong token
374
+ log.warn(`[auth] decoding the id_token failed: ${e.message}.`);
375
+ return AuthInfo.Default().withCookieInvalid(true);
376
+ }
377
+ }
378
+ return null;
379
+ }
380
+
381
+ /**
382
+ * Computes the authentication info.
383
+ * @param {PipelineState} state
384
+ * @param {PipelineRequest} req
385
+ * @returns {Promise<AuthInfo>} the authentication info or null if the request is not authenticated
386
+ */
387
+ export async function getAuthInfo(state, req) {
388
+ const { log } = state;
389
+ const auth = await getAuthInfoFromCookie(state, req);
390
+ if (auth) {
391
+ if (auth.authenticated) {
392
+ log.info(`[auth] id-token valid: iss=${auth.profile.iss}`);
393
+ }
394
+ return auth;
395
+ }
396
+ return AuthInfo
397
+ .Default()
398
+ // todo: select idp from config
399
+ .withIdp(idpMicrosoft);
400
+ }
@@ -0,0 +1,35 @@
1
+ /*
2
+ * Copyright 2022 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+ export default {
13
+ name: 'microsoft',
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,
18
+ }),
19
+ scope: 'openid profile email',
20
+ validateIssuer: (iss) => iss.startsWith('https://login.microsoftonline.com/'),
21
+ discoveryUrl: 'https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration',
22
+ // todo: fetch from discovery document
23
+ discovery: {
24
+ issuer: 'https://login.microsoftonline.com/{tenantid}/v2.0',
25
+ request_uri_parameter_supported: false,
26
+ token_endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/token',
27
+ userinfo_endpoint: 'https://graph.microsoft.com/oidc/userinfo',
28
+ authorization_endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
29
+ device_authorization_endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/devicecode',
30
+ http_logout_supported: true,
31
+ frontchannel_logout_supported: true,
32
+ end_session_endpoint: 'https://login.microsoftonline.com/common/oauth2/v2.0/logout',
33
+ jwks_uri: 'https://login.microsoftonline.com/common/discovery/v2.0/keys',
34
+ },
35
+ };