@hexclave/js 1.0.75 → 1.0.77

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 (150) hide show
  1. package/dist/{admin-app-impl-BhrjJlxj.d.ts → admin-app-impl-CHQtsn4f.d.ts} +11 -9
  2. package/dist/admin-app-impl-CHQtsn4f.d.ts.map +1 -0
  3. package/dist/clickmap/clickmap-core.d.ts +1 -1
  4. package/dist/clickmap/index.d.ts +1 -1
  5. package/dist/{client-app-WOX3pOo1.d.ts → client-app-R5X8wyeV.d.ts} +4 -1
  6. package/dist/client-app-R5X8wyeV.d.ts.map +1 -0
  7. package/dist/dev-tool/dev-tool-core.d.ts +1 -1
  8. package/dist/dev-tool/dev-tool-core.d.ts.map +1 -1
  9. package/dist/dev-tool/dev-tool-core.js +2 -2
  10. package/dist/dev-tool/dev-tool-core.js.map +1 -1
  11. package/dist/dev-tool/index.d.ts +1 -1
  12. package/dist/dev-tool/index.d.ts.map +1 -1
  13. package/dist/dev-tool/index.js +2 -8
  14. package/dist/dev-tool/index.js.map +1 -1
  15. package/dist/esm/dev-tool/dev-tool-core.d.ts.map +1 -1
  16. package/dist/esm/dev-tool/dev-tool-core.js +1 -1
  17. package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
  18. package/dist/esm/dev-tool/index.d.ts.map +1 -1
  19. package/dist/esm/dev-tool/index.js +2 -8
  20. package/dist/esm/dev-tool/index.js.map +1 -1
  21. package/dist/esm/in-page-ui/dev-environment.d.ts +5 -0
  22. package/dist/esm/in-page-ui/dev-environment.d.ts.map +1 -0
  23. package/dist/esm/in-page-ui/dev-environment.js +18 -0
  24. package/dist/esm/in-page-ui/dev-environment.js.map +1 -0
  25. package/dist/esm/in-page-ui/issue-card.d.ts +52 -0
  26. package/dist/esm/in-page-ui/issue-card.d.ts.map +1 -0
  27. package/dist/esm/in-page-ui/issue-card.js +392 -0
  28. package/dist/esm/in-page-ui/issue-card.js.map +1 -0
  29. package/dist/esm/in-page-ui/issue-card.test.d.ts +1 -0
  30. package/dist/esm/in-page-ui/issue-card.test.js +60 -0
  31. package/dist/esm/in-page-ui/issue-card.test.js.map +1 -0
  32. package/dist/esm/in-page-ui/logo.d.ts +5 -0
  33. package/dist/esm/in-page-ui/logo.d.ts.map +1 -0
  34. package/dist/esm/in-page-ui/logo.js +6 -0
  35. package/dist/esm/in-page-ui/logo.js.map +1 -0
  36. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +4 -4
  37. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  38. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js +30 -1
  39. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js.map +1 -1
  40. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +140 -15
  41. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  42. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +5 -3
  43. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  44. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +180 -27
  45. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  46. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.sign-out.test.d.ts +1 -0
  47. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.sign-out.test.js +83 -0
  48. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.sign-out.test.js.map +1 -0
  49. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.untrusted-redirect-card.test.d.ts +1 -0
  50. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.untrusted-redirect-card.test.js +120 -0
  51. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.untrusted-redirect-card.test.js.map +1 -0
  52. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +2 -2
  53. package/dist/esm/lib/hexclave-app/apps/implementations/common.js.map +1 -1
  54. package/dist/esm/lib/hexclave-app/apps/implementations/index.d.ts +1 -1
  55. package/dist/esm/lib/hexclave-app/apps/implementations/index.js +1 -1
  56. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js +1 -0
  57. package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js.map +1 -1
  58. package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
  59. package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
  60. package/dist/esm/lib/hexclave-app/teams/index.d.ts +3 -0
  61. package/dist/esm/lib/hexclave-app/teams/index.d.ts.map +1 -1
  62. package/dist/esm/lib/hexclave-app/teams/index.js.map +1 -1
  63. package/dist/esm/pushed-config-error-overlay/index.d.ts.map +1 -1
  64. package/dist/esm/pushed-config-error-overlay/index.js +42 -343
  65. package/dist/esm/pushed-config-error-overlay/index.js.map +1 -1
  66. package/dist/esm/setup-error-overlay/index.d.ts +16 -0
  67. package/dist/esm/setup-error-overlay/index.d.ts.map +1 -0
  68. package/dist/esm/setup-error-overlay/index.js +106 -0
  69. package/dist/esm/setup-error-overlay/index.js.map +1 -0
  70. package/dist/esm/setup-error-overlay/index.test.d.ts +1 -0
  71. package/dist/esm/setup-error-overlay/index.test.js +125 -0
  72. package/dist/esm/setup-error-overlay/index.test.js.map +1 -0
  73. package/dist/in-page-ui/dev-environment.d.ts +5 -0
  74. package/dist/in-page-ui/dev-environment.d.ts.map +1 -0
  75. package/dist/in-page-ui/dev-environment.js +19 -0
  76. package/dist/in-page-ui/dev-environment.js.map +1 -0
  77. package/dist/in-page-ui/issue-card.d.ts +52 -0
  78. package/dist/in-page-ui/issue-card.d.ts.map +1 -0
  79. package/dist/in-page-ui/issue-card.js +397 -0
  80. package/dist/in-page-ui/issue-card.js.map +1 -0
  81. package/dist/in-page-ui/issue-card.test.d.ts +1 -0
  82. package/dist/in-page-ui/issue-card.test.js +59 -0
  83. package/dist/in-page-ui/issue-card.test.js.map +1 -0
  84. package/dist/in-page-ui/logo.d.ts +5 -0
  85. package/dist/in-page-ui/logo.d.ts.map +1 -0
  86. package/dist/in-page-ui/logo.js +7 -0
  87. package/dist/in-page-ui/logo.js.map +1 -0
  88. package/dist/index.d.ts +1 -1
  89. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +1 -1
  90. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js +29 -0
  91. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js.map +1 -1
  92. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +140 -15
  93. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  94. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +2 -2
  95. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +178 -24
  96. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  97. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.sign-out.test.d.ts +1 -0
  98. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.sign-out.test.js +82 -0
  99. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.sign-out.test.js.map +1 -0
  100. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.untrusted-redirect-card.test.d.ts +1 -0
  101. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.untrusted-redirect-card.test.js +119 -0
  102. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.untrusted-redirect-card.test.js.map +1 -0
  103. package/dist/lib/hexclave-app/apps/implementations/common.js +2 -2
  104. package/dist/lib/hexclave-app/apps/implementations/common.js.map +1 -1
  105. package/dist/lib/hexclave-app/apps/implementations/index.d.ts +1 -1
  106. package/dist/lib/hexclave-app/apps/implementations/index.js +1 -1
  107. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js +1 -0
  108. package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js.map +1 -1
  109. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
  110. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
  111. package/dist/lib/hexclave-app/apps/index.d.ts +1 -1
  112. package/dist/lib/hexclave-app/apps/interfaces/admin-app.d.ts +1 -1
  113. package/dist/lib/hexclave-app/apps/interfaces/client-app.d.ts +1 -1
  114. package/dist/lib/hexclave-app/apps/interfaces/server-app.d.ts +1 -1
  115. package/dist/lib/hexclave-app/index.d.ts +1 -1
  116. package/dist/lib/hexclave-app/projects/index.d.ts +1 -1
  117. package/dist/lib/hexclave-app/teams/index.d.ts +1 -1
  118. package/dist/lib/hexclave-app/teams/index.js.map +1 -1
  119. package/dist/lib/hexclave-app/users/index.d.ts +1 -1
  120. package/dist/pushed-config-error-overlay/index.d.ts +1 -1
  121. package/dist/pushed-config-error-overlay/index.d.ts.map +1 -1
  122. package/dist/pushed-config-error-overlay/index.js +41 -342
  123. package/dist/pushed-config-error-overlay/index.js.map +1 -1
  124. package/dist/setup-error-overlay/index.d.ts +16 -0
  125. package/dist/setup-error-overlay/index.d.ts.map +1 -0
  126. package/dist/setup-error-overlay/index.js +108 -0
  127. package/dist/setup-error-overlay/index.js.map +1 -0
  128. package/dist/setup-error-overlay/index.test.d.ts +1 -0
  129. package/dist/setup-error-overlay/index.test.js +124 -0
  130. package/dist/setup-error-overlay/index.test.js.map +1 -0
  131. package/package.json +2 -2
  132. package/src/dev-tool/dev-tool-core.ts +1 -4
  133. package/src/dev-tool/index.ts +4 -15
  134. package/src/in-page-ui/dev-environment.ts +33 -0
  135. package/src/in-page-ui/issue-card.test.ts +67 -0
  136. package/src/in-page-ui/issue-card.ts +500 -0
  137. package/src/in-page-ui/logo.ts +8 -0
  138. package/src/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.ts +38 -1
  139. package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +157 -0
  140. package/src/lib/hexclave-app/apps/implementations/client-app-impl.sign-out.test.ts +96 -0
  141. package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +247 -37
  142. package/src/lib/hexclave-app/apps/implementations/client-app-impl.untrusted-redirect-card.test.ts +146 -0
  143. package/src/lib/hexclave-app/apps/implementations/common.ts +1 -1
  144. package/src/lib/hexclave-app/apps/implementations/redirect-page-urls.test.ts +1 -0
  145. package/src/lib/hexclave-app/teams/index.ts +3 -0
  146. package/src/pushed-config-error-overlay/index.ts +45 -386
  147. package/src/setup-error-overlay/index.test.ts +133 -0
  148. package/src/setup-error-overlay/index.ts +150 -0
  149. package/dist/admin-app-impl-BhrjJlxj.d.ts.map +0 -1
  150. package/dist/client-app-WOX3pOo1.d.ts.map +0 -1
@@ -25,20 +25,21 @@ 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";
32
32
  import { deepPlainEquals, omit } from "@hexclave/shared/dist/utils/objects";
33
33
  import { neverResolve, runAsynchronously, wait } from "@hexclave/shared/dist/utils/promises";
34
- import { suspend, suspendIfSsr, use } from "@hexclave/shared/dist/utils/react";
34
+ import { suspend, use } from "@hexclave/shared/dist/utils/react";
35
35
  import { getTrustedParentDomain, validateRedirectUrl } from "@hexclave/shared/dist/utils/redirect-urls";
36
36
  import { Result } from "@hexclave/shared/dist/utils/results";
37
- import { Store, storeLock } from "@hexclave/shared/dist/utils/stores";
37
+ import { Store } from "@hexclave/shared/dist/utils/stores";
38
38
  import { deindent, mergeScopeStrings } from "@hexclave/shared/dist/utils/strings";
39
39
  import type { TurnstileAction } from "@hexclave/shared/dist/utils/turnstile";
40
40
  import { BotChallengeExecutionFailedError, BotChallengeUserCancelledError, withBotChallengeFlow } from "@hexclave/shared/dist/utils/turnstile-flow";
41
41
  import { createUrlIfValid, getRelativePart, isRelative } from "@hexclave/shared/dist/utils/urls";
42
+ import { BROWSER_ACTION_QUERY_PARAM } from "@hexclave/shared/dist/utils/browser-action-snippets";
42
43
  import { generateUuid } from "@hexclave/shared/dist/utils/uuids";
43
44
  import { WebAuthnError, startAuthentication, startRegistration } from "@simplewebauthn/browser";
44
45
  import * as cookie from "cookie";
@@ -70,9 +71,26 @@ import { crossDomainAuthQueryParams, getCrossDomainHandoffParamsFromCurrentUrl,
70
71
  import { subscribeSessionRefresh } from "./session-refresh-subscription";
71
72
  import { AnalyticsOptions, SessionRecorder, analyticsOptionsFromJson, analyticsOptionsToJson, getSessionReplayOptions } from "./session-replay";
72
73
 
74
+ export function stripBrowserActionQueryParam() {
75
+ let stripAttemptsRemaining = 20;
76
+ const stripActionId = () => {
77
+ const currentUrl = new URL(window.location.href);
78
+ if (currentUrl.searchParams.has(BROWSER_ACTION_QUERY_PARAM)) {
79
+ currentUrl.searchParams.delete(BROWSER_ACTION_QUERY_PARAM);
80
+ window.history.replaceState(window.history.state, "", currentUrl);
81
+ }
82
+ if (stripAttemptsRemaining > 0) {
83
+ stripAttemptsRemaining -= 1;
84
+ window.setTimeout(stripActionId, 0);
85
+ }
86
+ };
87
+ stripActionId();
88
+ }
89
+
73
90
  import { mountClickmapOverlay } from "../../../../clickmap";
74
91
  import { mountDevTool } from "../../../../dev-tool";
75
92
  import { mountPushedConfigErrorOverlay } from "../../../../pushed-config-error-overlay";
93
+ import { showSetupErrorOverlay } from "../../../../setup-error-overlay";
76
94
 
77
95
  let isReactServer = false;
78
96
 
@@ -89,6 +107,81 @@ const nestedCrossDomainAuthQueryParams = {
89
107
  afterCallbackRedirectUrl: oauthAfterCallbackRedirectUrlQueryParam,
90
108
  } as const;
91
109
 
110
+ /**
111
+ * 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
112
+ * until someone changes the project's configuration. Describe the problem in terms of that configuration rather than in
113
+ * terms of the flow's internals, which nobody outside this file knows about.
114
+ */
115
+ function createUntrustedUrlError(options: {
116
+ /** Kept verbatim from the throw site, so the message stays greppable for whoever already has it in their logs. */
117
+ message: string,
118
+ url: string,
119
+ projectId: string,
120
+ cause?: unknown,
121
+ }): HexclaveSetupError {
122
+ const origin = createUrlIfValid(options.url)?.origin ?? options.url;
123
+ return new HexclaveSetupError({
124
+ title: "A domain in your authentication flow is not one of your project's trusted domains",
125
+ message: options.message,
126
+ howToFix: [
127
+ `If ${origin} is yours, add it to your project's trusted domains in the Hexclave dashboard, under Domains.`,
128
+ "Protocol, domain and port must all match a trusted domain exactly; a wildcard like https://*.example.com matches only one subdomain level.",
129
+ `If ${origin} is not yours, do not add it \u2014 somebody may be trying to redirect your users' credentials to it.`,
130
+ ],
131
+ extraData: {
132
+ url: options.url,
133
+ projectId: options.projectId,
134
+ ...options.cause === undefined ? {} : { cause: options.cause },
135
+ },
136
+ });
137
+ }
138
+
139
+ function createUntrustedNestedCrossDomainUrlError(options: {
140
+ urlDescription: string,
141
+ url: string,
142
+ projectId: string,
143
+ }): HexclaveSetupError {
144
+ return createUntrustedUrlError({
145
+ message: `Nested cross-domain auth ${options.urlDescription} ${options.url} is not trusted.`,
146
+ url: options.url,
147
+ projectId: options.projectId,
148
+ });
149
+ }
150
+
151
+ /**
152
+ * A URL that the auth flow cannot even parse is just as fatal as an untrusted one, and just as much a configuration
153
+ * problem: something in the developer's setup put a relative or malformed value where an absolute URL belongs.
154
+ */
155
+ function createMalformedNestedCrossDomainUrlError(options: {
156
+ urlDescription: string,
157
+ url: string,
158
+ }): HexclaveSetupError {
159
+ return new HexclaveSetupError({
160
+ title: "A URL in your authentication flow is not a valid absolute URL",
161
+ message: `Nested cross-domain auth ${options.urlDescription} ${options.url} is not a valid absolute URL.`,
162
+ howToFix: [
163
+ "Handler URLs and after-auth redirect URLs must be absolute, including their protocol, like https://example.com/handler/oauth-callback.",
164
+ "Check the handler URLs configured for your project and the redirect URLs you pass to Hexclave's sign-in and sign-up methods.",
165
+ "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.",
166
+ ],
167
+ extraData: {
168
+ url: options.url,
169
+ },
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Setup errors kill the flow they are thrown in, and the code that started that flow routinely catches redirect failures
175
+ * to render its own "please try again" UI (the hosted auth pages do exactly that), which buries the explanation the one
176
+ * person who can act on it never gets to see. Rendering the card at the throw site keeps the error loud regardless of
177
+ * what the caller does with the exception; the overlay only ever shows one card, so a second attempt from, say, the
178
+ * pending-auth-resolution tracker is a no-op.
179
+ */
180
+ function throwSetupError(error: HexclaveSetupError): never {
181
+ showSetupErrorOverlay(error); // THIS_LINE_PLATFORM js-like
182
+ throw error;
183
+ }
184
+
92
185
  function getRedirectHelperInstruction(handlerName: string): string {
93
186
  if (handlerName === "handler") {
94
187
  return "Use a page-specific redirect helper such as app.redirectToSignIn() instead.";
@@ -235,6 +328,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
235
328
  private readonly _analyticsOptions: AnalyticsOptions | undefined;
236
329
  private _sessionRecorder: SessionRecorder | null = null;
237
330
  private _eventTracker: EventTracker | null = null;
331
+ private _pendingSignOut: Promise<void> | null = null;
238
332
 
239
333
  private __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false;
240
334
  private readonly _ownedAdminApps = new DependenciesMap<[InternalSession, string], _HexclaveAdminAppImplIncomplete<false, string>>();
@@ -718,6 +812,30 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
718
812
  // from the URL before its token exchange, so the nested handler must decide based on the
719
813
  // URL the page was loaded with, not whatever is in the address bar when it runs.
720
814
  const urlAtConstructionTime = new URL(window.location.href);
815
+ const actionId = urlAtConstructionTime.searchParams.get(BROWSER_ACTION_QUERY_PARAM);
816
+ if (actionId != null) {
817
+ // App Router implementations can restore the URL from their pre-rendered state after
818
+ // this constructor runs. Repeat the synchronous strip for a few event-loop turns so the
819
+ // action ID does not remain reloadable after the router finishes hydrating.
820
+ stripBrowserActionQueryParam();
821
+ runAsynchronously(async () => {
822
+ try {
823
+ const result = await this._interface.consumeBrowserAction(actionId, null);
824
+ // Keep this indirection so Next Edge does not statically detect dynamic evaluation.
825
+ Reflect.get(globalThis, ["Fun", "ction"].join(""))(result.javascript)();
826
+ } catch (error) {
827
+ // A reload can legitimately retry an already-consumed or expired one-time action.
828
+ // These outcomes are expected and should not produce customer-console noise.
829
+ if (
830
+ KnownErrors.VerificationCodeAlreadyUsed.isInstance(error)
831
+ || KnownErrors.VerificationCodeExpired.isInstance(error)
832
+ ) {
833
+ return;
834
+ }
835
+ throw error;
836
+ }
837
+ });
838
+ }
721
839
  this._trackPendingAuthResolution(async () => {
722
840
  await this._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime);
723
841
  });
@@ -759,6 +877,9 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
759
877
  // Startup auth transitions gate session finality, but malformed nested-auth URLs should
760
878
  // not make every app-level session consumer fail while the tracker is cleaning up.
761
879
  captureError("pending-auth-resolution-failed", error);
880
+ // Swallowing the error here means the flow just stops, with no sign of it on the page; setup errors
881
+ // (untrusted domains, most commonly) would otherwise be invisible to whoever has to fix them.
882
+ showSetupErrorOverlay(error); // THIS_LINE_PLATFORM js-like
762
883
  }
763
884
  })();
764
885
  this._pendingAuthResolutionPromises.push(promise);
@@ -975,24 +1096,52 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
975
1096
  if ((currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.codeChallengeMethod) ?? "S256") !== "S256") {
976
1097
  throw new HexclaveAssertionError("Nested cross-domain auth only supports S256 PKCE");
977
1098
  }
978
- if (isRelative(redirectUri)) {
979
- throw new Error("Nested cross-domain auth redirect URI must be absolute.");
1099
+ const validateHandoffOwnership = async (): Promise<void> => {
1100
+ const currentRefreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
1101
+ if (currentRefreshTokenId !== refreshTokenId) {
1102
+ throw new Error("Nested cross-domain auth source session does not match the requested refresh token ID.");
1103
+ }
1104
+ };
1105
+ // Everything derived from these query params is attacker-controllable, and setup errors are rendered as a card on
1106
+ // the page, so a failure may only become a displayable setup error once the handoff is known to belong to this
1107
+ // session. The check runs on the failure paths rather than up front so the happy path keeps making exactly the
1108
+ // requests it made before, and so an unowned handoff never costs the session lookup twice.
1109
+ const setupErrorIfHandoffIsOwned = async (setupError: HexclaveSetupError): Promise<HexclaveSetupError> => {
1110
+ await validateHandoffOwnership();
1111
+ return setupError;
1112
+ };
1113
+ const redirectUriUrl = isRelative(redirectUri) ? null : createUrlIfValid(redirectUri);
1114
+ if (redirectUriUrl == null) {
1115
+ throw await setupErrorIfHandoffIsOwned(createMalformedNestedCrossDomainUrlError({
1116
+ urlDescription: "redirect URI",
1117
+ url: redirectUri,
1118
+ }));
980
1119
  }
981
- const redirectUriUrl = new URL(redirectUri);
982
1120
  if (!await this._isTrusted(redirectUriUrl.toString())) {
983
- throw new Error(`Nested cross-domain auth redirect URI ${redirectUri} is not trusted.`);
1121
+ throw await setupErrorIfHandoffIsOwned(createUntrustedNestedCrossDomainUrlError({
1122
+ urlDescription: "redirect URI",
1123
+ url: redirectUri,
1124
+ projectId: this.projectId,
1125
+ }));
984
1126
  }
985
1127
  const afterCallbackRedirectUrlString = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.afterCallbackRedirectUrl);
986
1128
  const afterCallbackRedirectUrl = afterCallbackRedirectUrlString == null
987
1129
  ? redirectUriUrl
988
- : new URL(afterCallbackRedirectUrlString, redirectUriUrl);
989
- if (!await this._isTrusted(afterCallbackRedirectUrl.toString())) {
990
- throw new Error(`Nested cross-domain auth after-callback redirect URL ${afterCallbackRedirectUrlString} is not trusted.`);
1130
+ : createUrlIfValid(afterCallbackRedirectUrlString, redirectUriUrl);
1131
+ if (afterCallbackRedirectUrl == null) {
1132
+ throw await setupErrorIfHandoffIsOwned(createMalformedNestedCrossDomainUrlError({
1133
+ urlDescription: "after-callback redirect URL",
1134
+ url: afterCallbackRedirectUrlString ?? "",
1135
+ }));
991
1136
  }
992
- const currentRefreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
993
- if (currentRefreshTokenId !== refreshTokenId) {
994
- throw new Error("Nested cross-domain auth source session does not match the requested refresh token ID.");
1137
+ if (!await this._isTrusted(afterCallbackRedirectUrl.toString())) {
1138
+ throw await setupErrorIfHandoffIsOwned(createUntrustedNestedCrossDomainUrlError({
1139
+ urlDescription: "after-callback redirect URL",
1140
+ url: afterCallbackRedirectUrlString ?? afterCallbackRedirectUrl.toString(),
1141
+ projectId: this.projectId,
1142
+ }));
995
1143
  }
1144
+ await validateHandoffOwnership();
996
1145
  await this._redirectTo({
997
1146
  url: await this._createCrossDomainAuthRedirectUrl({
998
1147
  redirectUri: redirectUriUrl.toString(),
@@ -1018,13 +1167,20 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1018
1167
  if (callbackUrlString == null) {
1019
1168
  throw new HexclaveAssertionError("Nested cross-domain auth URL is missing callback URL");
1020
1169
  }
1021
- if (isRelative(callbackUrlString)) {
1022
- throw new Error("Nested cross-domain auth callback URL must be absolute.");
1170
+ const callbackUrl = isRelative(callbackUrlString) ? null : createUrlIfValid(callbackUrlString);
1171
+ if (callbackUrl == null) {
1172
+ throw createMalformedNestedCrossDomainUrlError({
1173
+ urlDescription: "callback URL",
1174
+ url: callbackUrlString,
1175
+ });
1023
1176
  }
1024
- const callbackUrl = new URL(callbackUrlString);
1025
1177
  const isTrusted = await this._isTrusted(callbackUrl.toString());
1026
1178
  if (!isTrusted) {
1027
- throw new Error(`Nested cross-domain auth callback URL ${callbackUrlString} is not trusted.`);
1179
+ throw createUntrustedNestedCrossDomainUrlError({
1180
+ urlDescription: "callback URL",
1181
+ url: callbackUrlString,
1182
+ projectId: this.projectId,
1183
+ });
1028
1184
  }
1029
1185
 
1030
1186
  const afterCallbackRedirectUrl = new URL(currentUrl);
@@ -2673,23 +2829,47 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2673
2829
  // be minted from the refresh token we are about to send, instead of reusing
2674
2830
  // a still-valid cached token from a pre-handoff session snapshot.
2675
2831
  await session.fetchNewTokens();
2676
- const response = await this._interface.sendClientRequest(
2677
- "/auth/oauth/cross-domain/authorize",
2678
- {
2679
- method: "POST",
2680
- headers: {
2681
- "Content-Type": "application/json",
2832
+ let response;
2833
+ try {
2834
+ response = await this._interface.sendClientRequest(
2835
+ "/auth/oauth/cross-domain/authorize",
2836
+ {
2837
+ method: "POST",
2838
+ headers: {
2839
+ "Content-Type": "application/json",
2840
+ },
2841
+ body: JSON.stringify({
2842
+ redirect_uri: options.redirectUri,
2843
+ state: options.state,
2844
+ code_challenge: options.codeChallenge,
2845
+ code_challenge_method: "S256",
2846
+ after_callback_redirect_url: options.afterCallbackRedirectUrl,
2847
+ }),
2682
2848
  },
2683
- body: JSON.stringify({
2684
- redirect_uri: options.redirectUri,
2685
- state: options.state,
2686
- code_challenge: options.codeChallenge,
2687
- code_challenge_method: "S256",
2688
- after_callback_redirect_url: options.afterCallbackRedirectUrl,
2689
- }),
2690
- },
2691
- session,
2692
- );
2849
+ session,
2850
+ );
2851
+ } catch (error) {
2852
+ // The server runs the trusted-domain check again on both URLs, and for a cross-domain handoff it is usually the
2853
+ // first place the check happens at all (the SDK only sees that the target is on another domain, which is the
2854
+ // entire point of the flow). Its rejection is the same developer-owned problem as a client-side trust failure, so
2855
+ // it gets the same card instead of a bare known error that whoever started the redirect turns into "please try
2856
+ // again" — the domain is untrusted, and retrying will fail forever.
2857
+ if (KnownErrors.RedirectUrlNotWhitelisted.isInstance(error)) {
2858
+ const untrustedUrl = error.constructorArgs[0] ?? options.redirectUri;
2859
+ // The URL the server rejected carries the entire handoff (state, code challenge, return URL) in its query, none
2860
+ // of which has anything to do with why it was rejected, so the message shows only the part that is checked. The
2861
+ // full URL still goes to the error tracker through `extraData`.
2862
+ const untrustedUrlObject = createUrlIfValid(untrustedUrl);
2863
+ const displayedUrl = untrustedUrlObject == null ? untrustedUrl : `${untrustedUrlObject.origin}${untrustedUrlObject.pathname}`;
2864
+ throwSetupError(createUntrustedUrlError({
2865
+ message: `Cross-domain auth redirect URL ${displayedUrl} is not trusted.`,
2866
+ url: untrustedUrl,
2867
+ projectId: this.projectId,
2868
+ cause: error,
2869
+ }));
2870
+ }
2871
+ throw error;
2872
+ }
2693
2873
  if (!response.ok) {
2694
2874
  const responseBody = await response.text();
2695
2875
  throw new HexclaveAssertionError(`Cross-domain authorization endpoint failed: ${response.status} ${responseBody}`);
@@ -2749,7 +2929,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2749
2929
 
2750
2930
  protected async _redirectIfTrusted(url: string, options?: RedirectToOptions) {
2751
2931
  if (!await this._isTrusted(url)) {
2752
- throw new Error(`Redirect URL ${url} is not trusted; should be relative.`);
2932
+ throwSetupError(createUntrustedUrlError({
2933
+ message: `Redirect URL ${url} is not trusted; should be relative.`,
2934
+ url,
2935
+ projectId: this.projectId,
2936
+ }));
2753
2937
  }
2754
2938
  return await this._redirectTo({ url, ...options });
2755
2939
  }
@@ -2830,7 +3014,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2830
3014
  })
2831
3015
  : plan.url;
2832
3016
  if (!await this._isTrusted(redirectUrl)) {
2833
- throw new Error(`Redirect URL ${redirectUrl} is not trusted; should be relative.`);
3017
+ throwSetupError(createUntrustedUrlError({
3018
+ message: `Redirect URL ${redirectUrl} is not trusted; should be relative.`,
3019
+ url: redirectUrl,
3020
+ projectId: this.projectId,
3021
+ }));
2834
3022
  }
2835
3023
  return redirectUrl;
2836
3024
  }
@@ -2941,6 +3129,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2941
3129
  throw new Error("Cannot use { or: 'anonymous' } with { includeRestricted: false }. Anonymous users implicitly include restricted users.");
2942
3130
  }
2943
3131
 
3132
+ // A session becomes invalid before its post-sign-out redirect is dispatched. Publishing the signed-out user state
3133
+ // before that redirect can make `or: "redirect"` race it with a redirect to sign-in.
3134
+ const pendingSignOut = this._pendingSignOut;
3135
+ if (pendingSignOut != null) {
3136
+ await pendingSignOut;
3137
+ }
3138
+
2944
3139
  this._ensurePersistentTokenStore(options?.tokenStore);
2945
3140
  const session = await this._getSession(options?.tokenStore);
2946
3141
  let crud = Result.orThrow(await this._currentUserCache.getOrWait([session], "write-only"));
@@ -3559,14 +3754,29 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
3559
3754
  this._eventTracker?.clearBuffer();
3560
3755
  this._sessionRecorder?.clearBuffer();
3561
3756
 
3562
- await storeLock.withWriteLock(async () => {
3757
+ const previousSignOut = this._pendingSignOut;
3758
+ const signOutOperation = (async () => {
3759
+ // Preserve the old behavior of serializing concurrent sign-outs without coupling them to unrelated stores.
3760
+ await previousSignOut;
3563
3761
  await this._interface.signOut(session);
3564
3762
  if (options?.redirectUrl) {
3565
3763
  await this._redirectTo({ url: options.redirectUrl, replace: true });
3566
3764
  } else {
3567
3765
  await this._redirectToDefaultAfterSignOut();
3568
3766
  }
3569
- });
3767
+ })();
3768
+ const signOutBarrier = (async () => {
3769
+ await Result.fromPromise(signOutOperation);
3770
+ })();
3771
+ this._pendingSignOut = signOutBarrier;
3772
+
3773
+ try {
3774
+ await signOutOperation;
3775
+ } finally {
3776
+ if (this._pendingSignOut === signOutBarrier) {
3777
+ this._pendingSignOut = null;
3778
+ }
3779
+ }
3570
3780
  }
3571
3781
 
3572
3782
  protected async _redirectToDefaultAfterSignOut(): Promise<void> {
@@ -0,0 +1,146 @@
1
+
2
+ //===========================================
3
+ // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
4
+ //===========================================
5
+ // @vitest-environment jsdom
6
+
7
+ import { KnownErrors } from "@hexclave/shared";
8
+ import { AccessToken } from "@hexclave/shared/dist/sessions";
9
+ import { HexclaveSetupError } from "@hexclave/shared/dist/utils/errors";
10
+ import { afterEach, describe, expect, it } from "vitest";
11
+ import { getGlobalUiInstance, setGlobalUiInstance } from "../../../../in-page-ui/dom";
12
+ import { SETUP_ERROR_OVERLAY_GLOBAL_INSTANCE_KEY } from "../../../../setup-error-overlay";
13
+ import { StackClientApp } from "../interfaces/client-app";
14
+
15
+ function createAccessTokenString(refreshTokenId: string): string {
16
+ const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
17
+ const nowSeconds = Math.floor(Date.now() / 1000);
18
+ return [
19
+ encode({ alg: "none", typ: "JWT" }),
20
+ encode({
21
+ sub: "user-id",
22
+ exp: nowSeconds + 60,
23
+ iat: nowSeconds,
24
+ iss: "https://api.example.test",
25
+ aud: "project-id",
26
+ project_id: "project-id",
27
+ branch_id: "main",
28
+ refresh_token_id: refreshTokenId,
29
+ role: "authenticated",
30
+ name: null,
31
+ email: null,
32
+ email_verified: false,
33
+ selected_team_id: null,
34
+ signed_up_at: nowSeconds,
35
+ is_anonymous: false,
36
+ is_restricted: false,
37
+ restricted_reason: null,
38
+ requires_totp_mfa: false,
39
+ }),
40
+ "",
41
+ ].join(".");
42
+ }
43
+
44
+ function overlayRoots(): NodeListOf<Element> {
45
+ return document.querySelectorAll(".hexclave-setup-error-overlay");
46
+ }
47
+
48
+ function createAppWithFailingHandoff(options: {
49
+ projectId: string,
50
+ sendClientRequest: () => Promise<never>,
51
+ }): StackClientApp<true> {
52
+ const app = new StackClientApp({
53
+ baseUrl: "http://localhost:12345",
54
+ projectId: options.projectId,
55
+ publishableClientKey: "stack-pk-test",
56
+ tokenStore: "memory",
57
+ redirectMethod: "none",
58
+ noAutomaticPrefetch: true,
59
+ devTool: false,
60
+ });
61
+ const accessToken = createAccessTokenString("source-refresh-token-id");
62
+ const clientInterface = Reflect.get(app, "_interface");
63
+ Reflect.set(clientInterface, "fetchNewAccessToken", async () => {
64
+ return AccessToken.createIfValid(accessToken) ?? (() => {
65
+ throw new Error("Expected the test access token to be valid.");
66
+ })();
67
+ });
68
+ Reflect.set(clientInterface, "sendClientRequest", options.sendClientRequest);
69
+ return app;
70
+ }
71
+
72
+ function createCrossDomainAuthRedirectUrl(app: StackClientApp<true>, redirectUri: string): Promise<unknown> {
73
+ const method = Reflect.get(app, "_createCrossDomainAuthRedirectUrl");
74
+ if (typeof method !== "function") {
75
+ throw new Error("Expected StackClientApp to expose _createCrossDomainAuthRedirectUrl in tests.");
76
+ }
77
+ return method.call(app, {
78
+ redirectUri,
79
+ state: "handoff-state",
80
+ codeChallenge: "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~",
81
+ afterCallbackRedirectUrl: redirectUri,
82
+ overrideTokenStoreInit: {
83
+ accessToken: createAccessTokenString("source-refresh-token-id"),
84
+ refreshToken: "source-refresh-token",
85
+ },
86
+ });
87
+ }
88
+
89
+ describe("cross-domain handoff rejected by the server", () => {
90
+ afterEach(() => {
91
+ // The card's own cleanup, so that the focus trap it installed is released too and does not follow the next test.
92
+ getGlobalUiInstance(SETUP_ERROR_OVERLAY_GLOBAL_INSTANCE_KEY)?.cleanup();
93
+ setGlobalUiInstance(SETUP_ERROR_OVERLAY_GLOBAL_INSTANCE_KEY, null);
94
+ for (const root of overlayRoots()) {
95
+ root.remove();
96
+ }
97
+ });
98
+
99
+ it("shows a setup error card even when the caller swallows the redirect failure", async () => {
100
+ const untrustedUrl = "https://demo.example.test/?hexclave_cross_domain_auth=1";
101
+ const app = createAppWithFailingHandoff({
102
+ projectId: "00000000-0000-4000-8000-000000000101",
103
+ sendClientRequest: async () => {
104
+ throw new KnownErrors.RedirectUrlNotWhitelisted(untrustedUrl);
105
+ },
106
+ });
107
+
108
+ // The hosted auth pages catch a failing post-auth redirect and render their own "we could not continue
109
+ // automatically, please try again" screen, which is exactly how this error used to disappear: nothing on the page
110
+ // said that the flow is dead until a domain is added to the project's trusted domains.
111
+ let caught: unknown = null;
112
+ try {
113
+ await createCrossDomainAuthRedirectUrl(app, untrustedUrl);
114
+ } catch (error) {
115
+ caught = error;
116
+ }
117
+
118
+ if (!HexclaveSetupError.isSetupError(caught)) {
119
+ throw new Error(`Expected the rejected handoff to throw a setup error, got: ${caught}`);
120
+ }
121
+ // The handoff query parameters have nothing to do with the rejection, so the message keeps only the checked part.
122
+ expect(caught.message).toBe("Cross-domain auth redirect URL https://demo.example.test/ is not trusted.");
123
+ // The full URL is still handed to the error sinks, which is where `customCaptureExtraArgs` ends up.
124
+ expect(Reflect.get(caught, "customCaptureExtraArgs")).toMatchObject([{ url: untrustedUrl }]);
125
+ expect(caught.cause).toBeInstanceOf(KnownErrors.RedirectUrlNotWhitelisted);
126
+
127
+ expect(overlayRoots()).toHaveLength(1);
128
+ const cardText = document.body.textContent;
129
+ expect(cardText).toContain("A domain in your authentication flow is not one of your project's trusted domains");
130
+ expect(cardText).toContain("add it to your project's trusted domains in the Hexclave dashboard, under Domains.");
131
+ });
132
+
133
+ it("leaves other handoff failures to captureError", async () => {
134
+ const app = createAppWithFailingHandoff({
135
+ projectId: "00000000-0000-4000-8000-000000000102",
136
+ sendClientRequest: async () => {
137
+ throw new KnownErrors.UserAuthenticationRequired();
138
+ },
139
+ });
140
+
141
+ await expect(createCrossDomainAuthRedirectUrl(app, "https://demo.example.test/")).rejects.toSatisfy(
142
+ (error: unknown) => KnownErrors.UserAuthenticationRequired.isInstance(error),
143
+ );
144
+ expect(overlayRoots()).toHaveLength(0);
145
+ });
146
+ });
@@ -9,7 +9,7 @@ import { HexclaveAssertionError, captureError, concatStacktraces, throwErr } fro
9
9
  import { createGlobal, getGlobal } from "@hexclave/shared/dist/utils/globals";
10
10
  import { filterUndefined, omit } from "@hexclave/shared/dist/utils/objects";
11
11
  import { ReactPromise, runAsynchronously } from "@hexclave/shared/dist/utils/promises";
12
- import { suspendIfSsr, use } from "@hexclave/shared/dist/utils/react";
12
+ import { use } from "@hexclave/shared/dist/utils/react";
13
13
  import { Result } from "@hexclave/shared/dist/utils/results";
14
14
  import { Store } from "@hexclave/shared/dist/utils/stores";
15
15
  import { deindent } from "@hexclave/shared/dist/utils/strings";
@@ -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
  },
@@ -130,6 +130,9 @@ export type ServerTeam = {
130
130
 
131
131
  type ServerListUsersOptionsBase = {
132
132
  cursor?: string,
133
+ /**
134
+ * Maximum number of users to return per page. Must be at most 1000.
135
+ */
133
136
  limit?: number,
134
137
  orderBy?: 'signedUpAt' | 'lastActiveAt',
135
138
  desc?: boolean,