@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 +21 -0
- package/package.json +4 -4
- package/src/PipelineState.d.ts +2 -0
- package/src/PipelineState.js +1 -0
- package/src/forms-pipe.js +5 -0
- package/src/json-pipe.js +2 -0
- package/src/steps/authenticate.js +9 -4
- package/src/utils/auth.js +62 -13
- package/src/utils/idp-configs/microsoft.js +3 -3
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.
|
|
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.
|
|
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": "
|
|
85
|
+
"jsdom": "20.0.0",
|
|
86
86
|
"junit-report-builder": "3.0.0",
|
|
87
|
-
"lint-staged": "13.0.
|
|
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",
|
package/src/PipelineState.d.ts
CHANGED
|
@@ -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;
|
package/src/PipelineState.js
CHANGED
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
|
|
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-
|
|
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.
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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',
|
|
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
|
|
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
|
|
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
|
|
356
|
+
async function getAuthInfoFromCookieOrHeader(state, req) {
|
|
318
357
|
const { log } = state;
|
|
319
|
-
|
|
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
|
|
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:
|
|
17
|
-
clientSecret:
|
|
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/'),
|