@adobe/helix-html-pipeline 2.1.0 → 3.0.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 +26 -0
- package/package.json +7 -5
- package/src/PipelineRequest.d.ts +1 -0
- package/src/PipelineRequest.js +26 -1
- package/src/PipelineState.d.ts +20 -0
- package/src/PipelineState.js +7 -0
- package/src/html-pipe.js +22 -2
- package/src/steps/authenticate.js +78 -0
- package/src/steps/create-pictures.js +3 -5
- package/src/steps/fetch-content.js +3 -0
- package/src/steps/set-custom-response-headers.js +2 -2
- package/src/utils/auth-cookie.js +41 -0
- package/src/utils/auth.d.ts +81 -0
- package/src/utils/auth.js +378 -0
- package/src/utils/idp-configs/microsoft.js +35 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
+
# [3.0.0](https://github.com/adobe/helix-html-pipeline/compare/v2.1.2...v3.0.0) (2022-06-14)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Features
|
|
5
|
+
|
|
6
|
+
* add site access control ([#80](https://github.com/adobe/helix-html-pipeline/issues/80)) ([2109d90](https://github.com/adobe/helix-html-pipeline/commit/2109d90a932b75a9de6996da2854d349613541b9))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### BREAKING CHANGES
|
|
10
|
+
|
|
11
|
+
* PipelineState now need to implement fetch() and createExternalLocation()
|
|
12
|
+
|
|
13
|
+
## [2.1.2](https://github.com/adobe/helix-html-pipeline/compare/v2.1.1...v2.1.2) (2022-06-04)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
### Bug Fixes
|
|
17
|
+
|
|
18
|
+
* **deps:** update dependency @adobe/helix-shared-utils to v2.0.11 ([811287b](https://github.com/adobe/helix-html-pipeline/commit/811287bbf168ee7ae0a305d958d7ca84ec1a75ca))
|
|
19
|
+
|
|
20
|
+
## [2.1.1](https://github.com/adobe/helix-html-pipeline/compare/v2.1.0...v2.1.1) (2022-06-02)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
### Bug Fixes
|
|
24
|
+
|
|
25
|
+
* Lazy images ([#73](https://github.com/adobe/helix-html-pipeline/issues/73)) ([7442a5e](https://github.com/adobe/helix-html-pipeline/commit/7442a5e0720699d974332a2f5659fdddd27b0dc6))
|
|
26
|
+
|
|
1
27
|
# [2.1.0](https://github.com/adobe/helix-html-pipeline/compare/v2.0.4...v2.1.0) (2022-06-02)
|
|
2
28
|
|
|
3
29
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe/helix-html-pipeline",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "Helix HTML Pipeline",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"types": "src/index.d.ts",
|
|
@@ -34,13 +34,15 @@
|
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@adobe/helix-markdown-support": "3.1.6",
|
|
37
|
-
"@adobe/helix-shared-utils": "2.0.
|
|
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",
|
|
@@ -73,7 +75,7 @@
|
|
|
73
75
|
"@semantic-release/git": "10.0.1",
|
|
74
76
|
"@semantic-release/npm": "9.0.1",
|
|
75
77
|
"c8": "7.11.3",
|
|
76
|
-
"eslint": "8.
|
|
78
|
+
"eslint": "8.17.0",
|
|
77
79
|
"eslint-plugin-header": "3.1.1",
|
|
78
80
|
"eslint-plugin-import": "2.26.0",
|
|
79
81
|
"esmock": "1.7.5",
|
|
@@ -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": "
|
|
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.
|
|
91
|
+
"semantic-release": "19.0.3"
|
|
90
92
|
},
|
|
91
93
|
"lint-staged": {
|
|
92
94
|
"*.js": "eslint",
|
package/src/PipelineRequest.d.ts
CHANGED
package/src/PipelineRequest.js
CHANGED
|
@@ -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
|
}
|
package/src/PipelineState.d.ts
CHANGED
|
@@ -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
|
|
package/src/PipelineState.js
CHANGED
|
@@ -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/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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -0,0 +1,78 @@
|
|
|
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, res);
|
|
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
|
+
authInfo.redirectToLogin(state, req, res);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// console.log(authInfo.profile);
|
|
60
|
+
|
|
61
|
+
// check profile is allowed
|
|
62
|
+
const { allow } = state.config.access;
|
|
63
|
+
const allows = Array.isArray(allow) ? allow : [allow];
|
|
64
|
+
if (!isAllowed(authInfo.profile.email || authInfo.profile.preferred_username, allows)) {
|
|
65
|
+
state.log.warn(`[auth] profile not allowed for ${allows}`);
|
|
66
|
+
res.status = 403;
|
|
67
|
+
res.error = 'forbidden.';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// set some response headers
|
|
71
|
+
res.headers.set('x-hlx-auth-allow', allows.join(','));
|
|
72
|
+
if (authInfo.profile) {
|
|
73
|
+
res.headers.set('x-hlx-auth-iss', authInfo.profile.iss);
|
|
74
|
+
res.headers.set('x-hlx-auth-kid', authInfo.profile.kid);
|
|
75
|
+
res.headers.set('x-hlx-auth-aud', authInfo.profile.aud);
|
|
76
|
+
res.headers.set('x-hlx-auth-jwk', JSON.stringify(authInfo.profile.jwk));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -18,7 +18,7 @@ const BREAK_POINTS = [
|
|
|
18
18
|
{ width: '750' },
|
|
19
19
|
];
|
|
20
20
|
|
|
21
|
-
export function createOptimizedPicture(src, alt = ''
|
|
21
|
+
export function createOptimizedPicture(src, alt = '') {
|
|
22
22
|
const url = new URL(src, 'https://localhost/');
|
|
23
23
|
const { pathname, hash = '' } = url;
|
|
24
24
|
const props = new URLSearchParams(hash.substring(1));
|
|
@@ -53,7 +53,7 @@ export function createOptimizedPicture(src, alt = '', eager = false) {
|
|
|
53
53
|
});
|
|
54
54
|
}
|
|
55
55
|
return h('img', {
|
|
56
|
-
loading:
|
|
56
|
+
loading: 'lazy',
|
|
57
57
|
alt,
|
|
58
58
|
type: v.type,
|
|
59
59
|
src: srcset,
|
|
@@ -77,11 +77,9 @@ function isMediaImage(node) {
|
|
|
77
77
|
export default async function createPictures({ content }) {
|
|
78
78
|
const { hast } = content;
|
|
79
79
|
|
|
80
|
-
let first = true;
|
|
81
80
|
visitParents(hast, isMediaImage, (img, parents) => {
|
|
82
81
|
const { src, alt } = img.properties;
|
|
83
|
-
const picture = createOptimizedPicture(src, alt
|
|
84
|
-
first = false;
|
|
82
|
+
const picture = createOptimizedPicture(src, alt);
|
|
85
83
|
|
|
86
84
|
// check if parent has style and unwrap if needed
|
|
87
85
|
const parent = parents[parents.length - 1];
|
|
@@ -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
|
|
@@ -21,8 +21,8 @@ import { cleanupHeaderValue } from '@adobe/helix-shared-utils';
|
|
|
21
21
|
*/
|
|
22
22
|
export default function setCustomResponseHeaders(state, req, res) {
|
|
23
23
|
Object.entries(state.headers.getModifiers(state.info.path)).forEach(([name, value]) => {
|
|
24
|
-
//
|
|
25
|
-
if (name !== 'link' || state.type === 'html') {
|
|
24
|
+
// only use `link` header for extensionless pipeline
|
|
25
|
+
if (name !== 'link' || (state.type === 'html' && state.info.selector === '')) {
|
|
26
26
|
res.headers.set(name, cleanupHeaderValue(value));
|
|
27
27
|
}
|
|
28
28
|
});
|
|
@@ -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,378 @@
|
|
|
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 crypto from 'crypto';
|
|
14
|
+
import {
|
|
15
|
+
createLocalJWKSet, createRemoteJWKSet, decodeJwt, jwtVerify, UnsecuredJWT,
|
|
16
|
+
} from 'jose';
|
|
17
|
+
import { clearAuthCookie, getAuthCookie, setAuthCookie } from './auth-cookie.js';
|
|
18
|
+
|
|
19
|
+
import idpMicrosoft from './idp-configs/microsoft.js';
|
|
20
|
+
|
|
21
|
+
export const IDPS = [
|
|
22
|
+
idpMicrosoft,
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
const AUTH_REDIRECT_URL = 'https://login.hlx.page/.auth';
|
|
26
|
+
|
|
27
|
+
export class AccessDeniedError extends Error {
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decodes the given id_token for the given idp. if `lenient` is `true`, the clock tolerance
|
|
32
|
+
* is set to 1 week. this allows to extract some profile information that can be used as login_hint.
|
|
33
|
+
* @param {PipelineState} state
|
|
34
|
+
* @param {IDPConfig} idp
|
|
35
|
+
* @param {string} idToken
|
|
36
|
+
* @param {boolean} lenient
|
|
37
|
+
* @returns {Promise<JWTPayload>}
|
|
38
|
+
*/
|
|
39
|
+
export async function decodeIdToken(state, idp, idToken, lenient = false) {
|
|
40
|
+
const { log } = state;
|
|
41
|
+
const jwks = idp.discovery.jwks
|
|
42
|
+
? createLocalJWKSet(idp.discovery.jwks)
|
|
43
|
+
: /* c8 ignore next */ createRemoteJWKSet(new URL(idp.discovery.jwks_uri));
|
|
44
|
+
|
|
45
|
+
const { payload, key, protectedHeader } = await jwtVerify(idToken, jwks, {
|
|
46
|
+
audience: idp.client(state).clientId,
|
|
47
|
+
clockTolerance: lenient ? 7 * 24 * 60 * 60 : 0,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// delete from information not needed in the profile
|
|
51
|
+
['azp', 'sub', 'at_hash', 'nonce', 'aio', 'c_hash'].forEach((prop) => delete payload[prop]);
|
|
52
|
+
|
|
53
|
+
// compute ttl
|
|
54
|
+
payload.ttl = payload.exp - Math.floor(Date.now() / 1000);
|
|
55
|
+
|
|
56
|
+
// export the public key
|
|
57
|
+
payload.jwk = key.export({
|
|
58
|
+
type: 'pkcs1',
|
|
59
|
+
format: 'jwk',
|
|
60
|
+
});
|
|
61
|
+
payload.kid = protectedHeader.kid;
|
|
62
|
+
|
|
63
|
+
log.info(`[auth] decoded id_token${lenient ? ' (lenient)' : ''} from ${payload.iss} and validated payload.`);
|
|
64
|
+
return payload;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* AuthInfo class
|
|
69
|
+
*/
|
|
70
|
+
export class AuthInfo {
|
|
71
|
+
/**
|
|
72
|
+
* AuthInfo constructor
|
|
73
|
+
* @constructor
|
|
74
|
+
*/
|
|
75
|
+
constructor() {
|
|
76
|
+
Object.assign(this, {
|
|
77
|
+
authenticated: false,
|
|
78
|
+
idp: null,
|
|
79
|
+
profile: null,
|
|
80
|
+
loginHint: null,
|
|
81
|
+
expired: false,
|
|
82
|
+
idToken: null,
|
|
83
|
+
cookieInvalid: false,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Creates the default AuthInfo that is not authenticated.
|
|
89
|
+
* @returns {AuthInfo}
|
|
90
|
+
*/
|
|
91
|
+
static Default() {
|
|
92
|
+
return new AuthInfo()
|
|
93
|
+
.withAuthenticated(false);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
withAuthenticated(value) {
|
|
97
|
+
this.authenticated = value;
|
|
98
|
+
return this;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
withProfile(profile) {
|
|
102
|
+
this.profile = profile;
|
|
103
|
+
return this;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
withLoginHint(value) {
|
|
107
|
+
this.loginHint = value;
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
withIdp(value) {
|
|
112
|
+
this.idp = value;
|
|
113
|
+
return this;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
withExpired(value) {
|
|
117
|
+
this.expired = value;
|
|
118
|
+
return this;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
withCookieInvalid(value) {
|
|
122
|
+
this.cookieInvalid = value;
|
|
123
|
+
return this;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
withIdToken(value) {
|
|
127
|
+
this.idToken = value;
|
|
128
|
+
return this;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Sets a redirect (302) response to the IDPs login endpoint
|
|
133
|
+
*
|
|
134
|
+
* @param {PipelineState} state
|
|
135
|
+
* @param {PipelineRequest} req
|
|
136
|
+
* @param {PipelineResponse} res
|
|
137
|
+
* @param {IDPConfig} idp IDP config
|
|
138
|
+
*/
|
|
139
|
+
redirectToLogin(state, req, res) {
|
|
140
|
+
const { log } = state;
|
|
141
|
+
const { idp } = this;
|
|
142
|
+
|
|
143
|
+
const { clientId, clientSecret } = idp.client(state);
|
|
144
|
+
if (!clientId || !clientSecret) {
|
|
145
|
+
log.error('[auth] unable to create login redirect: missing client_id or client_secret');
|
|
146
|
+
res.status = 500;
|
|
147
|
+
res.error = 'invalid auth config.';
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// determine the location of 'this' document based on the xfh header. so that logins to
|
|
152
|
+
// .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;
|
|
154
|
+
|
|
155
|
+
const url = new URL(idp.discovery.authorization_endpoint);
|
|
156
|
+
|
|
157
|
+
// todo: properly sign to avoid CSRF
|
|
158
|
+
const tokenState = new UnsecuredJWT({
|
|
159
|
+
owner: state.owner,
|
|
160
|
+
repo: state.repo,
|
|
161
|
+
contentBusId: state.contentBusId,
|
|
162
|
+
// this is our own login redirect, i.e. the current document
|
|
163
|
+
requestPath: state.info.path,
|
|
164
|
+
requestHost: host,
|
|
165
|
+
}).encode();
|
|
166
|
+
|
|
167
|
+
url.searchParams.append('client_id', clientId);
|
|
168
|
+
url.searchParams.append('response_type', 'code');
|
|
169
|
+
url.searchParams.append('scope', idp.scope);
|
|
170
|
+
url.searchParams.append('nonce', crypto.randomUUID());
|
|
171
|
+
url.searchParams.append('state', tokenState);
|
|
172
|
+
url.searchParams.append('redirect_uri', state.createExternalLocation(AUTH_REDIRECT_URL));
|
|
173
|
+
url.searchParams.append('prompt', 'select_account');
|
|
174
|
+
|
|
175
|
+
log.info('[auth] redirecting to login page', url.href);
|
|
176
|
+
res.status = 302;
|
|
177
|
+
res.body = '';
|
|
178
|
+
res.headers.set('location', url.href);
|
|
179
|
+
res.headers.set('set-cookie', clearAuthCookie());
|
|
180
|
+
res.headers.set('cache-control', 'no-store, private, must-revalidate');
|
|
181
|
+
res.error = 'moved';
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Performs a token exchange from the code flow and redirects to the root page
|
|
186
|
+
*
|
|
187
|
+
* @param {PipelineState} state
|
|
188
|
+
* @param {PipelineRequest} req
|
|
189
|
+
* @param {PipelineResponse} res
|
|
190
|
+
*/
|
|
191
|
+
async exchangeToken(state, req, res) {
|
|
192
|
+
const { log } = state;
|
|
193
|
+
const { idp } = this;
|
|
194
|
+
|
|
195
|
+
const { code } = req.params;
|
|
196
|
+
if (!code) {
|
|
197
|
+
log.warn('[auth] code exchange failed: code parameter missing.');
|
|
198
|
+
res.status = 401;
|
|
199
|
+
res.error = 'code exchange failed.';
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ensure that the request is made to the target host
|
|
204
|
+
if (req.params.state?.requestHost) {
|
|
205
|
+
const host = req.headers.get('x-forwarded-host') || state.config.host;
|
|
206
|
+
if (host !== req.params.state.requestHost) {
|
|
207
|
+
const url = new URL(`https://${req.params.state.requestHost}/.auth`);
|
|
208
|
+
url.searchParams.append('state', req.params.rawState);
|
|
209
|
+
url.searchParams.append('code', req.params.code);
|
|
210
|
+
const location = state.createExternalLocation(url.href);
|
|
211
|
+
log.info('[auth] redirecting to initial host', location);
|
|
212
|
+
res.status = 302;
|
|
213
|
+
res.body = `please go to <a href="${location}">${location}</a>`;
|
|
214
|
+
res.headers.set('location', location);
|
|
215
|
+
res.headers.set('cache-control', 'no-store, private, must-revalidate');
|
|
216
|
+
res.error = 'moved';
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const { clientId, clientSecret } = idp.client(state);
|
|
222
|
+
const url = new URL(idp.discovery.token_endpoint);
|
|
223
|
+
const body = {
|
|
224
|
+
client_id: clientId,
|
|
225
|
+
client_secret: clientSecret,
|
|
226
|
+
code,
|
|
227
|
+
grant_type: 'authorization_code',
|
|
228
|
+
redirect_uri: state.createExternalLocation(AUTH_REDIRECT_URL),
|
|
229
|
+
};
|
|
230
|
+
const ret = await state.fetch(url.href, {
|
|
231
|
+
method: 'POST',
|
|
232
|
+
body: new URLSearchParams(body).toString(),
|
|
233
|
+
headers: {
|
|
234
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
if (!ret.ok) {
|
|
238
|
+
log.warn(`[auth] code exchange failed: ${ret.status}`, await ret.text());
|
|
239
|
+
res.status = 401;
|
|
240
|
+
res.error = 'code exchange failed.';
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const tokenResponse = await ret.json();
|
|
245
|
+
const { id_token: idToken } = tokenResponse;
|
|
246
|
+
try {
|
|
247
|
+
await decodeIdToken(state, idp, idToken);
|
|
248
|
+
} catch (e) {
|
|
249
|
+
log.warn(`[auth] id token from ${idp.name} is invalid: ${e.message}`);
|
|
250
|
+
res.status = 401;
|
|
251
|
+
res.error = 'id token invalid.';
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ensure that auth cookie is not cleared again in `index.js`
|
|
256
|
+
// ctx.attributes.authInfo?.withCookieInvalid(false);
|
|
257
|
+
|
|
258
|
+
const location = state.createExternalLocation(req.params.state.requestPath || '/');
|
|
259
|
+
log.info('[auth] redirecting to home page with id_token cookie', location);
|
|
260
|
+
res.status = 302;
|
|
261
|
+
res.body = `please go to <a href="${location}">${location}</a>`;
|
|
262
|
+
res.headers.set('location', location);
|
|
263
|
+
res.headers.set('content-tye', 'text/plain');
|
|
264
|
+
res.headers.set('set-cookie', setAuthCookie(idToken));
|
|
265
|
+
res.headers.set('cache-control', 'no-store, private, must-revalidate');
|
|
266
|
+
res.error = 'moved';
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export function initAuthRoute(state, req, res) {
|
|
271
|
+
const { log } = state;
|
|
272
|
+
|
|
273
|
+
// use request headers if present
|
|
274
|
+
if (req.headers.get('x-hlx-auth-state')) {
|
|
275
|
+
log.info('[auth] override params.state from header.');
|
|
276
|
+
req.params.state = req.headers.get('x-hlx-auth-state');
|
|
277
|
+
}
|
|
278
|
+
if (req.headers.get('x-hlx-auth-code')) {
|
|
279
|
+
log.info('[auth] override params.code from header.');
|
|
280
|
+
req.params.code = req.headers.get('x-hlx-auth-code');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (!req.params.state) {
|
|
284
|
+
log.warn('[auth] unable to exchange token: no state.');
|
|
285
|
+
res.status = 401;
|
|
286
|
+
res.headers.set('x-error', 'missing state parameter.');
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
req.params.rawState = req.params.state;
|
|
292
|
+
req.params.state = decodeJwt(req.params.state);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
log.warn(`[auth] error decoding state parameter: invalid state: ${e.message}`);
|
|
295
|
+
res.status = 401;
|
|
296
|
+
res.headers.set('x-error', 'missing state parameter.');
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// fixup pipeline state
|
|
301
|
+
state.owner = req.params.state.owner;
|
|
302
|
+
state.repo = req.params.state.repo;
|
|
303
|
+
state.ref = 'main';
|
|
304
|
+
state.contentBusId = req.params.state.contentBusId;
|
|
305
|
+
state.partition = 'preview';
|
|
306
|
+
state.info.path = '/.auth';
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Extracts the authentication info from the cookie. Returns {@code null} if missing or invalid.
|
|
312
|
+
*
|
|
313
|
+
* @param {PipelineState} state
|
|
314
|
+
* @param {PipelineRequest} req
|
|
315
|
+
* @returns {Promise<AuthInfo>} the authentication info or null if the request is not authenticated
|
|
316
|
+
*/
|
|
317
|
+
async function getAuthInfoFromCookie(state, req) {
|
|
318
|
+
const { log } = state;
|
|
319
|
+
const idToken = getAuthCookie(req);
|
|
320
|
+
if (idToken) {
|
|
321
|
+
let idp;
|
|
322
|
+
try {
|
|
323
|
+
const { iss } = decodeJwt(idToken);
|
|
324
|
+
if (!iss) {
|
|
325
|
+
log.warn('[auth] missing \'iss\' claim in id_token.');
|
|
326
|
+
return AuthInfo.Default().withCookieInvalid(true);
|
|
327
|
+
}
|
|
328
|
+
idp = IDPS.find((i) => i.validateIssuer(iss));
|
|
329
|
+
if (!idp) {
|
|
330
|
+
log.warn(`[auth] no IDP found for: ${iss}`);
|
|
331
|
+
return AuthInfo.Default().withCookieInvalid(true);
|
|
332
|
+
}
|
|
333
|
+
return AuthInfo.Default()
|
|
334
|
+
.withProfile(await decodeIdToken(state, idp, idToken))
|
|
335
|
+
.withAuthenticated(true)
|
|
336
|
+
.withIdp(idp)
|
|
337
|
+
.withIdToken(idToken);
|
|
338
|
+
} catch (e) {
|
|
339
|
+
if (e.code === 'ERR_JWT_EXPIRED' && idp) {
|
|
340
|
+
try {
|
|
341
|
+
const profile = await decodeIdToken(state, idp, idToken, true);
|
|
342
|
+
log.warn(`[auth] decoding the id_token failed: ${e.message}, using expired token as hint.`);
|
|
343
|
+
return AuthInfo.Default()
|
|
344
|
+
.withExpired(true)
|
|
345
|
+
.withIdp(idp)
|
|
346
|
+
.withLoginHint(profile.email);
|
|
347
|
+
} catch {
|
|
348
|
+
// ignore
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// wrong token
|
|
352
|
+
log.warn(`[auth] decoding the id_token failed: ${e.message}.`);
|
|
353
|
+
return AuthInfo.Default().withCookieInvalid(true);
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Computes the authentication info.
|
|
361
|
+
* @param {PipelineState} state
|
|
362
|
+
* @param {PipelineRequest} req
|
|
363
|
+
* @returns {Promise<AuthInfo>} the authentication info or null if the request is not authenticated
|
|
364
|
+
*/
|
|
365
|
+
export async function getAuthInfo(state, req) {
|
|
366
|
+
const { log } = state;
|
|
367
|
+
const auth = await getAuthInfoFromCookie(state, req);
|
|
368
|
+
if (auth) {
|
|
369
|
+
if (auth.authenticated) {
|
|
370
|
+
log.info(`[auth] id-token valid: iss=${auth.profile.iss}`);
|
|
371
|
+
}
|
|
372
|
+
return auth;
|
|
373
|
+
}
|
|
374
|
+
return AuthInfo
|
|
375
|
+
.Default()
|
|
376
|
+
// todo: select idp from config
|
|
377
|
+
.withIdp(idpMicrosoft);
|
|
378
|
+
}
|
|
@@ -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
|
+
};
|