@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.
@@ -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 = (session) => session.refreshToken
107
- ? cookie(REFRESH_COOKIE, encodeURIComponent(session.refreshToken), REFRESH_COOKIE_MAX_AGE_SECONDS)
108
- : clearCookie(REFRESH_COOKIE);
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
- this.#configurationUrl = new URL('/api/auth/oidc-configuration', this.#hostOrigin);
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)}`, [clearCookie(LOGOUT_COOKIE)]);
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
- this.#logoutFlows.delete(logoutId);
535
- if (logout) {
536
- const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);
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 (logout.idToken) {
542
- endpoint.searchParams.set('id_token_hint', logout.idToken);
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
- const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);
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 = { codeVerifier, createdAt: Date.now(), nonce: base64Url(), returnTo, state: base64Url() };
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 [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);
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
- const sessionId = base64Url();
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
- void this.#revoke(session).catch(() => undefined);
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(), idToken: session?.idToken ?? null });
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(session) {
652
- const [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);
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(session.accessToken, 'access_token'),
667
- session.refreshToken ? revoke(session.refreshToken, 'refresh_token') : Promise.resolve(),
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> &middot; <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
- if (!session.refreshToken) {
700
- if (sessionId) {
701
- this.#sessions.delete(sessionId);
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
- return null;
704
- }
705
- session.refreshPromise ??= this.#refresh(session).finally(() => {
706
- session.refreshPromise = undefined;
707
- });
708
- try {
709
- await session.refreshPromise;
710
- response.setHeader('Set-Cookie', refreshCookieOf(session));
711
- return session;
712
- }
713
- catch {
714
- if (sessionId) {
715
- this.#sessions.delete(sessionId);
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 cookie after this server restarted or another module's server signed
722
- // the developer in. Parallel requests carrying the same token share one refresh, and so, for a short while, do
723
- // requests the browser sent before the rotated cookie reached it: the sign-in service accepts a refresh token once,
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(refreshToken, response) {
726
- if (!refreshToken) {
836
+ async #restoredSession(hostRefreshToken, moduleRefreshToken, response) {
837
+ if (!hostRefreshToken) {
727
838
  return null;
728
839
  }
729
- let restore = this.#restores.get(refreshToken);
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(refreshToken);
732
- this.#restores.set(refreshToken, restore);
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(refreshToken);
851
+ this.#restores.delete(restoreKey);
736
852
  return;
737
853
  }
738
- setTimeout(() => this.#restores.delete(refreshToken), SPENT_REFRESH_TOKEN_REUSE_MS).unref();
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), refreshCookieOf(restored.session)]);
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(refreshToken) {
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(session);
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(session) {
770
- const refreshToken = session.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 [configuration, discovery] = await Promise.all([this.#configuration(), this.#discovery()]);
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
- session.accessToken = tokens.accessToken;
790
- session.expiresAt = Date.now() + tokens.expiresIn * 1000;
791
- session.refreshToken = tokens.refreshToken ?? session.refreshToken;
792
- session.idToken = tokens.idToken ?? session.idToken;
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, session.accessToken, moduleContext),
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 (!session.refreshToken && session.expiresAt + TOKEN_REFRESH_SKEW_MS < now) {
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
  }