@edynamix/mf-isolation 0.0.36 → 0.0.37
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/README.md +3 -1
- package/dist/vite-development.d.ts +6 -0
- package/dist/vite-development.d.ts.map +1 -1
- package/dist/vite-development.js +262 -151
- package/dist/vite-development.js.map +1 -1
- package/package.json +1 -1
package/dist/vite-development.js
CHANGED
|
@@ -3,6 +3,7 @@ import { Readable } from 'node:stream';
|
|
|
3
3
|
import { createRemoteJWKSet, jwtVerify } from 'jose';
|
|
4
4
|
const SESSION_COOKIE = '__Host-edyn_dev_session';
|
|
5
5
|
const REFRESH_COOKIE = '__Host-edyn_dev_refresh';
|
|
6
|
+
const MODULE_REFRESH_COOKIE = '__Host-edyn_dev_module_refresh';
|
|
6
7
|
const FLOW_COOKIE = '__Host-edyn_oidc_flow';
|
|
7
8
|
const LOGOUT_COOKIE = '__Host-edyn_oidc_logout';
|
|
8
9
|
const CALLBACK_PATH = '/callback';
|
|
@@ -20,6 +21,7 @@ const REFRESH_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
|
|
|
20
21
|
const FLOW_MAX_AGE_SECONDS = 10 * 60;
|
|
21
22
|
const TOKEN_REFRESH_SKEW_MS = 30_000;
|
|
22
23
|
const SPENT_REFRESH_TOKEN_REUSE_MS = 30_000;
|
|
24
|
+
const DEFAULT_MODULE_SIGN_IN_SCOPE = 'openid profile email offline_access';
|
|
23
25
|
const NAVIGATION_GROUP_HEADER = 'X-Edyn-Navigation-Group-Id';
|
|
24
26
|
const NAVIGATION_DEALER_HEADER = 'X-Edyn-Navigation-Dealer-Id';
|
|
25
27
|
const NAVIGATION_DEALERS_HEADER = 'X-Edyn-Navigation-Dealer-Ids';
|
|
@@ -82,6 +84,87 @@ const moduleApiPrefix = (candidate) => {
|
|
|
82
84
|
}
|
|
83
85
|
return candidate;
|
|
84
86
|
};
|
|
87
|
+
// Read from the module's client/.env (MODULE_SSO_ISSUER, MODULE_SSO_CLIENT_ID): there is no default, because a wrong
|
|
88
|
+
// guess only shows later as the module server refusing every call.
|
|
89
|
+
const moduleSignInConfiguration = (options) => {
|
|
90
|
+
if (!isRecord(options)) {
|
|
91
|
+
throw new Error('moduleSignIn is required: the issuer and client id the module server accepts (MODULE_SSO_ISSUER, MODULE_SSO_CLIENT_ID).');
|
|
92
|
+
}
|
|
93
|
+
const { clientId, issuer, scope } = options;
|
|
94
|
+
if (typeof issuer !== 'string' || issuer.trim() === '') {
|
|
95
|
+
throw new Error('moduleSignIn.issuer is required (MODULE_SSO_ISSUER).');
|
|
96
|
+
}
|
|
97
|
+
const issuerUrl = absoluteHttpsUrl(issuer.trim(), 'moduleSignIn.issuer', true);
|
|
98
|
+
if (issuerUrl.search) {
|
|
99
|
+
throw new Error('moduleSignIn.issuer must not contain a query.');
|
|
100
|
+
}
|
|
101
|
+
if (typeof clientId !== 'string' || clientId.trim() === '') {
|
|
102
|
+
throw new Error('moduleSignIn.clientId is required (MODULE_SSO_CLIENT_ID).');
|
|
103
|
+
}
|
|
104
|
+
if (scope !== undefined && (typeof scope !== 'string' || scope.trim() === '')) {
|
|
105
|
+
throw new Error('moduleSignIn.scope must be a non-empty string when it is set.');
|
|
106
|
+
}
|
|
107
|
+
return { clientId: clientId.trim(), issuer: issuerUrl.href.replace(/\/$/, ''), scope: scope?.trim() ?? DEFAULT_MODULE_SIGN_IN_SCOPE };
|
|
108
|
+
};
|
|
109
|
+
// One sign-in service and client: its configuration and discovery, each loaded once and loaded again after a failure.
|
|
110
|
+
class OidcClient {
|
|
111
|
+
#label;
|
|
112
|
+
#load;
|
|
113
|
+
#configurationPromise = null;
|
|
114
|
+
#discoveryPromise = null;
|
|
115
|
+
constructor(label, load) {
|
|
116
|
+
this.#label = label;
|
|
117
|
+
this.#load = load;
|
|
118
|
+
}
|
|
119
|
+
configuration() {
|
|
120
|
+
this.#configurationPromise ??= this.#load().catch((error) => {
|
|
121
|
+
this.#configurationPromise = null;
|
|
122
|
+
throw error;
|
|
123
|
+
});
|
|
124
|
+
return this.#configurationPromise;
|
|
125
|
+
}
|
|
126
|
+
discovery() {
|
|
127
|
+
this.#discoveryPromise ??= this.configuration()
|
|
128
|
+
.then(async (configuration) => {
|
|
129
|
+
const response = await fetch(`${configuration.issuer}/.well-known/openid-configuration`, {
|
|
130
|
+
headers: { Accept: 'application/json' },
|
|
131
|
+
redirect: 'error',
|
|
132
|
+
});
|
|
133
|
+
if (!response.ok) {
|
|
134
|
+
throw new Error(`OIDC discovery failed (${response.status}).`);
|
|
135
|
+
}
|
|
136
|
+
const value = await response.json();
|
|
137
|
+
if (!isRecord(value)) {
|
|
138
|
+
throw new Error('OIDC discovery returned an invalid response.');
|
|
139
|
+
}
|
|
140
|
+
const issuer = absoluteHttpsUrl(requiredString(value, 'issuer'), 'OIDC discovery issuer', true).href.replace(/\/$/, '');
|
|
141
|
+
if (issuer !== configuration.issuer) {
|
|
142
|
+
throw new Error(`OIDC discovery issuer does not match the ${this.#label} configuration.`);
|
|
143
|
+
}
|
|
144
|
+
const endpoint = (key) => {
|
|
145
|
+
const url = absoluteHttpsUrl(requiredString(value, key), `OIDC ${key}`, true);
|
|
146
|
+
if (url.origin !== new URL(issuer).origin) {
|
|
147
|
+
throw new Error(`OIDC ${key} must use the configured issuer origin.`);
|
|
148
|
+
}
|
|
149
|
+
return url.href;
|
|
150
|
+
};
|
|
151
|
+
const revocation = optionalString(value, 'revocation_endpoint');
|
|
152
|
+
return {
|
|
153
|
+
authorizationEndpoint: endpoint('authorization_endpoint'),
|
|
154
|
+
endSessionEndpoint: endpoint('end_session_endpoint'),
|
|
155
|
+
issuer,
|
|
156
|
+
jwksUri: endpoint('jwks_uri'),
|
|
157
|
+
revocationEndpoint: revocation ? endpoint('revocation_endpoint') : null,
|
|
158
|
+
tokenEndpoint: endpoint('token_endpoint'),
|
|
159
|
+
};
|
|
160
|
+
})
|
|
161
|
+
.catch((error) => {
|
|
162
|
+
this.#discoveryPromise = null;
|
|
163
|
+
throw error;
|
|
164
|
+
});
|
|
165
|
+
return this.#discoveryPromise;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
85
168
|
const base64Url = (size = 32) => randomBytes(size).toString('base64url');
|
|
86
169
|
const codeChallengeOf = (verifier) => createHash('sha256').update(verifier).digest('base64url');
|
|
87
170
|
const cookiesOf = (request) => {
|
|
@@ -103,9 +186,25 @@ const cookie = (name, value, maxAge) => `${name}=${value}; Path=/; HttpOnly; Sec
|
|
|
103
186
|
const clearCookie = (name) => cookie(name, '', 0);
|
|
104
187
|
// The refresh token stays in an HttpOnly cookie for this origin, which every module's development server shares, so
|
|
105
188
|
// one sign-in survives restarts and module switches until the token expires; page scripts can never read it.
|
|
106
|
-
const refreshCookieOf = (
|
|
107
|
-
|
|
108
|
-
|
|
189
|
+
const refreshCookieOf = (name, tokens) => tokens.refreshToken ? cookie(name, encodeURIComponent(tokens.refreshToken), REFRESH_COOKIE_MAX_AGE_SECONDS) : clearCookie(name);
|
|
190
|
+
// The host's refresh token keeps its original cookie, so developers signed in before the module had its own sign-in
|
|
191
|
+
// stay signed in to the host.
|
|
192
|
+
const refreshCookiesOf = (session) => [
|
|
193
|
+
refreshCookieOf(REFRESH_COOKIE, session.host),
|
|
194
|
+
...(session.module ? [refreshCookieOf(MODULE_REFRESH_COOKIE, session.module)] : []),
|
|
195
|
+
];
|
|
196
|
+
const tokenSetsOf = (session) => [
|
|
197
|
+
['host', REFRESH_COOKIE, session.host],
|
|
198
|
+
...(session.module ? [['module', MODULE_REFRESH_COOKIE, session.module]] : []),
|
|
199
|
+
];
|
|
200
|
+
const newSession = (host, module) => ({
|
|
201
|
+
host,
|
|
202
|
+
module,
|
|
203
|
+
moduleBootstrapTail: Promise.resolve(),
|
|
204
|
+
moduleCookies: new Map(),
|
|
205
|
+
moduleNavigationContext: null,
|
|
206
|
+
});
|
|
207
|
+
const refreshableTokens = (refreshToken) => ({ accessToken: '', expiresAt: 0, idToken: null, refreshToken });
|
|
109
208
|
const decodedCookieValue = (value) => {
|
|
110
209
|
if (!value) {
|
|
111
210
|
return null;
|
|
@@ -359,9 +458,9 @@ const readResponseBody = async (response, maximumBytes, label) => {
|
|
|
359
458
|
return Buffer.concat(chunks, totalBytes);
|
|
360
459
|
};
|
|
361
460
|
class MfDevelopmentBff {
|
|
362
|
-
#configurationUrl;
|
|
363
461
|
#developmentOrigin;
|
|
364
462
|
#flows = new Map();
|
|
463
|
+
#hostClient;
|
|
365
464
|
#hostOrigin;
|
|
366
465
|
#hostNavigationUrl;
|
|
367
466
|
#jwks = new Map();
|
|
@@ -369,15 +468,28 @@ class MfDevelopmentBff {
|
|
|
369
468
|
#moduleApiOrigin;
|
|
370
469
|
#moduleApiPath;
|
|
371
470
|
#moduleApiUnreachable = false;
|
|
471
|
+
#moduleClient;
|
|
372
472
|
#sessions = new Map();
|
|
373
473
|
#restores = new Map();
|
|
374
|
-
#configurationPromise = null;
|
|
375
|
-
#discoveryPromise = null;
|
|
376
474
|
constructor(options) {
|
|
377
475
|
const hostNavigation = absoluteHttpsUrl(options.hostNavigationUrl, 'hostNavigationUrl', true);
|
|
378
476
|
this.#hostNavigationUrl = hostNavigation;
|
|
379
477
|
this.#hostOrigin = new URL(hostNavigation.origin);
|
|
380
|
-
|
|
478
|
+
const configurationUrl = new URL('/api/auth/oidc-configuration', this.#hostOrigin);
|
|
479
|
+
this.#hostClient = new OidcClient('host', async () => {
|
|
480
|
+
const response = await fetch(configurationUrl, { headers: { Accept: 'application/json' }, redirect: 'error' });
|
|
481
|
+
if (!response.ok) {
|
|
482
|
+
throw new Error(`Host OIDC configuration failed (${response.status}).`);
|
|
483
|
+
}
|
|
484
|
+
const value = await response.json();
|
|
485
|
+
if (!isRecord(value)) {
|
|
486
|
+
throw new Error('Host OIDC configuration returned an invalid response.');
|
|
487
|
+
}
|
|
488
|
+
const issuer = absoluteHttpsUrl(requiredString(value, 'issuer'), 'OIDC issuer', true).href.replace(/\/$/, '');
|
|
489
|
+
return { clientId: requiredString(value, 'clientId'), issuer, scope: requiredString(value, 'scope') };
|
|
490
|
+
});
|
|
491
|
+
const moduleConfiguration = moduleSignInConfiguration(options.moduleSignIn);
|
|
492
|
+
this.#moduleClient = new OidcClient('module sign-in', async () => moduleConfiguration);
|
|
381
493
|
this.#developmentOrigin = absoluteHttpsUrl(options.developmentOrigin ?? 'https://localhost:4200', 'developmentOrigin');
|
|
382
494
|
if (this.#developmentOrigin.pathname !== '/' || this.#developmentOrigin.search || this.#developmentOrigin.hash) {
|
|
383
495
|
throw new Error('developmentOrigin must be an origin without a path, query, or fragment.');
|
|
@@ -388,6 +500,14 @@ class MfDevelopmentBff {
|
|
|
388
500
|
}
|
|
389
501
|
this.#moduleApiPath = moduleApiPrefix(options.moduleApiPath);
|
|
390
502
|
}
|
|
503
|
+
#clientOf(step) {
|
|
504
|
+
return step === 'host' ? this.#hostClient : this.#moduleClient;
|
|
505
|
+
}
|
|
506
|
+
// A module whose server accepts the host's issuer and client needs no second sign-in.
|
|
507
|
+
async #moduleSignsInSeparately() {
|
|
508
|
+
const [host, module] = await Promise.all([this.#hostClient.configuration(), this.#moduleClient.configuration()]);
|
|
509
|
+
return host.issuer !== module.issuer || host.clientId !== module.clientId;
|
|
510
|
+
}
|
|
391
511
|
async handle(request, response, next) {
|
|
392
512
|
if (!request.url || !request.method) {
|
|
393
513
|
next();
|
|
@@ -417,8 +537,9 @@ class MfDevelopmentBff {
|
|
|
417
537
|
return;
|
|
418
538
|
}
|
|
419
539
|
if (request.method === 'GET' && url.pathname === LOGGED_OUT_PATH) {
|
|
540
|
+
// The sign-in page signs out of the next sign-in service first while the logout cookie names one.
|
|
420
541
|
const returnTo = safeReturnTo(url.searchParams.get('state'));
|
|
421
|
-
redirect(response, `${LOGIN_PATH}?returnTo=${encodeURIComponent(returnTo)}
|
|
542
|
+
redirect(response, `${LOGIN_PATH}?returnTo=${encodeURIComponent(returnTo)}`);
|
|
422
543
|
return;
|
|
423
544
|
}
|
|
424
545
|
if (request.method === 'GET' && url.pathname === '/frontchannel-logout') {
|
|
@@ -427,7 +548,7 @@ class MfDevelopmentBff {
|
|
|
427
548
|
this.#sessions.delete(sessionId);
|
|
428
549
|
}
|
|
429
550
|
response.statusCode = 204;
|
|
430
|
-
response.setHeader('Set-Cookie', [clearCookie(SESSION_COOKIE), clearCookie(REFRESH_COOKIE)]);
|
|
551
|
+
response.setHeader('Set-Cookie', [clearCookie(SESSION_COOKIE), clearCookie(REFRESH_COOKIE), clearCookie(MODULE_REFRESH_COOKIE)]);
|
|
431
552
|
response.end();
|
|
432
553
|
return;
|
|
433
554
|
}
|
|
@@ -467,88 +588,51 @@ class MfDevelopmentBff {
|
|
|
467
588
|
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
468
589
|
response.end(body);
|
|
469
590
|
}
|
|
470
|
-
async #configuration() {
|
|
471
|
-
this.#configurationPromise ??= fetch(this.#configurationUrl, { headers: { Accept: 'application/json' }, redirect: 'error' })
|
|
472
|
-
.then(async (response) => {
|
|
473
|
-
if (!response.ok) {
|
|
474
|
-
throw new Error(`Host OIDC configuration failed (${response.status}).`);
|
|
475
|
-
}
|
|
476
|
-
const value = await response.json();
|
|
477
|
-
if (!isRecord(value)) {
|
|
478
|
-
throw new Error('Host OIDC configuration returned an invalid response.');
|
|
479
|
-
}
|
|
480
|
-
const issuer = absoluteHttpsUrl(requiredString(value, 'issuer'), 'OIDC issuer', true).href.replace(/\/$/, '');
|
|
481
|
-
return { clientId: requiredString(value, 'clientId'), issuer, scope: requiredString(value, 'scope') };
|
|
482
|
-
})
|
|
483
|
-
.catch((error) => {
|
|
484
|
-
this.#configurationPromise = null;
|
|
485
|
-
throw error;
|
|
486
|
-
});
|
|
487
|
-
return this.#configurationPromise;
|
|
488
|
-
}
|
|
489
|
-
async #discovery() {
|
|
490
|
-
this.#discoveryPromise ??= this.#configuration()
|
|
491
|
-
.then(async (configuration) => {
|
|
492
|
-
const response = await fetch(`${configuration.issuer}/.well-known/openid-configuration`, {
|
|
493
|
-
headers: { Accept: 'application/json' },
|
|
494
|
-
redirect: 'error',
|
|
495
|
-
});
|
|
496
|
-
if (!response.ok) {
|
|
497
|
-
throw new Error(`OIDC discovery failed (${response.status}).`);
|
|
498
|
-
}
|
|
499
|
-
const value = await response.json();
|
|
500
|
-
if (!isRecord(value)) {
|
|
501
|
-
throw new Error('OIDC discovery returned an invalid response.');
|
|
502
|
-
}
|
|
503
|
-
const issuer = absoluteHttpsUrl(requiredString(value, 'issuer'), 'OIDC discovery issuer', true).href.replace(/\/$/, '');
|
|
504
|
-
if (issuer !== configuration.issuer) {
|
|
505
|
-
throw new Error('OIDC discovery issuer does not match the host configuration.');
|
|
506
|
-
}
|
|
507
|
-
const endpoint = (key) => {
|
|
508
|
-
const url = absoluteHttpsUrl(requiredString(value, key), `OIDC ${key}`, true);
|
|
509
|
-
if (url.origin !== new URL(issuer).origin) {
|
|
510
|
-
throw new Error(`OIDC ${key} must use the configured issuer origin.`);
|
|
511
|
-
}
|
|
512
|
-
return url.href;
|
|
513
|
-
};
|
|
514
|
-
const revocation = optionalString(value, 'revocation_endpoint');
|
|
515
|
-
return {
|
|
516
|
-
authorizationEndpoint: endpoint('authorization_endpoint'),
|
|
517
|
-
endSessionEndpoint: endpoint('end_session_endpoint'),
|
|
518
|
-
issuer,
|
|
519
|
-
jwksUri: endpoint('jwks_uri'),
|
|
520
|
-
revocationEndpoint: revocation ? endpoint('revocation_endpoint') : null,
|
|
521
|
-
tokenEndpoint: endpoint('token_endpoint'),
|
|
522
|
-
};
|
|
523
|
-
})
|
|
524
|
-
.catch((error) => {
|
|
525
|
-
this.#discoveryPromise = null;
|
|
526
|
-
throw error;
|
|
527
|
-
});
|
|
528
|
-
return this.#discoveryPromise;
|
|
529
|
-
}
|
|
530
591
|
async #login(request, response, returnTo) {
|
|
531
592
|
const logoutId = cookiesOf(request).get(LOGOUT_COOKIE);
|
|
532
593
|
if (logoutId) {
|
|
594
|
+
// Signing out ends the session at each sign-in service in turn: each one returns to /logged-out, which comes back
|
|
595
|
+
// here, until none is left and the logout cookie goes.
|
|
533
596
|
const logout = this.#logoutFlows.get(logoutId);
|
|
534
|
-
|
|
535
|
-
if (logout) {
|
|
536
|
-
const
|
|
597
|
+
const next = logout?.pending.shift();
|
|
598
|
+
if (logout && next) {
|
|
599
|
+
const last = logout.pending.length === 0;
|
|
600
|
+
if (last) {
|
|
601
|
+
this.#logoutFlows.delete(logoutId);
|
|
602
|
+
}
|
|
603
|
+
const client = this.#clientOf(next.step);
|
|
604
|
+
const [configuration, discovery] = await Promise.all([client.configuration(), client.discovery()]);
|
|
537
605
|
const endpoint = new URL(discovery.endSessionEndpoint);
|
|
538
606
|
endpoint.searchParams.set('client_id', configuration.clientId);
|
|
539
607
|
endpoint.searchParams.set('post_logout_redirect_uri', new URL(LOGGED_OUT_PATH, this.#developmentOrigin).href);
|
|
540
608
|
endpoint.searchParams.set('state', returnTo);
|
|
541
|
-
if (
|
|
542
|
-
endpoint.searchParams.set('id_token_hint',
|
|
609
|
+
if (next.idToken) {
|
|
610
|
+
endpoint.searchParams.set('id_token_hint', next.idToken);
|
|
543
611
|
}
|
|
544
|
-
redirect(response, endpoint.href, [clearCookie(LOGOUT_COOKIE)]);
|
|
612
|
+
redirect(response, endpoint.href, last ? [clearCookie(LOGOUT_COOKIE)] : []);
|
|
545
613
|
return;
|
|
546
614
|
}
|
|
615
|
+
this.#logoutFlows.delete(logoutId);
|
|
616
|
+
await this.#startSignIn(response, 'host', returnTo, null, [clearCookie(LOGOUT_COOKIE)]);
|
|
617
|
+
return;
|
|
547
618
|
}
|
|
548
|
-
|
|
619
|
+
await this.#startSignIn(response, 'host', returnTo, null);
|
|
620
|
+
}
|
|
621
|
+
// The state names the step, so a callback can only finish the sign-in it was sent for.
|
|
622
|
+
async #startSignIn(response, step, returnTo, hostTokens, extraCookies = []) {
|
|
623
|
+
const client = this.#clientOf(step);
|
|
624
|
+
const [configuration, discovery] = await Promise.all([client.configuration(), client.discovery()]);
|
|
549
625
|
const flowId = base64Url();
|
|
550
626
|
const codeVerifier = base64Url(48);
|
|
551
|
-
const flow = {
|
|
627
|
+
const flow = {
|
|
628
|
+
codeVerifier,
|
|
629
|
+
createdAt: Date.now(),
|
|
630
|
+
hostTokens,
|
|
631
|
+
nonce: base64Url(),
|
|
632
|
+
returnTo,
|
|
633
|
+
state: `${step}.${base64Url()}`,
|
|
634
|
+
step,
|
|
635
|
+
};
|
|
552
636
|
this.#flows.set(flowId, flow);
|
|
553
637
|
const endpoint = new URL(discovery.authorizationEndpoint);
|
|
554
638
|
endpoint.searchParams.set('response_type', 'code');
|
|
@@ -559,7 +643,7 @@ class MfDevelopmentBff {
|
|
|
559
643
|
endpoint.searchParams.set('nonce', flow.nonce);
|
|
560
644
|
endpoint.searchParams.set('code_challenge', codeChallengeOf(codeVerifier));
|
|
561
645
|
endpoint.searchParams.set('code_challenge_method', 'S256');
|
|
562
|
-
redirect(response, endpoint.href, [cookie(FLOW_COOKIE, flowId, FLOW_MAX_AGE_SECONDS)]);
|
|
646
|
+
redirect(response, endpoint.href, [cookie(FLOW_COOKIE, flowId, FLOW_MAX_AGE_SECONDS), ...extraCookies]);
|
|
563
647
|
}
|
|
564
648
|
async #callback(request, response, url) {
|
|
565
649
|
const flowId = cookiesOf(request).get(FLOW_COOKIE);
|
|
@@ -581,7 +665,28 @@ class MfDevelopmentBff {
|
|
|
581
665
|
writeJson(response, 400, { code: 'missing_authorization_code', error: 'The OIDC callback did not include an authorization code.' });
|
|
582
666
|
return;
|
|
583
667
|
}
|
|
584
|
-
const
|
|
668
|
+
const tokens = await this.#exchange(flow, code);
|
|
669
|
+
if (flow.step === 'host' && (await this.#moduleSignsInSeparately())) {
|
|
670
|
+
// The host's tokens wait in the module step's flow until the module's sign-in service signs the developer in too.
|
|
671
|
+
await this.#startSignIn(response, 'module', flow.returnTo, tokens);
|
|
672
|
+
return;
|
|
673
|
+
}
|
|
674
|
+
const hostTokens = flow.step === 'host' ? tokens : flow.hostTokens;
|
|
675
|
+
if (!hostTokens) {
|
|
676
|
+
throw new Error('The module sign-in has no host sign-in to complete.');
|
|
677
|
+
}
|
|
678
|
+
const session = newSession(hostTokens, flow.step === 'module' ? tokens : null);
|
|
679
|
+
const sessionId = base64Url();
|
|
680
|
+
this.#sessions.set(sessionId, session);
|
|
681
|
+
redirect(response, flow.returnTo, [
|
|
682
|
+
cookie(SESSION_COOKIE, sessionId, SESSION_MAX_AGE_SECONDS),
|
|
683
|
+
...refreshCookiesOf(session),
|
|
684
|
+
clearCookie(FLOW_COOKIE),
|
|
685
|
+
]);
|
|
686
|
+
}
|
|
687
|
+
async #exchange(flow, code) {
|
|
688
|
+
const client = this.#clientOf(flow.step);
|
|
689
|
+
const [configuration, discovery] = await Promise.all([client.configuration(), client.discovery()]);
|
|
585
690
|
const fields = new URLSearchParams({
|
|
586
691
|
client_id: configuration.clientId,
|
|
587
692
|
code,
|
|
@@ -613,22 +718,12 @@ class MfDevelopmentBff {
|
|
|
613
718
|
if (verified.payload.nonce !== flow.nonce) {
|
|
614
719
|
throw new Error('OIDC ID token nonce does not match the authorization request.');
|
|
615
720
|
}
|
|
616
|
-
|
|
617
|
-
const session = {
|
|
721
|
+
return {
|
|
618
722
|
accessToken: tokens.accessToken,
|
|
619
723
|
expiresAt: Date.now() + tokens.expiresIn * 1000,
|
|
620
724
|
idToken,
|
|
621
|
-
moduleBootstrapTail: Promise.resolve(),
|
|
622
|
-
moduleCookies: new Map(),
|
|
623
|
-
moduleNavigationContext: null,
|
|
624
725
|
refreshToken: tokens.refreshToken,
|
|
625
726
|
};
|
|
626
|
-
this.#sessions.set(sessionId, session);
|
|
627
|
-
redirect(response, flow.returnTo, [
|
|
628
|
-
cookie(SESSION_COOKIE, sessionId, SESSION_MAX_AGE_SECONDS),
|
|
629
|
-
refreshCookieOf(session),
|
|
630
|
-
clearCookie(FLOW_COOKIE),
|
|
631
|
-
]);
|
|
632
727
|
}
|
|
633
728
|
async #logout(request, response) {
|
|
634
729
|
const sessionId = cookiesOf(request).get(SESSION_COOKIE);
|
|
@@ -637,19 +732,27 @@ class MfDevelopmentBff {
|
|
|
637
732
|
this.#sessions.delete(sessionId);
|
|
638
733
|
}
|
|
639
734
|
if (session) {
|
|
640
|
-
|
|
735
|
+
for (const [step, , tokens] of tokenSetsOf(session)) {
|
|
736
|
+
void this.#revoke(step, tokens).catch(() => undefined);
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
const pending = [{ idToken: session?.host.idToken ?? null, step: 'host' }];
|
|
740
|
+
if (await this.#moduleSignsInSeparately().catch(() => false)) {
|
|
741
|
+
pending.push({ idToken: session?.module?.idToken ?? null, step: 'module' });
|
|
641
742
|
}
|
|
642
743
|
const logoutId = base64Url();
|
|
643
|
-
this.#logoutFlows.set(logoutId, { createdAt: Date.now(),
|
|
744
|
+
this.#logoutFlows.set(logoutId, { createdAt: Date.now(), pending });
|
|
644
745
|
response.setHeader('Set-Cookie', [
|
|
645
746
|
clearCookie(SESSION_COOKIE),
|
|
646
747
|
clearCookie(REFRESH_COOKIE),
|
|
748
|
+
clearCookie(MODULE_REFRESH_COOKIE),
|
|
647
749
|
cookie(LOGOUT_COOKIE, logoutId, FLOW_MAX_AGE_SECONDS),
|
|
648
750
|
]);
|
|
649
751
|
writeJson(response, 200, { loggedOut: true });
|
|
650
752
|
}
|
|
651
|
-
async #revoke(
|
|
652
|
-
const
|
|
753
|
+
async #revoke(step, tokens) {
|
|
754
|
+
const client = this.#clientOf(step);
|
|
755
|
+
const [configuration, discovery] = await Promise.all([client.configuration(), client.discovery()]);
|
|
653
756
|
const revocationEndpoint = discovery.revocationEndpoint;
|
|
654
757
|
if (!revocationEndpoint) {
|
|
655
758
|
return;
|
|
@@ -663,8 +766,8 @@ class MfDevelopmentBff {
|
|
|
663
766
|
});
|
|
664
767
|
};
|
|
665
768
|
await Promise.all([
|
|
666
|
-
revoke(
|
|
667
|
-
|
|
769
|
+
revoke(tokens.accessToken, 'access_token'),
|
|
770
|
+
tokens.refreshToken ? revoke(tokens.refreshToken, 'refresh_token') : Promise.resolve(),
|
|
668
771
|
]);
|
|
669
772
|
}
|
|
670
773
|
// Standalone modules send the browser to their legacy /login page when an API call answers 401, and locally no such
|
|
@@ -686,78 +789,83 @@ class MfDevelopmentBff {
|
|
|
686
789
|
`<p><a href="${LOGIN_PATH}?returnTo=%2F">Sign in again</a> · <a href="/">Back to the module</a></p>` +
|
|
687
790
|
'</body></html>');
|
|
688
791
|
}
|
|
792
|
+
// A session is whole only with every sign-in it needs: when one of them can no longer refresh, the developer signs in
|
|
793
|
+
// again, which passes through each sign-in service in turn.
|
|
689
794
|
async #authenticatedSession(request, response) {
|
|
690
795
|
const cookies = cookiesOf(request);
|
|
691
796
|
const sessionId = cookies.get(SESSION_COOKIE);
|
|
692
797
|
const session = sessionId ? this.#sessions.get(sessionId) : undefined;
|
|
693
798
|
if (!session) {
|
|
694
|
-
return this.#restoredSession(decodedCookieValue(cookies.get(REFRESH_COOKIE)), response);
|
|
695
|
-
}
|
|
696
|
-
if (session.expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) {
|
|
697
|
-
return session;
|
|
799
|
+
return this.#restoredSession(decodedCookieValue(cookies.get(REFRESH_COOKIE)), decodedCookieValue(cookies.get(MODULE_REFRESH_COOKIE)), response);
|
|
698
800
|
}
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
801
|
+
const refreshedCookies = [];
|
|
802
|
+
for (const [step, cookieName, tokens] of tokenSetsOf(session)) {
|
|
803
|
+
if (tokens.expiresAt > Date.now() + TOKEN_REFRESH_SKEW_MS) {
|
|
804
|
+
continue;
|
|
702
805
|
}
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
806
|
+
if (!tokens.refreshToken) {
|
|
807
|
+
if (sessionId) {
|
|
808
|
+
this.#sessions.delete(sessionId);
|
|
809
|
+
}
|
|
810
|
+
return null;
|
|
811
|
+
}
|
|
812
|
+
tokens.refreshPromise ??= this.#refresh(step, tokens).finally(() => {
|
|
813
|
+
tokens.refreshPromise = undefined;
|
|
814
|
+
});
|
|
815
|
+
try {
|
|
816
|
+
await tokens.refreshPromise;
|
|
817
|
+
refreshedCookies.push(refreshCookieOf(cookieName, tokens));
|
|
818
|
+
}
|
|
819
|
+
catch {
|
|
820
|
+
if (sessionId) {
|
|
821
|
+
this.#sessions.delete(sessionId);
|
|
822
|
+
}
|
|
823
|
+
response.setHeader('Set-Cookie', clearCookie(cookieName));
|
|
824
|
+
return null;
|
|
716
825
|
}
|
|
717
|
-
response.setHeader('Set-Cookie', clearCookie(REFRESH_COOKIE));
|
|
718
|
-
return null;
|
|
719
826
|
}
|
|
827
|
+
if (refreshedCookies.length > 0) {
|
|
828
|
+
response.setHeader('Set-Cookie', refreshedCookies);
|
|
829
|
+
}
|
|
830
|
+
return session;
|
|
720
831
|
}
|
|
721
|
-
// Starts a session from the browser's refresh
|
|
722
|
-
// the developer in. Parallel requests carrying the same
|
|
723
|
-
// requests the browser sent before the rotated
|
|
832
|
+
// Starts a session from the browser's refresh cookies after this server restarted or another module's server signed
|
|
833
|
+
// the developer in. Parallel requests carrying the same tokens share one refresh, and so, for a short while, do
|
|
834
|
+
// requests the browser sent before the rotated cookies reached it: the sign-in service accepts a refresh token once,
|
|
724
835
|
// so spending it again would fail and clear the cookie that replaced it.
|
|
725
|
-
async #restoredSession(
|
|
726
|
-
if (!
|
|
836
|
+
async #restoredSession(hostRefreshToken, moduleRefreshToken, response) {
|
|
837
|
+
if (!hostRefreshToken) {
|
|
727
838
|
return null;
|
|
728
839
|
}
|
|
729
|
-
|
|
840
|
+
const separate = await this.#moduleSignsInSeparately();
|
|
841
|
+
if (separate && !moduleRefreshToken) {
|
|
842
|
+
return null;
|
|
843
|
+
}
|
|
844
|
+
const restoreKey = separate ? `${hostRefreshToken}\n${moduleRefreshToken}` : hostRefreshToken;
|
|
845
|
+
let restore = this.#restores.get(restoreKey);
|
|
730
846
|
if (!restore) {
|
|
731
|
-
restore = this.#restore(
|
|
732
|
-
this.#restores.set(
|
|
847
|
+
restore = this.#restore(hostRefreshToken, separate ? moduleRefreshToken : null);
|
|
848
|
+
this.#restores.set(restoreKey, restore);
|
|
733
849
|
void restore.then((restoredSession) => {
|
|
734
850
|
if (!restoredSession) {
|
|
735
|
-
this.#restores.delete(
|
|
851
|
+
this.#restores.delete(restoreKey);
|
|
736
852
|
return;
|
|
737
853
|
}
|
|
738
|
-
setTimeout(() => this.#restores.delete(
|
|
854
|
+
setTimeout(() => this.#restores.delete(restoreKey), SPENT_REFRESH_TOKEN_REUSE_MS).unref();
|
|
739
855
|
});
|
|
740
856
|
}
|
|
741
857
|
const restored = await restore;
|
|
742
858
|
if (!restored || this.#sessions.get(restored.sessionId) !== restored.session) {
|
|
743
|
-
response.setHeader('Set-Cookie', clearCookie(REFRESH_COOKIE));
|
|
859
|
+
response.setHeader('Set-Cookie', separate ? [clearCookie(REFRESH_COOKIE), clearCookie(MODULE_REFRESH_COOKIE)] : clearCookie(REFRESH_COOKIE));
|
|
744
860
|
return null;
|
|
745
861
|
}
|
|
746
|
-
response.setHeader('Set-Cookie', [cookie(SESSION_COOKIE, restored.sessionId, SESSION_MAX_AGE_SECONDS),
|
|
862
|
+
response.setHeader('Set-Cookie', [cookie(SESSION_COOKIE, restored.sessionId, SESSION_MAX_AGE_SECONDS), ...refreshCookiesOf(restored.session)]);
|
|
747
863
|
return restored.session;
|
|
748
864
|
}
|
|
749
|
-
async #restore(
|
|
750
|
-
const session =
|
|
751
|
-
accessToken: '',
|
|
752
|
-
expiresAt: 0,
|
|
753
|
-
idToken: null,
|
|
754
|
-
moduleBootstrapTail: Promise.resolve(),
|
|
755
|
-
moduleCookies: new Map(),
|
|
756
|
-
moduleNavigationContext: null,
|
|
757
|
-
refreshToken,
|
|
758
|
-
};
|
|
865
|
+
async #restore(hostRefreshToken, moduleRefreshToken) {
|
|
866
|
+
const session = newSession(refreshableTokens(hostRefreshToken), moduleRefreshToken ? refreshableTokens(moduleRefreshToken) : null);
|
|
759
867
|
try {
|
|
760
|
-
await this.#refresh(
|
|
868
|
+
await Promise.all(tokenSetsOf(session).map(([step, , tokens]) => this.#refresh(step, tokens)));
|
|
761
869
|
}
|
|
762
870
|
catch {
|
|
763
871
|
return null;
|
|
@@ -766,12 +874,13 @@ class MfDevelopmentBff {
|
|
|
766
874
|
this.#sessions.set(sessionId, session);
|
|
767
875
|
return { session, sessionId };
|
|
768
876
|
}
|
|
769
|
-
async #refresh(
|
|
770
|
-
const refreshToken =
|
|
877
|
+
async #refresh(step, tokenSet) {
|
|
878
|
+
const refreshToken = tokenSet.refreshToken;
|
|
771
879
|
if (!refreshToken) {
|
|
772
880
|
throw new Error('OIDC refresh token is unavailable.');
|
|
773
881
|
}
|
|
774
|
-
const
|
|
882
|
+
const client = this.#clientOf(step);
|
|
883
|
+
const [configuration, discovery] = await Promise.all([client.configuration(), client.discovery()]);
|
|
775
884
|
const response = await fetch(discovery.tokenEndpoint, {
|
|
776
885
|
body: new URLSearchParams({
|
|
777
886
|
client_id: configuration.clientId,
|
|
@@ -786,10 +895,10 @@ class MfDevelopmentBff {
|
|
|
786
895
|
throw new Error(`OIDC refresh failed (${response.status}).`);
|
|
787
896
|
}
|
|
788
897
|
const tokens = parseTokenResponse(await response.json(), false);
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
898
|
+
tokenSet.accessToken = tokens.accessToken;
|
|
899
|
+
tokenSet.expiresAt = Date.now() + tokens.expiresIn * 1000;
|
|
900
|
+
tokenSet.refreshToken = tokens.refreshToken ?? tokenSet.refreshToken;
|
|
901
|
+
tokenSet.idToken = tokens.idToken ?? tokenSet.idToken;
|
|
793
902
|
}
|
|
794
903
|
async #proxy(request, response, localUrl) {
|
|
795
904
|
const session = await this.#authenticatedSession(request, response);
|
|
@@ -829,7 +938,7 @@ class MfDevelopmentBff {
|
|
|
829
938
|
}
|
|
830
939
|
async #loadNavigationContext(session) {
|
|
831
940
|
const upstream = await fetch(new URL('/api/navigation', this.#hostOrigin), {
|
|
832
|
-
headers: { Accept: 'application/json', Authorization: `Bearer ${session.accessToken}` },
|
|
941
|
+
headers: { Accept: 'application/json', Authorization: `Bearer ${session.host.accessToken}` },
|
|
833
942
|
redirect: 'manual',
|
|
834
943
|
}).catch(() => null);
|
|
835
944
|
if (!upstream?.ok || session.moduleNavigationContext) {
|
|
@@ -848,8 +957,10 @@ class MfDevelopmentBff {
|
|
|
848
957
|
: portalApiPath
|
|
849
958
|
? { cookies: new Map(), navigationContext: session.moduleNavigationContext }
|
|
850
959
|
: undefined;
|
|
960
|
+
// The module's server accepts the module's sign-in; the host and the portal it serves accept the host's.
|
|
961
|
+
const accessToken = isModuleApi ? (session.module ?? session.host).accessToken : session.host.accessToken;
|
|
851
962
|
const init = {
|
|
852
|
-
headers: proxyHeaders(request,
|
|
963
|
+
headers: proxyHeaders(request, accessToken, moduleContext),
|
|
853
964
|
method,
|
|
854
965
|
redirect: 'manual',
|
|
855
966
|
};
|
|
@@ -918,7 +1029,7 @@ class MfDevelopmentBff {
|
|
|
918
1029
|
}
|
|
919
1030
|
}
|
|
920
1031
|
for (const [id, session] of this.#sessions) {
|
|
921
|
-
if (
|
|
1032
|
+
if (tokenSetsOf(session).some(([, , tokens]) => !tokens.refreshToken && tokens.expiresAt + TOKEN_REFRESH_SKEW_MS < now)) {
|
|
922
1033
|
this.#sessions.delete(id);
|
|
923
1034
|
}
|
|
924
1035
|
}
|