@finos/legend-application-studio 28.21.14 → 28.21.16

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.
@@ -54,6 +54,18 @@ export type LegendStudioApplicationStore = ApplicationStore<
54
54
  LegendStudioPluginManager
55
55
  >;
56
56
 
57
+ /**
58
+ * Same-origin path of the static page that the SDLC OAuth flow redirects to
59
+ * when re-authenticating in a popup window. Shipped by the Studio deployment
60
+ * (see `assets/popup-callback.html`); deployments enabling the popup
61
+ * re-auth feature must register `<base-url>/popup-callback.html` on the
62
+ * SDLC server's OAuth client allow-list.
63
+ *
64
+ * The leading slash is required because `navigator.generateAddress` is
65
+ * `origin + baseUrl + location` and baseUrl has its trailing slash stripped.
66
+ */
67
+ const SDLC_POPUP_REAUTH_CALLBACK_PATH = '/popup-callback.html';
68
+
57
69
  export class LegendStudioBaseStore {
58
70
  readonly applicationStore: LegendStudioApplicationStore;
59
71
  readonly sdlcServerClient: SDLCServerClient;
@@ -61,18 +73,72 @@ export class LegendStudioBaseStore {
61
73
  readonly pluginManager: LegendStudioPluginManager;
62
74
 
63
75
  readonly initState = ActionState.create();
76
+ readonly popupReAuthState = ActionState.create();
64
77
 
65
78
  isSDLCAuthorized: boolean | undefined = false;
66
79
  private isSDLCServerInitialized = false;
67
80
  SDLCServerTermsOfServicesUrlsToView: string[] = [];
68
81
 
82
+ /**
83
+ * Tracks whether an automatic popup re-auth has already been attempted for
84
+ * the current "failure episode". An episode is the window between two
85
+ * successful SDLC interactions: the flag is set on the first auto-attempt
86
+ * and cleared whenever a re-auth (auto or manual) succeeds. This is what
87
+ * prevents endless 401 → popup → 401 loops.
88
+ */
89
+ private autoPopupReAuthAttempted = false;
90
+
91
+ /**
92
+ * Dedupes concurrent auto callers of `handleSDLCUnauthorized`. Wraps the
93
+ * raw popup promise with the auto-flow notification logic (manual-fallback
94
+ * warning on failure) so all concurrent 401-driven callers get the same
95
+ * resolution and only one warning fires per attempt.
96
+ */
97
+ private inFlightAutoReAuth: Promise<boolean> | undefined;
98
+
99
+ /**
100
+ * The raw popup promise, set by `reAuthorizeSDLCInPopup` for the duration
101
+ * of an on-screen popup REGARDLESS of who opened it (manual shield click
102
+ * OR auto-trigger). `handleSDLCUnauthorized` consults this before deciding
103
+ * to consume the episode and warn — without it, a 401 raised while the
104
+ * user is in a manually-opened popup would either stack a second popup or
105
+ * pop a "click the shield" warning at them while the shield's popup is in
106
+ * front of them.
107
+ */
108
+ private inFlightReAuth: Promise<boolean> | undefined;
109
+
110
+ /**
111
+ * Per-episode dedup flag for the "click the shield to retry" warning.
112
+ * `handleSDLCUnauthorized` can be re-entered many times within a single
113
+ * failure episode (every subsequent failing SDLC request in a burst lands
114
+ * in the loop-guard branch); without this flag, each one would stack an
115
+ * identical toast. Cleared alongside `autoPopupReAuthAttempted` on a
116
+ * successful re-auth so the next episode can warn again.
117
+ */
118
+ private manualReAuthNotifiedForEpisode = false;
119
+
120
+ /**
121
+ * Re-entry guard for the network layer's 401 hook. Set to `true` while we
122
+ * are re-bootstrapping the SDLC session inside the popup-success path so
123
+ * that a 401 raised by the re-bootstrap requests themselves (e.g. the
124
+ * `isAuthorized` verification GET coming back 401 because cookies didn't
125
+ * actually land) cannot recurse into `handleSDLCUnauthorized` — that would
126
+ * return the still-in-flight outer promise and deadlock the whole chain.
127
+ */
128
+ private suppressAutoReAuth = false;
129
+
69
130
  constructor(applicationStore: LegendStudioApplicationStore) {
70
- makeObservable<LegendStudioBaseStore, 'initializeSDLCServerClient'>(this, {
131
+ makeObservable<
132
+ LegendStudioBaseStore,
133
+ 'initializeSDLCServerClient' | 'verifySDLCSessionAfterReAuth'
134
+ >(this, {
71
135
  isSDLCAuthorized: observable,
72
136
  SDLCServerTermsOfServicesUrlsToView: observable,
73
137
  needsToAcceptSDLCServerTermsOfServices: computed,
138
+ isSDLCPopupReAuthEnabled: computed,
74
139
  initialize: flow,
75
140
  initializeSDLCServerClient: flow,
141
+ verifySDLCSessionAfterReAuth: flow,
76
142
  dismissSDLCServerTermsOfServicesAlert: action,
77
143
  });
78
144
 
@@ -93,6 +159,11 @@ export class LegendStudioBaseStore {
93
159
  serverUrl: this.applicationStore.config.sdlcServerUrl,
94
160
  baseHeaders: this.applicationStore.config.sdlcServerBaseHeaders,
95
161
  client: this.applicationStore.config.sdlcServerClient,
162
+ // On a mid-session 401, auto-launch the popup re-auth flow. The
163
+ // handler enforces a one-attempt-per-episode guard to prevent endless
164
+ // retry loops; when it gives up, the StatusBar manual button remains
165
+ // available.
166
+ autoReAuthenticate: () => this.handleSDLCUnauthorized(),
96
167
  });
97
168
  this.sdlcServerClient.setTracerService(this.applicationStore.tracerService);
98
169
  }
@@ -206,6 +277,364 @@ export class LegendStudioBaseStore {
206
277
  this.SDLCServerTermsOfServicesUrlsToView = [];
207
278
  }
208
279
 
280
+ /**
281
+ * Whether the popup-based SDLC re-authentication flow is available.
282
+ *
283
+ * The feature is opt-in via the `sdlc.enablePopupReAuth` config flag
284
+ * because the derived callback URL must be explicitly registered as an
285
+ * OAuth `redirect_uri` on the SDLC server. When `false` (or unset), only
286
+ * the legacy top-level redirect flow is available.
287
+ */
288
+ get isSDLCPopupReAuthEnabled(): boolean {
289
+ return this.applicationStore.config.sdlcEnablePopupReAuth;
290
+ }
291
+
292
+ /**
293
+ * 401 interception hook wired into the SDLC server client. Triggers the
294
+ * popup re-auth flow automatically — without any user action — but only
295
+ * once per failure episode to guard against retry loops.
296
+ *
297
+ * @returns a promise resolving to `true` if the SDLC server is now
298
+ * authorized (i.e. the underlying network layer should retry the original
299
+ * request), `false` otherwise.
300
+ */
301
+ private handleSDLCUnauthorized(): Promise<boolean> {
302
+ // Feature off → propagate the 401 to the caller as before.
303
+ if (!this.isSDLCPopupReAuthEnabled) {
304
+ return Promise.resolve(false);
305
+ }
306
+ // Re-entry guard: a 401 raised from inside the post-popup re-bootstrap
307
+ // (e.g. the verification `isAuthorized()` call itself coming back 401
308
+ // because cookies didn't actually land) MUST NOT recurse into the popup
309
+ // flow — that would return the in-flight outer promise and deadlock.
310
+ if (this.suppressAutoReAuth) {
311
+ return Promise.resolve(false);
312
+ }
313
+ // Coalesce concurrent auto callers onto the wrapped auto-flow promise so
314
+ // the manual-fallback warning only fires once per attempt.
315
+ if (this.inFlightAutoReAuth) {
316
+ return this.inFlightAutoReAuth;
317
+ }
318
+ // If a popup is already on screen — most commonly because the user just
319
+ // clicked the StatusBar shield, but also possibly because a prior auto-
320
+ // attempt's popup is still finalising — defer to its outcome instead of
321
+ // consuming this episode's one auto-attempt and warning at the user
322
+ // about a manual button that the popup is in the way of.
323
+ if (this.inFlightReAuth) {
324
+ return this.inFlightReAuth;
325
+ }
326
+ // Loop guard: we've already auto-attempted once this episode. Surface
327
+ // the manual fallback (deduped) and stop auto-retrying.
328
+ if (this.autoPopupReAuthAttempted) {
329
+ this.notifyManualReAuthAvailable();
330
+ return Promise.resolve(false);
331
+ }
332
+ this.autoPopupReAuthAttempted = true;
333
+ // NOTE: we deliberately don't fire an upfront "session expired" toast —
334
+ // the popup window itself is the user-visible signal that re-auth is
335
+ // happening. We only notify on completion (success or failure) so the
336
+ // toast stack stays clean.
337
+ this.inFlightAutoReAuth = this.reAuthorizeSDLCInPopup()
338
+ .then((ok) => {
339
+ this.inFlightAutoReAuth = undefined;
340
+ if (!ok) {
341
+ this.notifyManualReAuthAvailable();
342
+ }
343
+ // success path resets `autoPopupReAuthAttempted` inside
344
+ // `reAuthorizeSDLCInPopup` so the next episode can auto-attempt again
345
+ return ok;
346
+ })
347
+ .catch(() => {
348
+ this.inFlightAutoReAuth = undefined;
349
+ this.notifyManualReAuthAvailable();
350
+ return false;
351
+ });
352
+ return this.inFlightAutoReAuth;
353
+ }
354
+
355
+ /**
356
+ * Best-effort notification that nudges the user toward the manual
357
+ * re-authentication entry point (the StatusBar shield button) when the
358
+ * automatic flow could not complete (popup blocked, user dismissed, or
359
+ * the retry still 401'd).
360
+ *
361
+ * Deduped per failure episode: subsequent 401s within the same episode
362
+ * (a burst of editor requests after session expiry, for example) all hit
363
+ * the loop-guard branch and would otherwise each stack an identical
364
+ * warning toast. The flag is cleared when a re-auth attempt succeeds.
365
+ */
366
+ private notifyManualReAuthAvailable(): void {
367
+ if (this.manualReAuthNotifiedForEpisode) {
368
+ return;
369
+ }
370
+ this.manualReAuthNotifiedForEpisode = true;
371
+ this.applicationStore.notificationService.notifyWarning(
372
+ `Automatic SDLC re-authentication did not complete. Click the shield icon in the status bar to retry, or reload the page to start a fresh session.`,
373
+ );
374
+ }
375
+
376
+ /**
377
+ * Re-initiate the SDLC authorization step in a popup window so that the
378
+ * user can recover from an expired SDLC session without losing in-memory
379
+ * editor state (open tabs, unsaved buffers, etc.).
380
+ *
381
+ * The popup loads the SDLC server's `/auth/authorize?redirect_uri=...`
382
+ * endpoint with a same-origin callback page as the `redirect_uri`. That
383
+ * URL is derived at runtime via the navigator — the same way the
384
+ * boot-time auth flow derives its own `redirect_uri` — so deployments
385
+ * only need to declare intent (`sdlc.enablePopupReAuth: true`) and ship
386
+ * the static `popup-callback.html` page at their base URL.
387
+ *
388
+ * The callback page is expected to `postMessage` back to the opener with
389
+ * payload `{ type: 'SDLC_REAUTH_DONE' }` and then close itself.
390
+ *
391
+ * On success, the SDLC server client is re-checked and re-initialized in
392
+ * place — no top-level navigation, no state loss.
393
+ *
394
+ * @returns a promise that resolves to `true` if the SDLC server is
395
+ * authorized after the popup flow completes, `false` otherwise (popup
396
+ * blocked, user cancelled, authorization check still failing, ...).
397
+ */
398
+ async reAuthorizeSDLCInPopup(): Promise<boolean> {
399
+ if (!this.isSDLCPopupReAuthEnabled) {
400
+ this.applicationStore.notificationService.notifyWarning(
401
+ `Popup re-authentication is not enabled. Set 'sdlc.enablePopupReAuth' to true in the Studio config to enable it.`,
402
+ );
403
+ return false;
404
+ }
405
+ // Coalesce concurrent callers (manual shield click + auto 401 trigger)
406
+ // onto a single popup. Without this, a 401 raised while the user is in
407
+ // a manually-opened popup would either stack a second popup OR fire a
408
+ // confusing "click the shield" warning at the user while the shield's
409
+ // popup is literally on screen.
410
+ if (this.inFlightReAuth) {
411
+ return this.inFlightReAuth;
412
+ }
413
+ this.popupReAuthState.inProgress();
414
+
415
+ // Derive the callback URL the same way the boot-time SDLC auth flow
416
+ // derives its `redirect_uri` — via the navigator's same-origin address
417
+ // builder. The callback page is a constant static asset shipped with
418
+ // every Studio deployment.
419
+ const callbackUrl =
420
+ this.applicationStore.navigationService.navigator.generateAddress(
421
+ SDLC_POPUP_REAUTH_CALLBACK_PATH,
422
+ );
423
+ const callbackOrigin = new URL(callbackUrl).origin;
424
+
425
+ const authorizeUrl = SDLCServerClient.authorizeCallbackUrl(
426
+ this.applicationStore.config.sdlcServerUrl,
427
+ callbackUrl,
428
+ this.applicationStore.config.sdlcServerClient,
429
+ );
430
+
431
+ const popup = window.open(
432
+ authorizeUrl,
433
+ 'legend-studio-sdlc-reauth',
434
+ 'width=560,height=720,menubar=no,toolbar=no,location=no,status=no',
435
+ );
436
+ if (!popup) {
437
+ this.popupReAuthState.fail();
438
+ this.applicationStore.notificationService.notifyError(
439
+ `Failed to open the re-authentication popup. Please allow popups for this site and try again.`,
440
+ );
441
+ return false;
442
+ }
443
+
444
+ const promise = new Promise<boolean>((resolve) => {
445
+ let closedPoll: ReturnType<typeof setInterval> | undefined;
446
+ let settled = false;
447
+
448
+ // `finalize` and `onMessage` form a small cycle:
449
+ // onMessage -> finalize (call on success postMessage)
450
+ // finalize -> onMessage (removeEventListener on teardown)
451
+ // We declare `finalize` first so only the one forward reference to
452
+ // `onMessage` from inside `finalize`'s body needs to be excused.
453
+ const finalize = (signalled: boolean): void => {
454
+ if (settled) {
455
+ return;
456
+ }
457
+ settled = true;
458
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
459
+ window.removeEventListener('message', onMessage);
460
+ if (closedPoll !== undefined) {
461
+ clearInterval(closedPoll);
462
+ closedPoll = undefined;
463
+ }
464
+ try {
465
+ popup.close();
466
+ } catch {
467
+ // ignored — popup may already be closed or cross-origin
468
+ }
469
+ // Always re-verify the SDLC session in place, whether the callback
470
+ // page managed to `postMessage` (`signalled === true`) or the popup
471
+ // simply closed (`signalled === false`). The popup-closed path used
472
+ // to fail outright, but that silently broke environments where the
473
+ // browser severs `window.opener` somewhere along the auth redirect
474
+ // chain (most commonly an IdP or the SDLC server sending
475
+ // `Cross-Origin-Opener-Policy: same-origin`): the callback page's
476
+ // `postMessage` becomes a no-op, the popup closes itself, and the
477
+ // only way to learn whether auth actually took is to ask the server.
478
+ // The cost is one cheap GET on user-cancel; the upside is robust
479
+ // recovery for COOP-severed deployments.
480
+ //
481
+ // We deliberately do NOT route through `initializeSDLCServerClient`
482
+ // here — that would do a top-level redirect on `!isAuthorized` and
483
+ // lose all the in-memory editor state this whole feature exists to
484
+ // protect. `suppressAutoReAuth` is set for the duration of the
485
+ // verify so that a 401 raised by the verify GET itself can't recurse
486
+ // back into `handleSDLCUnauthorized` (which would return the still-
487
+ // in-flight outer promise and deadlock).
488
+ this.isSDLCServerInitialized = false;
489
+ this.suppressAutoReAuth = true;
490
+ flowResult(this.verifySDLCSessionAfterReAuth())
491
+ .then((ok) => {
492
+ this.suppressAutoReAuth = false;
493
+ if (ok) {
494
+ // reset the per-episode flags so the next failure episode
495
+ // can auto-attempt re-auth and warn the user again
496
+ this.autoPopupReAuthAttempted = false;
497
+ this.manualReAuthNotifiedForEpisode = false;
498
+ this.popupReAuthState.pass();
499
+ this.applicationStore.notificationService.notifySuccess(
500
+ `Successfully re-authenticated with the SDLC server.`,
501
+ );
502
+ } else {
503
+ this.popupReAuthState.fail();
504
+ if (signalled) {
505
+ // The popup explicitly told us it completed, yet the SDLC
506
+ // server still reports the session as unauthorized. That's
507
+ // a real problem the user should know about (wrong identity
508
+ // chosen in the popup, third-party-cookie / SameSite issue,
509
+ // ToS race, etc.). Surface a loud error.
510
+ this.applicationStore.notificationService.notifyError(
511
+ `Re-authentication popup completed but the SDLC session is still not authorized. Please reload the page to start a fresh session.`,
512
+ );
513
+ }
514
+ // !signalled + verify failed is indistinguishable from a
515
+ // deliberate user-cancel — stay quiet. The auto-trigger
516
+ // caller (`handleSDLCUnauthorized`) will fire a single
517
+ // "click the shield to retry" hint if this was an auto
518
+ // attempt; the manual button caller simply gets `false` back.
519
+ }
520
+ resolve(ok);
521
+ })
522
+ .catch((error: unknown) => {
523
+ this.suppressAutoReAuth = false;
524
+ assertErrorThrown(error);
525
+ this.popupReAuthState.fail();
526
+ this.applicationStore.logService.error(
527
+ LogEvent.create(LEGEND_STUDIO_APP_EVENT.SDLC_MANAGER_FAILURE),
528
+ error,
529
+ );
530
+ this.applicationStore.notificationService.notifyError(error);
531
+ resolve(false);
532
+ });
533
+ };
534
+
535
+ const onMessage = (event: MessageEvent): void => {
536
+ // strict origin + payload-shape check
537
+ if (event.origin !== callbackOrigin) {
538
+ return;
539
+ }
540
+ const data = event.data as { type?: unknown } | null;
541
+ if (!data || data.type !== 'SDLC_REAUTH_DONE') {
542
+ return;
543
+ }
544
+ finalize(true);
545
+ };
546
+
547
+ window.addEventListener('message', onMessage);
548
+
549
+ // Fallback: if the user closes the popup without completing auth,
550
+ // we still want to release the in-progress state.
551
+ closedPoll = setInterval(() => {
552
+ if (popup.closed) {
553
+ finalize(false);
554
+ }
555
+ }, 500);
556
+ });
557
+ this.inFlightReAuth = promise;
558
+ try {
559
+ return await promise;
560
+ } finally {
561
+ // Clear the slot AFTER awaiting so all concurrent callers (which got
562
+ // `this.inFlightReAuth` back from the early-return branch) resolve
563
+ // against the same promise instance. Subsequent attempts then start
564
+ // a fresh popup.
565
+ this.inFlightReAuth = undefined;
566
+ }
567
+ }
568
+
569
+ /**
570
+ * Shared post-auth bootstrap: ToS gate, platforms, features. Plain
571
+ * generator (not a flow) — delegated to from both `initializeSDLCServerClient`
572
+ * (the boot-time flow) and `verifySDLCSessionAfterReAuth` (the popup-success
573
+ * flow) via `yield*`. Caller is responsible for asserting `isSDLCAuthorized`
574
+ * beforehand.
575
+ */
576
+ private *bootstrapSDLCSessionAfterAuth(): GeneratorFn<void> {
577
+ // check terms of service agreement status
578
+ this.SDLCServerTermsOfServicesUrlsToView =
579
+ (yield this.sdlcServerClient.hasAcceptedTermsOfService()) as string[];
580
+ if (this.SDLCServerTermsOfServicesUrlsToView.length) {
581
+ this.applicationStore.alertService.setActionAlertInfo({
582
+ message: `Please read and accept the SDLC servers' terms of service`,
583
+ prompt: `Click 'Done' when you have accepted all the terms`,
584
+ type: ActionAlertType.CAUTION,
585
+ actions: [
586
+ {
587
+ label: 'See terms of services',
588
+ default: true,
589
+ handler: (): void =>
590
+ this.SDLCServerTermsOfServicesUrlsToView.forEach((url) =>
591
+ this.applicationStore.navigationService.navigator.visitAddress(
592
+ url,
593
+ ),
594
+ ),
595
+ type: ActionAlertActionType.PROCEED,
596
+ },
597
+ {
598
+ label: 'Done',
599
+ type: ActionAlertActionType.PROCEED_WITH_CAUTION,
600
+ handler: (): void => {
601
+ this.dismissSDLCServerTermsOfServicesAlert();
602
+ this.applicationStore.navigationService.navigator.reload();
603
+ },
604
+ },
605
+ ],
606
+ });
607
+ }
608
+
609
+ // fetch server features config and platforms
610
+ yield this.sdlcServerClient.fetchServerPlatforms();
611
+ yield this.sdlcServerClient.fetchServerFeaturesConfiguration();
612
+
613
+ // the sdlc server client is authorized and initialized
614
+ this.isSDLCServerInitialized = true;
615
+ }
616
+
617
+ /**
618
+ * Verify the SDLC session after a popup re-auth attempt completes, and
619
+ * re-bootstrap the session in place if it took. Returns `true` if the
620
+ * session is now usable, `false` if the SDLC server still reports the
621
+ * session as unauthorized (e.g. cookies didn't actually land, user authed
622
+ * against the wrong identity).
623
+ *
624
+ * Unlike `initializeSDLCServerClient`, this does NOT do a top-level
625
+ * redirect on `!isAuthorized` — preserving in-memory editor state is the
626
+ * entire reason the popup flow exists.
627
+ */
628
+ private *verifySDLCSessionAfterReAuth(): GeneratorFn<boolean> {
629
+ this.isSDLCAuthorized =
630
+ (yield this.sdlcServerClient.isAuthorized()) as boolean;
631
+ if (!this.isSDLCAuthorized) {
632
+ return false;
633
+ }
634
+ yield* this.bootstrapSDLCSessionAfterAuth();
635
+ return true;
636
+ }
637
+
209
638
  private *initializeSDLCServerClient(): GeneratorFn<void> {
210
639
  try {
211
640
  this.isSDLCAuthorized =
@@ -219,46 +648,8 @@ export class LegendStudioBaseStore {
219
648
  ),
220
649
  );
221
650
  } else {
222
- // Only proceed intialization after passing authorization check
223
-
224
- // check terms of service agreement status
225
- this.SDLCServerTermsOfServicesUrlsToView =
226
- (yield this.sdlcServerClient.hasAcceptedTermsOfService()) as string[];
227
- if (this.SDLCServerTermsOfServicesUrlsToView.length) {
228
- this.applicationStore.alertService.setActionAlertInfo({
229
- message: `Please read and accept the SDLC servers' terms of service`,
230
- prompt: `Click 'Done' when you have accepted all the terms`,
231
- type: ActionAlertType.CAUTION,
232
- actions: [
233
- {
234
- label: 'See terms of services',
235
- default: true,
236
- handler: (): void =>
237
- this.SDLCServerTermsOfServicesUrlsToView.forEach((url) =>
238
- this.applicationStore.navigationService.navigator.visitAddress(
239
- url,
240
- ),
241
- ),
242
- type: ActionAlertActionType.PROCEED,
243
- },
244
- {
245
- label: 'Done',
246
- type: ActionAlertActionType.PROCEED_WITH_CAUTION,
247
- handler: (): void => {
248
- this.dismissSDLCServerTermsOfServicesAlert();
249
- this.applicationStore.navigationService.navigator.reload();
250
- },
251
- },
252
- ],
253
- });
254
- }
255
-
256
- // fetch server features config and platforms
257
- yield this.sdlcServerClient.fetchServerPlatforms();
258
- yield this.sdlcServerClient.fetchServerFeaturesConfiguration();
259
-
260
- // the sdlc server client is authorized and initialized
261
- this.isSDLCServerInitialized = true;
651
+ // Only proceed initialization after passing authorization check
652
+ yield* this.bootstrapSDLCSessionAfterAuth();
262
653
  }
263
654
  } catch (error) {
264
655
  assertErrorThrown(error);