@hexclave/next 1.0.75 → 1.0.76

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.
Files changed (114) hide show
  1. package/dist/{admin-app-impl-sTQD2d-3.d.ts → admin-app-impl-CsH-gVGz.d.ts} +7 -7
  2. package/dist/{admin-app-impl-sTQD2d-3.d.ts.map → admin-app-impl-CsH-gVGz.d.ts.map} +1 -1
  3. package/dist/components/api-key-dialogs.d.ts +1 -1
  4. package/dist/components/elements/sidebar-layout.js +1 -1
  5. package/dist/dev-tool/dev-tool-core.d.ts.map +1 -1
  6. package/dist/dev-tool/dev-tool-core.js +2 -2
  7. package/dist/dev-tool/dev-tool-core.js.map +1 -1
  8. package/dist/dev-tool/index.d.ts.map +1 -1
  9. package/dist/dev-tool/index.js +2 -8
  10. package/dist/dev-tool/index.js.map +1 -1
  11. package/dist/esm/components/api-key-dialogs.d.ts +1 -1
  12. package/dist/esm/components/elements/sidebar-layout.js +1 -1
  13. package/dist/esm/dev-tool/dev-tool-core.d.ts.map +1 -1
  14. package/dist/esm/dev-tool/dev-tool-core.js +1 -1
  15. package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
  16. package/dist/esm/dev-tool/index.d.ts.map +1 -1
  17. package/dist/esm/dev-tool/index.js +2 -8
  18. package/dist/esm/dev-tool/index.js.map +1 -1
  19. package/dist/esm/generated/quetzal-translations.d.ts +2 -2
  20. package/dist/esm/in-page-ui/dev-environment.d.ts +5 -0
  21. package/dist/esm/in-page-ui/dev-environment.d.ts.map +1 -0
  22. package/dist/esm/in-page-ui/dev-environment.js +18 -0
  23. package/dist/esm/in-page-ui/dev-environment.js.map +1 -0
  24. package/dist/esm/in-page-ui/issue-card.d.ts +52 -0
  25. package/dist/esm/in-page-ui/issue-card.d.ts.map +1 -0
  26. package/dist/esm/in-page-ui/issue-card.js +392 -0
  27. package/dist/esm/in-page-ui/issue-card.js.map +1 -0
  28. package/dist/esm/in-page-ui/issue-card.test.d.ts +1 -0
  29. package/dist/esm/in-page-ui/issue-card.test.js +60 -0
  30. package/dist/esm/in-page-ui/issue-card.test.js.map +1 -0
  31. package/dist/esm/in-page-ui/logo.d.ts +5 -0
  32. package/dist/esm/in-page-ui/logo.d.ts.map +1 -0
  33. package/dist/esm/in-page-ui/logo.js +6 -0
  34. package/dist/esm/in-page-ui/logo.js.map +1 -0
  35. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +4 -4
  36. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  37. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +140 -15
  38. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  39. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +2 -2
  40. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  41. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +95 -12
  42. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  43. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +2 -2
  44. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js +1 -0
  45. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js.map +1 -1
  46. package/dist/esm/pushed-config-error-overlay/index.d.ts.map +1 -1
  47. package/dist/esm/pushed-config-error-overlay/index.js +42 -343
  48. package/dist/esm/pushed-config-error-overlay/index.js.map +1 -1
  49. package/dist/esm/setup-error-overlay/index.d.ts +16 -0
  50. package/dist/esm/setup-error-overlay/index.d.ts.map +1 -0
  51. package/dist/esm/setup-error-overlay/index.js +106 -0
  52. package/dist/esm/setup-error-overlay/index.js.map +1 -0
  53. package/dist/esm/setup-error-overlay/index.test.d.ts +1 -0
  54. package/dist/esm/setup-error-overlay/index.test.js +125 -0
  55. package/dist/esm/setup-error-overlay/index.test.js.map +1 -0
  56. package/dist/generated/quetzal-translations.d.ts +1 -1
  57. package/dist/{hexclave-provider-BKxi3Vy-.d.ts → hexclave-provider-BA97jMlx.d.ts} +2 -2
  58. package/dist/{hexclave-provider-BKxi3Vy-.d.ts.map → hexclave-provider-BA97jMlx.d.ts.map} +1 -1
  59. package/dist/in-page-ui/dev-environment.d.ts +5 -0
  60. package/dist/in-page-ui/dev-environment.d.ts.map +1 -0
  61. package/dist/in-page-ui/dev-environment.js +19 -0
  62. package/dist/in-page-ui/dev-environment.js.map +1 -0
  63. package/dist/in-page-ui/issue-card.d.ts +52 -0
  64. package/dist/in-page-ui/issue-card.d.ts.map +1 -0
  65. package/dist/in-page-ui/issue-card.js +397 -0
  66. package/dist/in-page-ui/issue-card.js.map +1 -0
  67. package/dist/in-page-ui/issue-card.test.d.ts +1 -0
  68. package/dist/in-page-ui/issue-card.test.js +59 -0
  69. package/dist/in-page-ui/issue-card.test.js.map +1 -0
  70. package/dist/in-page-ui/logo.d.ts +5 -0
  71. package/dist/in-page-ui/logo.d.ts.map +1 -0
  72. package/dist/in-page-ui/logo.js +7 -0
  73. package/dist/in-page-ui/logo.js.map +1 -0
  74. package/dist/index.d.ts +1 -1
  75. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +1 -1
  76. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +140 -15
  77. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  78. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -1
  79. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +94 -11
  80. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  81. package/dist/lib/hexclave-app/apps/implementations/common.js +2 -2
  82. package/dist/lib/hexclave-app/apps/implementations/index.d.ts +1 -1
  83. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js +1 -0
  84. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js.map +1 -1
  85. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
  86. package/dist/providers/hexclave-provider.d.ts +1 -1
  87. package/dist/providers/translation-provider.d.ts +1 -1
  88. package/dist/pushed-config-error-overlay/index.d.ts.map +1 -1
  89. package/dist/pushed-config-error-overlay/index.js +41 -342
  90. package/dist/pushed-config-error-overlay/index.js.map +1 -1
  91. package/dist/{quetzal-translations-XeH3zQ9l.d.ts → quetzal-translations-CK45_4Dm.d.ts} +3 -3
  92. package/dist/{quetzal-translations-XeH3zQ9l.d.ts.map → quetzal-translations-CK45_4Dm.d.ts.map} +1 -1
  93. package/dist/setup-error-overlay/index.d.ts +16 -0
  94. package/dist/setup-error-overlay/index.d.ts.map +1 -0
  95. package/dist/setup-error-overlay/index.js +108 -0
  96. package/dist/setup-error-overlay/index.js.map +1 -0
  97. package/dist/setup-error-overlay/index.test.d.ts +1 -0
  98. package/dist/setup-error-overlay/index.test.js +124 -0
  99. package/dist/setup-error-overlay/index.test.js.map +1 -0
  100. package/dist/{translation-provider-WOaZXqAz.d.ts → translation-provider-DQ3BJepJ.d.ts} +2 -2
  101. package/dist/{translation-provider-WOaZXqAz.d.ts.map → translation-provider-DQ3BJepJ.d.ts.map} +1 -1
  102. package/package.json +4 -4
  103. package/src/dev-tool/dev-tool-core.ts +1 -4
  104. package/src/dev-tool/index.ts +4 -15
  105. package/src/in-page-ui/dev-environment.ts +33 -0
  106. package/src/in-page-ui/issue-card.test.ts +67 -0
  107. package/src/in-page-ui/issue-card.ts +500 -0
  108. package/src/in-page-ui/logo.ts +8 -0
  109. package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +158 -0
  110. package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +125 -17
  111. package/src/lib/hexclave-app/apps/implementations/redirect-page-urls.test.ts +1 -0
  112. package/src/pushed-config-error-overlay/index.ts +45 -386
  113. package/src/setup-error-overlay/index.test.ts +133 -0
  114. package/src/setup-error-overlay/index.ts +150 -0
@@ -4,11 +4,16 @@
4
4
  //===========================================
5
5
  import { describe, expect, it, vi } from "vitest";
6
6
  import { AccessToken } from "@hexclave/shared/dist/sessions";
7
+ import { HexclaveSetupError } from "@hexclave/shared/dist/utils/errors";
7
8
  import { Store } from "@hexclave/shared/dist/utils/stores";
8
9
  import { hexclaveAppInternalsSymbol } from "../../common";
9
10
  import { StackClientApp } from "../interfaces/client-app";
10
11
  import { planRedirectToHandler } from "./redirect-page-urls";
11
12
 
13
+ // Every app in this file is constructed with `devTool: false`. The tests install a mock window and document, which makes
14
+ // the SDK believe it is in a browser, and mounting the dev tool kicks off a background `getProject()` against a backend
15
+ // that does not exist here. That fetch never settles, and because it runs inside the global store lock, it blocks every
16
+ // later `withWriteLock` caller (`_signOut`, most visibly) for the rest of the test file.
12
17
  function createAccessTokenString(refreshTokenId: string): string {
13
18
  const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
14
19
  const nowSeconds = Math.floor(Date.now() / 1000);
@@ -54,6 +59,28 @@ function createMockDocument(): Document {
54
59
  } as any;
55
60
  }
56
61
 
62
+ /**
63
+ * Installs a stand-in for `window` with only the parts the code under test touches. `globalThis.window` is typed as a
64
+ * full `Window`, which a hand-written stub can never be, so the assignment goes through `Reflect.set` — the stub's own
65
+ * shape stays checked, unlike with a cast on the object literal.
66
+ */
67
+ function setMockWindow(mockWindow: {
68
+ location: { href: string, replace?: (url: string) => void },
69
+ }): void {
70
+ Reflect.set(globalThis, "window", mockWindow);
71
+ }
72
+
73
+ /**
74
+ * Calls one of the app's `protected` methods, which are part of the flows under test but not of the public SDK surface.
75
+ */
76
+ function callProtectedMethod(app: StackClientApp<true>, methodName: string, ...args: unknown[]): Promise<unknown> {
77
+ const method = Reflect.get(app, methodName);
78
+ if (typeof method !== "function") {
79
+ throw new Error(`Expected StackClientApp to have a ${methodName} method in tests.`);
80
+ }
81
+ return method.apply(app, args);
82
+ }
83
+
57
84
  describe("StackClientApp cross-domain auth", () => {
58
85
  it("keeps noRedirectBack same-domain redirects free of redirect-back state", async () => {
59
86
  const redirectPlan = await planRedirectToHandler({
@@ -145,6 +172,7 @@ describe("StackClientApp cross-domain auth", () => {
145
172
  signIn: "/handler/sign-in",
146
173
  },
147
174
  noAutomaticPrefetch: true,
175
+ devTool: false,
148
176
  });
149
177
 
150
178
  const redirectUrl = await clientApp[hexclaveAppInternalsSymbol].getRedirectToHandlerUrl("signIn");
@@ -173,6 +201,7 @@ describe("StackClientApp cross-domain auth", () => {
173
201
  },
174
202
  redirectMethod: "none",
175
203
  noAutomaticPrefetch: true,
204
+ devTool: false,
176
205
  });
177
206
 
178
207
  const clientInterface = Reflect.get(clientApp, "_interface");
@@ -253,6 +282,7 @@ describe("StackClientApp cross-domain auth", () => {
253
282
  default: { type: "hosted" },
254
283
  },
255
284
  noAutomaticPrefetch: true,
285
+ devTool: false,
256
286
  });
257
287
  const outerState = "outer-cross-domain-state";
258
288
  const outerCodeChallenge = "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~";
@@ -314,6 +344,7 @@ describe("StackClientApp cross-domain auth", () => {
314
344
  default: { type: "hosted" },
315
345
  },
316
346
  noAutomaticPrefetch: true,
347
+ devTool: false,
317
348
  });
318
349
  const tokenStore = Reflect.get(clientApp, "_memoryTokenStore");
319
350
  if (!(tokenStore instanceof Store)) {
@@ -392,6 +423,7 @@ describe("StackClientApp cross-domain auth", () => {
392
423
  tokenStore: "cookie",
393
424
  redirectMethod: "none",
394
425
  noAutomaticPrefetch: true,
426
+ devTool: false,
395
427
  });
396
428
  const clientInterface = Reflect.get(clientApp, "_interface");
397
429
  const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
@@ -466,6 +498,7 @@ describe("StackClientApp cross-domain auth", () => {
466
498
  tokenStore: "memory",
467
499
  redirectMethod: "window",
468
500
  noAutomaticPrefetch: true,
501
+ devTool: false,
469
502
  });
470
503
 
471
504
  globalThis.document = createMockDocument();
@@ -499,6 +532,122 @@ describe("StackClientApp cross-domain auth", () => {
499
532
  }
500
533
  });
501
534
 
535
+ it("reports malformed nested cross-domain auth URLs as setup errors", async () => {
536
+ const projectId = "00000000-0000-4000-8000-000000000012";
537
+ const previousWindow = globalThis.window;
538
+ const previousDocument = globalThis.document;
539
+ globalThis.document = createMockDocument();
540
+
541
+ // A URL the flow cannot even parse is as fatal as an untrusted one, so it has to reach the developer the same way
542
+ // instead of escaping as a bare TypeError from `new URL()`, which the overlay only shows during development.
543
+ // The two branches of the handler want opposite session states: on the provider side (a.com) a query-derived failure
544
+ // only becomes a setup error when the handoff belongs to the current session, while the requesting side (b.com) only
545
+ // bounces when the current session is *not* the one that was asked for.
546
+ const malformedCases = [
547
+ {
548
+ description: "redirect URI",
549
+ currentRefreshTokenId: "source-refresh-token-id",
550
+ params: { redirect_uri: "not-a-url", state: "nested-state", code_challenge: "nested-code-challenge" },
551
+ },
552
+ {
553
+ description: "after-callback redirect URL",
554
+ currentRefreshTokenId: "source-refresh-token-id",
555
+ params: {
556
+ redirect_uri: "https://source.example.test/handler/account-settings",
557
+ state: "nested-state",
558
+ code_challenge: "nested-code-challenge",
559
+ after_callback_redirect_url: "http://[",
560
+ },
561
+ },
562
+ {
563
+ description: "callback URL",
564
+ currentRefreshTokenId: null,
565
+ params: { stack_nested_cross_domain_auth_callback_url: "http://[" },
566
+ },
567
+ ];
568
+
569
+ try {
570
+ for (const malformedCase of malformedCases) {
571
+ const currentUrl = new URL("https://target.example.test/nested-provider");
572
+ currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-refresh-token-id");
573
+ for (const [key, value] of Object.entries(malformedCase.params)) {
574
+ currentUrl.searchParams.set(key, value);
575
+ }
576
+ // Each app is constructed with the mock window uninstalled: the constructor's automatic side effects expect a
577
+ // real window, and a construction-time nested-auth URL would also schedule a second, unawaited run of the
578
+ // handler that keeps failing in the background and leaks into whichever test runs next.
579
+ globalThis.window = previousWindow;
580
+ const clientApp = new StackClientApp({
581
+ baseUrl: "http://localhost:12345",
582
+ projectId,
583
+ publishableClientKey: "stack-pk-test",
584
+ tokenStore: "memory",
585
+ redirectMethod: "window",
586
+ noAutomaticPrefetch: true,
587
+ devTool: false,
588
+ });
589
+ setMockWindow({ location: { href: currentUrl.toString() } });
590
+ Reflect.set(clientApp, "_fetchCurrentRefreshTokenIdIfSignedIn", async () => malformedCase.currentRefreshTokenId);
591
+ Reflect.set(clientApp, "_isTrusted", async () => true);
592
+
593
+ const nestedAuthPromise = callProtectedMethod(clientApp, "_maybeHandleNestedCrossDomainAuth");
594
+ await expect(nestedAuthPromise).rejects.toThrowError(new RegExp(`${malformedCase.description} .* is not a valid absolute URL`));
595
+ await expect(nestedAuthPromise).rejects.toSatisfy((error: unknown) => HexclaveSetupError.isSetupError(error));
596
+ }
597
+ } finally {
598
+ globalThis.window = previousWindow;
599
+ globalThis.document = previousDocument;
600
+ }
601
+ });
602
+
603
+ it("keeps nested cross-domain auth failures off the page for a handoff that does not own the session", async () => {
604
+ const projectId = "00000000-0000-4000-8000-000000000013";
605
+ const previousWindow = globalThis.window;
606
+ const previousDocument = globalThis.document;
607
+ globalThis.document = createMockDocument();
608
+
609
+ // Setup errors are shown in production, so a link anybody can craft must not be able to put a card on a healthy
610
+ // app's page: with a refresh-token ID that is not the current session's, every failure below stays an ordinary error
611
+ // that only captureError sees, even though the same URL from the right session would render a card.
612
+ const craftedRedirectUris = [
613
+ "https://evil.example.test/handler/account-settings", // untrusted
614
+ "not-a-url", // malformed
615
+ ];
616
+
617
+ try {
618
+ for (const craftedRedirectUri of craftedRedirectUris) {
619
+ const currentUrl = new URL("https://target.example.test/nested-provider");
620
+ currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "attacker-chosen-refresh-token-id");
621
+ currentUrl.searchParams.set("redirect_uri", craftedRedirectUri);
622
+ currentUrl.searchParams.set("state", "nested-state");
623
+ currentUrl.searchParams.set("code_challenge", "nested-code-challenge");
624
+
625
+ globalThis.window = previousWindow;
626
+ const clientApp = new StackClientApp({
627
+ baseUrl: "http://localhost:12345",
628
+ projectId,
629
+ publishableClientKey: "stack-pk-test",
630
+ tokenStore: "memory",
631
+ redirectMethod: "window",
632
+ noAutomaticPrefetch: true,
633
+ devTool: false,
634
+ });
635
+ setMockWindow({ location: { href: currentUrl.toString() } });
636
+ Reflect.set(clientApp, "_fetchCurrentRefreshTokenIdIfSignedIn", async () => "this-apps-refresh-token-id");
637
+ Reflect.set(clientApp, "_isTrusted", async () => false);
638
+
639
+ const nestedAuthPromise = callProtectedMethod(clientApp, "_maybeHandleNestedCrossDomainAuth");
640
+ await expect(nestedAuthPromise).rejects.toThrowError(/does not match the requested refresh token ID/);
641
+ await expect(nestedAuthPromise).rejects.toSatisfy(
642
+ (error: unknown) => !HexclaveSetupError.isSetupError(error),
643
+ );
644
+ }
645
+ } finally {
646
+ globalThis.window = previousWindow;
647
+ globalThis.document = previousDocument;
648
+ }
649
+ });
650
+
502
651
  it("passes the construction-time URL to the nested cross-domain auth handler", async () => {
503
652
  const projectId = "00000000-0000-4000-8000-000000000009";
504
653
  const previousWindow = globalThis.window;
@@ -529,6 +678,7 @@ describe("StackClientApp cross-domain auth", () => {
529
678
  tokenStore: "memory",
530
679
  redirectMethod: "window",
531
680
  noAutomaticPrefetch: true,
681
+ devTool: false,
532
682
  });
533
683
 
534
684
  // Simulate consumeOAuthCallbackQueryParams stripping code+state before microtasks run.
@@ -597,6 +747,7 @@ describe("StackClientApp cross-domain auth", () => {
597
747
  default: { type: "hosted" },
598
748
  },
599
749
  noAutomaticPrefetch: true,
750
+ devTool: false,
600
751
  });
601
752
 
602
753
  await new Promise((resolve) => setTimeout(resolve, 0));
@@ -629,6 +780,7 @@ describe("StackClientApp cross-domain auth", () => {
629
780
  signOut: { type: "hosted" },
630
781
  },
631
782
  noAutomaticPrefetch: true,
783
+ devTool: false,
632
784
  });
633
785
  const signOutSpy = vi.spyOn(clientApp, "signOut").mockRejectedValue(new Error("INTENTIONAL_TEST_ABORT"));
634
786
 
@@ -651,6 +803,7 @@ describe("StackClientApp cross-domain auth", () => {
651
803
  default: { type: "hosted" },
652
804
  },
653
805
  noAutomaticPrefetch: true,
806
+ devTool: false,
654
807
  });
655
808
 
656
809
  expect(() => clientApp.urls.signIn).toThrowError(/app\.urls\.signIn cannot be used when this app is configured to use hosted components.*Use app\.redirectToSignIn\(\) instead/s);
@@ -669,6 +822,7 @@ describe("StackClientApp cross-domain auth", () => {
669
822
  handler: "/custom-handler",
670
823
  },
671
824
  noAutomaticPrefetch: true,
825
+ devTool: false,
672
826
  });
673
827
 
674
828
  expect(clientApp.urls.signIn).toBe("/custom-handler/sign-in");
@@ -685,6 +839,7 @@ describe("StackClientApp cross-domain auth", () => {
685
839
  default: { type: "hosted" },
686
840
  },
687
841
  noAutomaticPrefetch: true,
842
+ devTool: false,
688
843
  });
689
844
  const currentHref = "https://demo.stack-auth.com/settings?tab=profile";
690
845
 
@@ -754,6 +909,7 @@ describe("StackClientApp cross-domain auth", () => {
754
909
  tokenStore: "memory",
755
910
  redirectMethod: "window",
756
911
  noAutomaticPrefetch: true,
912
+ devTool: false,
757
913
  });
758
914
 
759
915
  // Intermediate hops (MFA, magic link, OAuth round-trips, ...) dropped the query params.
@@ -822,6 +978,7 @@ describe("StackClientApp cross-domain auth", () => {
822
978
  tokenStore: "memory",
823
979
  redirectMethod: "window",
824
980
  noAutomaticPrefetch: true,
981
+ devTool: false,
825
982
  });
826
983
  const crossDomainAuthorizeRedirect = "https://demo.example.test/handler/oauth-callback?code=minted-code&state=mirror-handoff-state";
827
984
  const createCrossDomainAuthRedirectUrlSpy = vi
@@ -858,6 +1015,7 @@ describe("StackClientApp cross-domain auth", () => {
858
1015
  tokenStore: "memory",
859
1016
  redirectMethod: "none",
860
1017
  noAutomaticPrefetch: true,
1018
+ devTool: false,
861
1019
  });
862
1020
  const oldAccessToken = createAccessTokenString("old-refresh-token-id");
863
1021
  const refreshedOldAccessToken = createAccessTokenString("refreshed-old-refresh-token-id");
@@ -25,7 +25,7 @@ import { InternalSession } from "@hexclave/shared/dist/sessions";
25
25
  import { decodeBase32, decodeBase64, encodeBase32, encodeBase64 } from "@hexclave/shared/dist/utils/bytes";
26
26
  import { scrambleDuringCompileTime } from "@hexclave/shared/dist/utils/compile-time";
27
27
  import { isBrowserLike } from "@hexclave/shared/dist/utils/env";
28
- import { HexclaveAssertionError, captureError, throwErr } from "@hexclave/shared/dist/utils/errors";
28
+ import { HexclaveAssertionError, HexclaveSetupError, captureError, throwErr } from "@hexclave/shared/dist/utils/errors";
29
29
  import { parseJson } from "@hexclave/shared/dist/utils/json";
30
30
  import { DependenciesMap } from "@hexclave/shared/dist/utils/maps";
31
31
  import { ProviderType } from "@hexclave/shared/dist/utils/oauth";
@@ -76,6 +76,7 @@ import { useAsyncCache } from "./common";
76
76
  import { mountClickmapOverlay } from "../../../../clickmap";
77
77
  import { mountDevTool } from "../../../../dev-tool";
78
78
  import { mountPushedConfigErrorOverlay } from "../../../../pushed-config-error-overlay";
79
+ import { showSetupErrorOverlay } from "../../../../setup-error-overlay";
79
80
 
80
81
  let isReactServer = false;
81
82
  import * as sc from "@hexclave/sc";
@@ -98,6 +99,67 @@ const nestedCrossDomainAuthQueryParams = {
98
99
  afterCallbackRedirectUrl: oauthAfterCallbackRedirectUrlQueryParam,
99
100
  } as const;
100
101
 
102
+ /**
103
+ * An auth flow can only ever send the user to a URL that the project trusts, so an untrusted one means the flow is dead
104
+ * until someone changes the project's configuration. Describe the problem in terms of that configuration rather than in
105
+ * terms of the flow's internals, which nobody outside this file knows about.
106
+ */
107
+ function createUntrustedUrlError(options: {
108
+ /** Kept verbatim from the throw site, so the message stays greppable for whoever already has it in their logs. */
109
+ message: string,
110
+ url: string,
111
+ projectId: string,
112
+ }): HexclaveSetupError {
113
+ const origin = createUrlIfValid(options.url)?.origin ?? options.url;
114
+ return new HexclaveSetupError({
115
+ title: "A domain in your authentication flow is not one of your project's trusted domains",
116
+ message: options.message,
117
+ howToFix: [
118
+ `If ${origin} is yours, add it to your project's trusted domains in the Hexclave dashboard, under Domains.`,
119
+ "Protocol, domain and port must all match a trusted domain exactly; a wildcard like https://*.example.com matches only one subdomain level.",
120
+ `If ${origin} is not yours, do not add it \u2014 somebody may be trying to redirect your users' credentials to it.`,
121
+ ],
122
+ extraData: {
123
+ url: options.url,
124
+ projectId: options.projectId,
125
+ },
126
+ });
127
+ }
128
+
129
+ function createUntrustedNestedCrossDomainUrlError(options: {
130
+ urlDescription: string,
131
+ url: string,
132
+ projectId: string,
133
+ }): HexclaveSetupError {
134
+ return createUntrustedUrlError({
135
+ message: `Nested cross-domain auth ${options.urlDescription} ${options.url} is not trusted.`,
136
+ url: options.url,
137
+ projectId: options.projectId,
138
+ });
139
+ }
140
+
141
+ /**
142
+ * A URL that the auth flow cannot even parse is just as fatal as an untrusted one, and just as much a configuration
143
+ * problem: something in the developer's setup put a relative or malformed value where an absolute URL belongs.
144
+ */
145
+ function createMalformedNestedCrossDomainUrlError(options: {
146
+ urlDescription: string,
147
+ url: string,
148
+ }): HexclaveSetupError {
149
+ return new HexclaveSetupError({
150
+ title: "A URL in your authentication flow is not a valid absolute URL",
151
+ message: `Nested cross-domain auth ${options.urlDescription} ${options.url} is not a valid absolute URL.`,
152
+ howToFix: [
153
+ "Handler URLs and after-auth redirect URLs must be absolute, including their protocol, like https://example.com/handler/oauth-callback.",
154
+ "Check the handler URLs configured for your project and the redirect URLs you pass to Hexclave's sign-in and sign-up methods.",
155
+ "If none of those look wrong, check whether a proxy or rewrite in front of your app is rewriting the authentication flow's query parameters.",
156
+ ],
157
+ extraData: {
158
+ url: options.url,
159
+ },
160
+ });
161
+ }
162
+
101
163
  function getRedirectHelperInstruction(handlerName: string): string {
102
164
  if (handlerName === "handler") {
103
165
  return "Use a page-specific redirect helper such as app.redirectToSignIn() instead.";
@@ -788,6 +850,9 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
788
850
  // Startup auth transitions gate session finality, but malformed nested-auth URLs should
789
851
  // not make every app-level session consumer fail while the tracker is cleaning up.
790
852
  captureError("pending-auth-resolution-failed", error);
853
+ // Swallowing the error here means the flow just stops, with no sign of it on the page; setup errors
854
+ // (untrusted domains, most commonly) would otherwise be invisible to whoever has to fix them.
855
+ showSetupErrorOverlay(error); // THIS_LINE_PLATFORM js-like
791
856
  }
792
857
  })();
793
858
  this._pendingAuthResolutionPromises.push(promise);
@@ -1014,24 +1079,52 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1014
1079
  if ((currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.codeChallengeMethod) ?? "S256") !== "S256") {
1015
1080
  throw new HexclaveAssertionError("Nested cross-domain auth only supports S256 PKCE");
1016
1081
  }
1017
- if (isRelative(redirectUri)) {
1018
- throw new Error("Nested cross-domain auth redirect URI must be absolute.");
1082
+ const validateHandoffOwnership = async (): Promise<void> => {
1083
+ const currentRefreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
1084
+ if (currentRefreshTokenId !== refreshTokenId) {
1085
+ throw new Error("Nested cross-domain auth source session does not match the requested refresh token ID.");
1086
+ }
1087
+ };
1088
+ // Everything derived from these query params is attacker-controllable, and setup errors are rendered as a card on
1089
+ // the page, so a failure may only become a displayable setup error once the handoff is known to belong to this
1090
+ // session. The check runs on the failure paths rather than up front so the happy path keeps making exactly the
1091
+ // requests it made before, and so an unowned handoff never costs the session lookup twice.
1092
+ const setupErrorIfHandoffIsOwned = async (setupError: HexclaveSetupError): Promise<HexclaveSetupError> => {
1093
+ await validateHandoffOwnership();
1094
+ return setupError;
1095
+ };
1096
+ const redirectUriUrl = isRelative(redirectUri) ? null : createUrlIfValid(redirectUri);
1097
+ if (redirectUriUrl == null) {
1098
+ throw await setupErrorIfHandoffIsOwned(createMalformedNestedCrossDomainUrlError({
1099
+ urlDescription: "redirect URI",
1100
+ url: redirectUri,
1101
+ }));
1019
1102
  }
1020
- const redirectUriUrl = new URL(redirectUri);
1021
1103
  if (!await this._isTrusted(redirectUriUrl.toString())) {
1022
- throw new Error(`Nested cross-domain auth redirect URI ${redirectUri} is not trusted.`);
1104
+ throw await setupErrorIfHandoffIsOwned(createUntrustedNestedCrossDomainUrlError({
1105
+ urlDescription: "redirect URI",
1106
+ url: redirectUri,
1107
+ projectId: this.projectId,
1108
+ }));
1023
1109
  }
1024
1110
  const afterCallbackRedirectUrlString = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.afterCallbackRedirectUrl);
1025
1111
  const afterCallbackRedirectUrl = afterCallbackRedirectUrlString == null
1026
1112
  ? redirectUriUrl
1027
- : new URL(afterCallbackRedirectUrlString, redirectUriUrl);
1028
- if (!await this._isTrusted(afterCallbackRedirectUrl.toString())) {
1029
- throw new Error(`Nested cross-domain auth after-callback redirect URL ${afterCallbackRedirectUrlString} is not trusted.`);
1113
+ : createUrlIfValid(afterCallbackRedirectUrlString, redirectUriUrl);
1114
+ if (afterCallbackRedirectUrl == null) {
1115
+ throw await setupErrorIfHandoffIsOwned(createMalformedNestedCrossDomainUrlError({
1116
+ urlDescription: "after-callback redirect URL",
1117
+ url: afterCallbackRedirectUrlString ?? "",
1118
+ }));
1030
1119
  }
1031
- const currentRefreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
1032
- if (currentRefreshTokenId !== refreshTokenId) {
1033
- throw new Error("Nested cross-domain auth source session does not match the requested refresh token ID.");
1120
+ if (!await this._isTrusted(afterCallbackRedirectUrl.toString())) {
1121
+ throw await setupErrorIfHandoffIsOwned(createUntrustedNestedCrossDomainUrlError({
1122
+ urlDescription: "after-callback redirect URL",
1123
+ url: afterCallbackRedirectUrlString ?? afterCallbackRedirectUrl.toString(),
1124
+ projectId: this.projectId,
1125
+ }));
1034
1126
  }
1127
+ await validateHandoffOwnership();
1035
1128
  await this._redirectTo({
1036
1129
  url: await this._createCrossDomainAuthRedirectUrl({
1037
1130
  redirectUri: redirectUriUrl.toString(),
@@ -1057,13 +1150,20 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1057
1150
  if (callbackUrlString == null) {
1058
1151
  throw new HexclaveAssertionError("Nested cross-domain auth URL is missing callback URL");
1059
1152
  }
1060
- if (isRelative(callbackUrlString)) {
1061
- throw new Error("Nested cross-domain auth callback URL must be absolute.");
1153
+ const callbackUrl = isRelative(callbackUrlString) ? null : createUrlIfValid(callbackUrlString);
1154
+ if (callbackUrl == null) {
1155
+ throw createMalformedNestedCrossDomainUrlError({
1156
+ urlDescription: "callback URL",
1157
+ url: callbackUrlString,
1158
+ });
1062
1159
  }
1063
- const callbackUrl = new URL(callbackUrlString);
1064
1160
  const isTrusted = await this._isTrusted(callbackUrl.toString());
1065
1161
  if (!isTrusted) {
1066
- throw new Error(`Nested cross-domain auth callback URL ${callbackUrlString} is not trusted.`);
1162
+ throw createUntrustedNestedCrossDomainUrlError({
1163
+ urlDescription: "callback URL",
1164
+ url: callbackUrlString,
1165
+ projectId: this.projectId,
1166
+ });
1067
1167
  }
1068
1168
 
1069
1169
  const afterCallbackRedirectUrl = new URL(currentUrl);
@@ -3016,7 +3116,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
3016
3116
  }
3017
3117
  protected async _redirectIfTrusted(url: string, options?: RedirectToOptions) {
3018
3118
  if (!await this._isTrusted(url)) {
3019
- throw new Error(`Redirect URL ${url} is not trusted; should be relative.`);
3119
+ throw createUntrustedUrlError({
3120
+ message: `Redirect URL ${url} is not trusted; should be relative.`,
3121
+ url,
3122
+ projectId: this.projectId,
3123
+ });
3020
3124
  }
3021
3125
  return await this._redirectTo({ url, ...options });
3022
3126
  }
@@ -3097,7 +3201,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
3097
3201
  })
3098
3202
  : plan.url;
3099
3203
  if (!await this._isTrusted(redirectUrl)) {
3100
- throw new Error(`Redirect URL ${redirectUrl} is not trusted; should be relative.`);
3204
+ throw createUntrustedUrlError({
3205
+ message: `Redirect URL ${redirectUrl} is not trusted; should be relative.`,
3206
+ url: redirectUrl,
3207
+ projectId: this.projectId,
3208
+ });
3101
3209
  }
3102
3210
  return redirectUrl;
3103
3211
  }
@@ -17,6 +17,7 @@ async function plan(options: {
17
17
  ...options,
18
18
  noRedirectBack: options.noRedirectBack === true,
19
19
  localOAuthCallbackUrl,
20
+ rawHomeUrl: "/",
20
21
  getCrossDomainHandoffParams: async () => {
21
22
  throw new Error("Continuation-only redirects must not mint a new cross-domain handoff");
22
23
  },