@hexclave/next 1.0.5 → 1.0.6

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 (89) hide show
  1. package/dist/components-page/account-settings/payments/payments-panel.js +3 -3
  2. package/dist/components-page/account-settings/payments/payments-panel.js.map +1 -1
  3. package/dist/components-page/hexclave-handler-client.d.ts +13 -1
  4. package/dist/components-page/hexclave-handler-client.d.ts.map +1 -1
  5. package/dist/components-page/hexclave-handler-client.js +44 -9
  6. package/dist/components-page/hexclave-handler-client.js.map +1 -1
  7. package/dist/components-page/hexclave-handler-client.test.d.ts +1 -0
  8. package/dist/components-page/hexclave-handler-client.test.js +51 -0
  9. package/dist/components-page/hexclave-handler-client.test.js.map +1 -0
  10. package/dist/dev-tool/dev-tool-core.js +2 -2
  11. package/dist/dev-tool/dev-tool-core.js.map +1 -1
  12. package/dist/esm/components-page/account-settings/payments/payments-panel.js +2 -2
  13. package/dist/esm/components-page/account-settings/payments/payments-panel.js.map +1 -1
  14. package/dist/esm/components-page/hexclave-handler-client.d.ts +12 -1
  15. package/dist/esm/components-page/hexclave-handler-client.d.ts.map +1 -1
  16. package/dist/esm/components-page/hexclave-handler-client.js +46 -12
  17. package/dist/esm/components-page/hexclave-handler-client.js.map +1 -1
  18. package/dist/esm/components-page/hexclave-handler-client.test.d.ts +1 -0
  19. package/dist/esm/components-page/hexclave-handler-client.test.js +51 -0
  20. package/dist/esm/components-page/hexclave-handler-client.test.js.map +1 -0
  21. package/dist/esm/dev-tool/dev-tool-core.js +2 -2
  22. package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
  23. package/dist/esm/generated/env.d.ts +26 -0
  24. package/dist/esm/{lib → generated}/env.d.ts.map +1 -1
  25. package/dist/esm/generated/env.js +67 -0
  26. package/dist/esm/generated/env.js.map +1 -0
  27. package/dist/esm/generated/quetzal-translations.d.ts +2 -2
  28. package/dist/esm/global.d.ts +8 -1
  29. package/dist/esm/global.d.ts.map +1 -0
  30. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  31. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +263 -3
  32. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  33. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +3 -1
  34. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  35. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +53 -26
  36. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  37. package/dist/esm/lib/hexclave-app/apps/implementations/common.d.ts +8 -8
  38. package/dist/esm/lib/hexclave-app/apps/implementations/common.d.ts.map +1 -1
  39. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +28 -14
  40. package/dist/esm/lib/hexclave-app/apps/implementations/common.js.map +1 -1
  41. package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
  42. package/dist/esm/lib/hexclave-app/url-targets.d.ts.map +1 -1
  43. package/dist/esm/lib/hexclave-app/url-targets.js +25 -11
  44. package/dist/esm/lib/hexclave-app/url-targets.js.map +1 -1
  45. package/dist/esm/lib/hexclave-app/url-targets.test.js +12 -0
  46. package/dist/esm/lib/hexclave-app/url-targets.test.js.map +1 -1
  47. package/dist/generated/env.d.ts +26 -0
  48. package/dist/{lib → generated}/env.d.ts.map +1 -1
  49. package/dist/generated/env.js +69 -0
  50. package/dist/generated/env.js.map +1 -0
  51. package/dist/generated/quetzal-translations.d.ts +2 -2
  52. package/dist/global.d.ts +8 -1
  53. package/dist/global.d.ts.map +1 -0
  54. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  55. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +263 -3
  56. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  57. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +3 -1
  58. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  59. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +52 -25
  60. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  61. package/dist/lib/hexclave-app/apps/implementations/common.d.ts +8 -8
  62. package/dist/lib/hexclave-app/apps/implementations/common.d.ts.map +1 -1
  63. package/dist/lib/hexclave-app/apps/implementations/common.js +28 -14
  64. package/dist/lib/hexclave-app/apps/implementations/common.js.map +1 -1
  65. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
  66. package/dist/lib/hexclave-app/url-targets.d.ts.map +1 -1
  67. package/dist/lib/hexclave-app/url-targets.js +25 -11
  68. package/dist/lib/hexclave-app/url-targets.js.map +1 -1
  69. package/dist/lib/hexclave-app/url-targets.test.js +12 -0
  70. package/dist/lib/hexclave-app/url-targets.test.js.map +1 -1
  71. package/package.json +9 -7
  72. package/src/components-page/account-settings/payments/payments-panel.tsx +2 -2
  73. package/src/components-page/hexclave-handler-client.test.tsx +64 -0
  74. package/src/components-page/hexclave-handler-client.tsx +50 -11
  75. package/src/dev-tool/dev-tool-core.ts +2 -2
  76. package/src/generated/.gitignore +1 -1
  77. package/src/global.d.ts +8 -1
  78. package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +316 -3
  79. package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +69 -25
  80. package/src/lib/hexclave-app/apps/implementations/common.ts +34 -14
  81. package/src/lib/hexclave-app/url-targets.test.ts +17 -0
  82. package/src/lib/hexclave-app/url-targets.ts +25 -7
  83. package/dist/esm/lib/env.d.ts +0 -42
  84. package/dist/esm/lib/env.js +0 -93
  85. package/dist/esm/lib/env.js.map +0 -1
  86. package/dist/lib/env.d.ts +0 -42
  87. package/dist/lib/env.js +0 -95
  88. package/dist/lib/env.js.map +0 -1
  89. package/src/lib/env.ts +0 -93
@@ -0,0 +1,64 @@
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
+ import { KnownErrors } from "@hexclave/shared";
6
+ import { describe, expect, it, vi } from "vitest";
7
+ import { hexclaveAppInternalsSymbol } from "../lib/hexclave-app";
8
+ import type { StackClientApp } from "../lib/hexclave-app/apps/interfaces/client-app";
9
+ import { getRedirectToPageResult } from "./hexclave-handler-client";
10
+
11
+ vi.mock("next/navigation", () => ({
12
+ RedirectType: {
13
+ replace: "replace",
14
+ },
15
+ notFound: () => {
16
+ throw new Error("notFound");
17
+ },
18
+ redirect: (url: string) => {
19
+ throw new Error(`redirect:${url}`);
20
+ },
21
+ usePathname: () => window.location.pathname,
22
+ useSearchParams: () => new URLSearchParams(window.location.search),
23
+ }));
24
+
25
+ function createAppTestDouble(options: {
26
+ redirectToHandler: (name: string, options: { replace: true }) => Promise<void>,
27
+ }) {
28
+ const projectId = "00000000-0000-4000-8000-000000000000";
29
+ const app = {
30
+ projectId,
31
+ urls: {
32
+ handler: "http://localhost/handler",
33
+ signIn: `https://${projectId}.example-stack-hosted.test/handler/sign-in`,
34
+ home: "http://localhost",
35
+ },
36
+ redirectToHome: vi.fn(async () => {}),
37
+ [hexclaveAppInternalsSymbol]: {
38
+ getConstructorOptions: () => ({ urls: {} }),
39
+ redirectToHandler: options.redirectToHandler,
40
+ },
41
+ };
42
+
43
+ // This test double intentionally implements only the StackClientApp surface
44
+ // that HexclaveHandlerClient touches in this redirect path.
45
+ return app as unknown as StackClientApp<true>;
46
+ }
47
+
48
+ describe("HexclaveHandlerClient", () => {
49
+ it("returns known cross-domain redirect errors instead of treating them as unhandled async failures", async () => {
50
+ const redirectToHandler = vi.fn(async () => {
51
+ throw new KnownErrors.RedirectUrlNotWhitelisted();
52
+ });
53
+ const app = createAppTestDouble({ redirectToHandler });
54
+
55
+ const result = await getRedirectToPageResult(app, "signIn");
56
+
57
+ expect(redirectToHandler).toHaveBeenCalledWith("signIn", { replace: true });
58
+ expect(result.status).toBe("known-error");
59
+ if (result.status === "known-error") {
60
+ expect(result.error.errorCode).toBe("REDIRECT_URL_NOT_WHITELISTED");
61
+ expect(result.error.message).toContain("Redirect URL not whitelisted");
62
+ }
63
+ });
64
+ });
@@ -5,12 +5,13 @@
5
5
  // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
6
6
  //===========================================
7
7
 
8
- import { HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
8
+ import { KnownError } from "@hexclave/shared";
9
+ import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
9
10
  import { FilterUndefined, filterUndefined } from "@hexclave/shared/dist/utils/objects";
10
- import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
11
+ import { use } from "@hexclave/shared/dist/utils/react";
11
12
  import { getRelativePart } from "@hexclave/shared/dist/utils/urls";
12
13
  import { notFound, redirect, RedirectType, usePathname, useSearchParams } from 'next/navigation'; // THIS_LINE_PLATFORM next
13
- import { useEffect, useMemo, useSyncExternalStore } from 'react';
14
+ import { Suspense, useMemo, useSyncExternalStore } from 'react';
14
15
  import { SignIn, SignUp, StackServerApp } from "..";
15
16
  import { useStackApp } from "../lib/hooks";
16
17
  import { HandlerUrls, StackClientApp, hexclaveAppInternalsSymbol } from "../lib/hexclave-app";
@@ -28,7 +29,9 @@ import { PasswordReset } from "./password-reset";
28
29
  import { SignOut } from "./sign-out";
29
30
  import { TeamInvitation } from "./team-invitation";
30
31
 
32
+ import { KnownErrorMessageCard } from "../components/message-cards/known-error-message-card";
31
33
  import { MessageCard } from "../components/message-cards/message-card";
34
+ import { PredefinedMessageCard } from "../components/message-cards/predefined-message-card";
32
35
 
33
36
  type Components = {
34
37
  SignIn: typeof SignIn,
@@ -52,6 +55,11 @@ type RouteProps = {
52
55
  searchParams: Promise<Record<string, string>> | Record<string, string>,
53
56
  };
54
57
 
58
+ type RedirectToPageResult =
59
+ | { status: "success" }
60
+ | { status: "known-error", error: KnownError }
61
+ | { status: "unknown-error" };
62
+
55
63
  const availablePaths = {
56
64
  signIn: 'sign-in',
57
65
  signUp: 'sign-up',
@@ -225,6 +233,42 @@ function renderComponent(props: {
225
233
  }
226
234
  }
227
235
 
236
+ export async function getRedirectToPageResult(
237
+ app: StackClientApp,
238
+ redirectToPage: keyof HandlerUrls,
239
+ ): Promise<RedirectToPageResult> {
240
+ try {
241
+ await app[hexclaveAppInternalsSymbol].redirectToHandler(redirectToPage, { replace: true });
242
+ return { status: "success" };
243
+ } catch (e) {
244
+ if (KnownError.isKnownError(e)) {
245
+ return { status: "known-error", error: e };
246
+ }
247
+ captureError("<HexclaveHandlerClient redirectToPage />", e);
248
+ return { status: "unknown-error" };
249
+ }
250
+ }
251
+
252
+ function RedirectToPage(props: {
253
+ app: StackClientApp,
254
+ fullPage?: boolean,
255
+ redirectToPage: keyof HandlerUrls,
256
+ }) {
257
+ const redirectResultPromise = useMemo(
258
+ () => getRedirectToPageResult(props.app, props.redirectToPage),
259
+ [props.app, props.redirectToPage],
260
+ );
261
+
262
+ const redirectResult = use(redirectResultPromise);
263
+ if (redirectResult.status === "known-error") {
264
+ return <KnownErrorMessageCard error={redirectResult.error} fullPage={props.fullPage} />;
265
+ }
266
+ if (redirectResult.status === "unknown-error") {
267
+ return <PredefinedMessageCard type="unknownError" fullPage={props.fullPage} />;
268
+ }
269
+ return <MessageCard title="Redirecting..." fullPage={props.fullPage} />;
270
+ }
271
+
228
272
  export function HexclaveHandlerClient(props: BaseHandlerProps & Partial<RouteProps> & { location?: string }) {
229
273
  // Use hooks to get app
230
274
  const hexclaveApp = useStackApp();
@@ -284,16 +328,11 @@ export function HexclaveHandlerClient(props: BaseHandlerProps & Partial<RoutePro
284
328
 
285
329
  const redirectToPage = (result != null && typeof result === 'object' && 'redirectToPage' in result) ? result.redirectToPage : undefined;
286
330
 
287
- useEffect(() => {
288
- if (redirectToPage == null) return;
289
- runAsynchronouslyWithAlert(
290
- hexclaveApp[hexclaveAppInternalsSymbol].redirectToHandler(redirectToPage, { replace: true })
291
- );
292
- }, [redirectToPage, hexclaveApp]);
293
-
294
331
  if (redirectToPage != null) {
295
332
  return (
296
- <MessageCard title="Redirecting..." fullPage={props.fullPage} />
333
+ <Suspense fallback={<MessageCard title="Redirecting..." fullPage={props.fullPage} />}>
334
+ <RedirectToPage app={hexclaveApp} redirectToPage={redirectToPage} fullPage={props.fullPage} />
335
+ </Suspense>
297
336
  );
298
337
  }
299
338
 
@@ -7,7 +7,7 @@ import type { RequestLogEntry } from "@hexclave/shared/dist/interface/client-int
7
7
  import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
8
8
  import { isLocalhost } from "@hexclave/shared/dist/utils/urls";
9
9
  import type { StackClientApp } from "../lib/hexclave-app";
10
- import { envVars } from "../lib/env";
10
+ import { envVars } from "../generated/env";
11
11
  import { getBaseUrl } from "../lib/hexclave-app/apps/implementations/common";
12
12
  import type { HandlerUrlOptions, HandlerUrls, HandlerUrlTarget } from "../lib/hexclave-app/common";
13
13
  import { hexclaveAppInternalsSymbol } from "../lib/hexclave-app/common";
@@ -211,7 +211,7 @@ function resolveApiBaseUrl(app: StackClientApp<true>): string {
211
211
  }
212
212
 
213
213
  function shouldShowDashboardTab(app: StackClientApp<true>): boolean {
214
- return envVars.NEXT_PUBLIC_STACK_IS_LOCAL_EMULATOR === "true" && isLocalhost(resolveApiBaseUrl(app));
214
+ return envVars.HEXCLAVE_IS_LOCAL_EMULATOR === "true" && isLocalhost(resolveApiBaseUrl(app));
215
215
  }
216
216
 
217
217
  function getTabsForApp(app: StackClientApp<true>): { id: TabId; label: string; icon: string }[] {
@@ -1,3 +1,3 @@
1
1
  /*
2
2
  !.gitignore
3
- !quetzal-translations.ts
3
+ !/quetzal-translations.ts
package/src/global.d.ts CHANGED
@@ -2,4 +2,11 @@
2
2
  //===========================================
3
3
  // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
4
4
  //===========================================
5
- import type {} from "react/canary";
5
+ import type { } from "react/canary";
6
+
7
+ declare global {
8
+ // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
9
+ interface ImportMeta {
10
+ readonly env?: Record<string, string | undefined>,
11
+ }
12
+ }
@@ -3,8 +3,39 @@
3
3
  // THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
4
4
  //===========================================
5
5
  import { describe, expect, it, vi } from "vitest";
6
+ import { AccessToken } from "@hexclave/shared/dist/sessions";
7
+ import { Store } from "@hexclave/shared/dist/utils/stores";
6
8
  import { StackClientApp } from "../interfaces/client-app";
7
9
 
10
+ function createAccessTokenString(refreshTokenId: string): string {
11
+ const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
12
+ const nowSeconds = Math.floor(Date.now() / 1000);
13
+ return [
14
+ encode({ alg: "none", typ: "JWT" }),
15
+ encode({
16
+ sub: "user-id",
17
+ exp: nowSeconds + 60,
18
+ iat: nowSeconds,
19
+ iss: "https://api.example.test",
20
+ aud: "project-id",
21
+ project_id: "project-id",
22
+ branch_id: "main",
23
+ refresh_token_id: refreshTokenId,
24
+ role: "authenticated",
25
+ name: null,
26
+ email: null,
27
+ email_verified: false,
28
+ selected_team_id: null,
29
+ signed_up_at: nowSeconds,
30
+ is_anonymous: false,
31
+ is_restricted: false,
32
+ restricted_reason: null,
33
+ requires_totp_mfa: false,
34
+ }),
35
+ "",
36
+ ].join(".");
37
+ }
38
+
8
39
  function createMockDocument(): Document {
9
40
  const cookieJar = new Map<string, string>();
10
41
  return {
@@ -23,12 +54,13 @@ function createMockDocument(): Document {
23
54
 
24
55
  describe("StackClientApp cross-domain auth", () => {
25
56
  it("uses the fresh post-auth refresh token when minting a cross-domain handoff", async () => {
57
+ const freshAccessToken = createAccessTokenString("fresh-refresh-token-id");
26
58
  const clientApp = new StackClientApp({
27
59
  baseUrl: "http://localhost:12345",
28
60
  projectId: "00000000-0000-4000-8000-000000000000",
29
61
  publishableClientKey: "stack-pk-test",
30
62
  tokenStore: {
31
- accessToken: "stale-access-token",
63
+ accessToken: createAccessTokenString("stale-refresh-token-id"),
32
64
  refreshToken: "stale-refresh-token",
33
65
  },
34
66
  redirectMethod: "none",
@@ -37,24 +69,43 @@ describe("StackClientApp cross-domain auth", () => {
37
69
 
38
70
  const clientInterface = Reflect.get(clientApp, "_interface");
39
71
  const originalSendClientRequest = Reflect.get(clientInterface, "sendClientRequest");
72
+ const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
40
73
  const capturedRefreshTokens: string[] = [];
74
+ const capturedAccessTokenRefreshTokenIds: string[] = [];
75
+ const refreshedRawRefreshTokens: string[] = [];
41
76
 
42
77
  Reflect.set(clientInterface, "sendClientRequest", async (_path: unknown, _requestOptions: unknown, session: unknown) => {
43
78
  const getRefreshToken = Reflect.get(session ?? {}, "getRefreshToken");
79
+ const getOrFetchLikelyValidTokens = Reflect.get(session ?? {}, "getOrFetchLikelyValidTokens");
44
80
  if (typeof getRefreshToken !== "function") {
45
81
  throw new Error("Expected cross-domain auth to pass a session to the client interface.");
46
82
  }
83
+ if (typeof getOrFetchLikelyValidTokens !== "function") {
84
+ throw new Error("Expected cross-domain auth to pass a session with token accessors.");
85
+ }
47
86
  const refreshToken = getRefreshToken.call(session);
48
87
  const refreshTokenString = Reflect.get(refreshToken ?? {}, "token");
49
88
  if (typeof refreshTokenString !== "string") {
50
89
  throw new Error("Expected cross-domain auth to pass a refresh-token-backed session.");
51
90
  }
52
91
  capturedRefreshTokens.push(refreshTokenString);
92
+ const tokens = await getOrFetchLikelyValidTokens.call(session, 0, null);
93
+ capturedAccessTokenRefreshTokenIds.push(tokens.accessToken.payload.refresh_token_id);
53
94
  return {
54
95
  ok: true,
55
96
  json: async () => ({ redirect_url: "https://example.com/handler/oauth-callback?code=handoff-code&state=handoff-state" }),
56
97
  };
57
98
  });
99
+ Reflect.set(clientInterface, "fetchNewAccessToken", async (refreshToken: unknown) => {
100
+ const refreshTokenString = Reflect.get(refreshToken ?? {}, "token");
101
+ if (typeof refreshTokenString !== "string") {
102
+ throw new Error("Expected refresh token while fetching a new access token.");
103
+ }
104
+ refreshedRawRefreshTokens.push(refreshTokenString);
105
+ return AccessToken.createIfValid(freshAccessToken) ?? (() => {
106
+ throw new Error("Expected test access token to be valid");
107
+ })();
108
+ });
58
109
 
59
110
  try {
60
111
  const createCrossDomainAuthRedirectUrl = Reflect.get(clientApp, "_createCrossDomainAuthRedirectUrl");
@@ -68,15 +119,18 @@ describe("StackClientApp cross-domain auth", () => {
68
119
  codeChallenge: "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~",
69
120
  afterCallbackRedirectUrl: "https://example.com/account-settings",
70
121
  overrideTokenStoreInit: {
71
- accessToken: "fresh-access-token",
122
+ accessToken: createAccessTokenString("fresh-stale-refresh-token-id"),
72
123
  refreshToken: "fresh-refresh-token",
73
124
  },
74
125
  })).resolves.toBe("https://example.com/handler/oauth-callback?code=handoff-code&state=handoff-state");
75
126
  } finally {
76
127
  Reflect.set(clientInterface, "sendClientRequest", originalSendClientRequest);
128
+ Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
77
129
  }
78
130
 
131
+ expect(refreshedRawRefreshTokens).toEqual(["fresh-refresh-token"]);
79
132
  expect(capturedRefreshTokens).toEqual(["fresh-refresh-token"]);
133
+ expect(capturedAccessTokenRefreshTokenIds).toEqual(["fresh-refresh-token-id"]);
80
134
  });
81
135
 
82
136
  it("uses a fresh nested OAuth state while preserving the outer cross-domain return state", async () => {
@@ -105,7 +159,7 @@ describe("StackClientApp cross-domain auth", () => {
105
159
  const previousWindow = globalThis.window;
106
160
  const previousDocument = globalThis.document;
107
161
  let redirectedUrl = "";
108
- vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
162
+ vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
109
163
  vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({
110
164
  state: "fresh-nested-state",
111
165
  codeChallenge: "fresh-nested-code-challenge",
@@ -138,4 +192,263 @@ describe("StackClientApp cross-domain auth", () => {
138
192
  expect(redirectUri.searchParams.get("hexclave_cross_domain_code_challenge")).toBe(outerCodeChallenge);
139
193
  expect(redirectUri.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe("https://demo.stack-auth.com/");
140
194
  });
195
+
196
+ it("clears a stale target-domain session before deferring to the source-domain session", async () => {
197
+ const projectId = "00000000-0000-4000-8000-000000000006";
198
+ const hostedAccessToken = createAccessTokenString("hosted-old-refresh-token-id");
199
+ const clientApp = new StackClientApp({
200
+ baseUrl: "http://localhost:12345",
201
+ projectId,
202
+ publishableClientKey: "stack-pk-test",
203
+ tokenStore: "memory",
204
+ redirectMethod: "window",
205
+ urls: {
206
+ default: { type: "hosted" },
207
+ },
208
+ noAutomaticPrefetch: true,
209
+ });
210
+ const tokenStore = Reflect.get(clientApp, "_memoryTokenStore");
211
+ if (!(tokenStore instanceof Store)) {
212
+ throw new Error("Expected StackClientApp to use a memory token store in this test.");
213
+ }
214
+ tokenStore.set({
215
+ refreshToken: "hosted-old-refresh-token",
216
+ accessToken: hostedAccessToken,
217
+ });
218
+
219
+ const currentUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`);
220
+ currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-anonymous-refresh-token-id");
221
+ currentUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", "https://demo.stack-auth.com/handler/oauth-callback");
222
+ currentUrl.searchParams.set("hexclave_cross_domain_state", "outer-state");
223
+ currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", "outer-code-challenge");
224
+ currentUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://demo.stack-auth.com/app");
225
+
226
+ const previousWindow = globalThis.window;
227
+ const previousDocument = globalThis.document;
228
+ let redirectedUrl = "";
229
+ const clientInterface = Reflect.get(clientApp, "_interface");
230
+ const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
231
+ Reflect.set(clientInterface, "fetchNewAccessToken", async () => {
232
+ return AccessToken.createIfValid(hostedAccessToken) ?? (() => {
233
+ throw new Error("Expected test access token to be valid");
234
+ })();
235
+ });
236
+ vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true);
237
+
238
+ globalThis.document = createMockDocument();
239
+ globalThis.window = {
240
+ location: {
241
+ href: currentUrl.toString(),
242
+ replace: (url: string) => {
243
+ redirectedUrl = url;
244
+ throw new Error("INTENTIONAL_TEST_ABORT");
245
+ },
246
+ },
247
+ } as any;
248
+
249
+ try {
250
+ await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
251
+ } finally {
252
+ Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
253
+ globalThis.window = previousWindow;
254
+ globalThis.document = previousDocument;
255
+ }
256
+
257
+ expect(tokenStore.get()).toEqual({
258
+ refreshToken: null,
259
+ accessToken: null,
260
+ });
261
+ expect(new URL(redirectedUrl).origin).toBe("https://demo.stack-auth.com");
262
+ });
263
+
264
+ it("uses the latest browser refresh cookie before computing nested cross-domain session IDs", async () => {
265
+ const projectId = "00000000-0000-4000-8000-000000000007";
266
+ const previousWindow = globalThis.window;
267
+ const previousDocument = globalThis.document;
268
+
269
+ globalThis.document = createMockDocument();
270
+ globalThis.window = {
271
+ location: {
272
+ href: "https://demo.stack-auth.com/",
273
+ protocol: "https:",
274
+ hostname: "demo.stack-auth.com",
275
+ },
276
+ } as any;
277
+
278
+ const clientApp = new StackClientApp({
279
+ baseUrl: "http://localhost:12345",
280
+ projectId,
281
+ publishableClientKey: "stack-pk-test",
282
+ tokenStore: "cookie",
283
+ redirectMethod: "none",
284
+ noAutomaticPrefetch: true,
285
+ });
286
+ const clientInterface = Reflect.get(clientApp, "_interface");
287
+ const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
288
+ const refreshedRawRefreshTokens: string[] = [];
289
+
290
+ try {
291
+ const getBrowserCookieTokenStore = Reflect.get(clientApp, "_getBrowserCookieTokenStore");
292
+ if (typeof getBrowserCookieTokenStore !== "function") {
293
+ throw new Error("Expected StackClientApp to expose _getBrowserCookieTokenStore in tests.");
294
+ }
295
+ const tokenStore = getBrowserCookieTokenStore.call(clientApp);
296
+ tokenStore.set({
297
+ refreshToken: "old-refresh-token",
298
+ accessToken: createAccessTokenString("old-refresh-token-id"),
299
+ });
300
+
301
+ document.cookie = `__Host-hexclave-refresh-${projectId}--default=${JSON.stringify({
302
+ refresh_token: "new-refresh-token",
303
+ updated_at_millis: 1,
304
+ })}`;
305
+ Reflect.set(clientInterface, "fetchNewAccessToken", async (refreshToken: unknown) => {
306
+ const refreshTokenString = Reflect.get(refreshToken ?? {}, "token");
307
+ if (typeof refreshTokenString !== "string") {
308
+ throw new Error("Expected refresh token while fetching a new access token.");
309
+ }
310
+ refreshedRawRefreshTokens.push(refreshTokenString);
311
+ return AccessToken.createIfValid(createAccessTokenString("new-refresh-token-id")) ?? (() => {
312
+ throw new Error("Expected test access token to be valid");
313
+ })();
314
+ });
315
+
316
+ const fetchCurrentRefreshTokenIdIfSignedIn = Reflect.get(clientApp, "_fetchCurrentRefreshTokenIdIfSignedIn");
317
+ if (typeof fetchCurrentRefreshTokenIdIfSignedIn !== "function") {
318
+ throw new Error("Expected StackClientApp to expose _fetchCurrentRefreshTokenIdIfSignedIn in tests.");
319
+ }
320
+ await expect(fetchCurrentRefreshTokenIdIfSignedIn.call(clientApp, {
321
+ awaitPendingAuthResolutions: false,
322
+ })).resolves.toBe("new-refresh-token-id");
323
+ } finally {
324
+ Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
325
+ globalThis.window = previousWindow;
326
+ globalThis.document = previousDocument;
327
+ }
328
+
329
+ expect(refreshedRawRefreshTokens).toEqual(["new-refresh-token"]);
330
+ });
331
+
332
+ it("uses direct sign-out instead of hosted sign-out redirects when code execution is available", async () => {
333
+ const clientApp = new StackClientApp({
334
+ baseUrl: "http://localhost:12345",
335
+ projectId: "00000000-0000-4000-8000-000000000003",
336
+ publishableClientKey: "stack-pk-test",
337
+ tokenStore: "memory",
338
+ redirectMethod: "window",
339
+ urls: {
340
+ handler: "/handler",
341
+ signOut: { type: "hosted" },
342
+ },
343
+ noAutomaticPrefetch: true,
344
+ });
345
+ const signOutSpy = vi.spyOn(clientApp, "signOut").mockRejectedValue(new Error("INTENTIONAL_TEST_ABORT"));
346
+
347
+ try {
348
+ await expect(clientApp.redirectToSignOut()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
349
+ expect(signOutSpy).toHaveBeenCalledWith();
350
+ } finally {
351
+ signOutSpy.mockRestore();
352
+ }
353
+ });
354
+
355
+ it("keeps default hosted signOut() on the source domain when afterSignOut is not configured", async () => {
356
+ const clientApp = new StackClientApp({
357
+ baseUrl: "http://localhost:12345",
358
+ projectId: "00000000-0000-4000-8000-000000000004",
359
+ publishableClientKey: "stack-pk-test",
360
+ tokenStore: "memory",
361
+ redirectMethod: "window",
362
+ urls: {
363
+ default: { type: "hosted" },
364
+ },
365
+ noAutomaticPrefetch: true,
366
+ });
367
+ const currentHref = "https://demo.stack-auth.com/settings?tab=profile";
368
+
369
+ const clientInterface = Reflect.get(clientApp, "_interface");
370
+ const originalSignOut = Reflect.get(clientInterface, "signOut");
371
+ Reflect.set(clientInterface, "signOut", async () => {});
372
+ const previousWindow = globalThis.window;
373
+ const previousDocument = globalThis.document;
374
+ let redirectedUrl = "";
375
+
376
+ globalThis.document = createMockDocument();
377
+ globalThis.window = {
378
+ location: {
379
+ href: currentHref,
380
+ replace: (url: string) => {
381
+ redirectedUrl = url;
382
+ throw new Error("INTENTIONAL_TEST_ABORT");
383
+ },
384
+ },
385
+ } as any;
386
+
387
+ try {
388
+ const signOut = Reflect.get(clientApp, "_signOut");
389
+ if (typeof signOut !== "function") {
390
+ throw new Error("Expected StackClientApp to expose _signOut in tests.");
391
+ }
392
+ await expect(signOut.call(clientApp, Reflect.get(clientInterface, "createSession").call(clientInterface, {
393
+ refreshToken: null,
394
+ }))).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
395
+ } finally {
396
+ Reflect.set(clientInterface, "signOut", originalSignOut);
397
+ globalThis.window = previousWindow;
398
+ globalThis.document = previousDocument;
399
+ }
400
+
401
+ expect(redirectedUrl).toBe("/settings?tab=profile");
402
+ });
403
+
404
+ it("ignores stale session callbacks after a newer refresh token owns the token store", async () => {
405
+ const clientApp = new StackClientApp({
406
+ baseUrl: "http://localhost:12345",
407
+ projectId: "00000000-0000-4000-8000-000000000005",
408
+ publishableClientKey: "stack-pk-test",
409
+ tokenStore: "memory",
410
+ redirectMethod: "none",
411
+ noAutomaticPrefetch: true,
412
+ });
413
+ const oldAccessToken = createAccessTokenString("old-refresh-token-id");
414
+ const refreshedOldAccessToken = createAccessTokenString("refreshed-old-refresh-token-id");
415
+ const newAccessToken = createAccessTokenString("new-refresh-token-id");
416
+ const tokenStore = new Store({
417
+ refreshToken: "old-refresh-token",
418
+ accessToken: oldAccessToken,
419
+ });
420
+ const clientInterface = Reflect.get(clientApp, "_interface");
421
+ const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
422
+ Reflect.set(clientInterface, "fetchNewAccessToken", async () => {
423
+ return AccessToken.createIfValid(refreshedOldAccessToken) ?? (() => {
424
+ throw new Error("Expected test access token to be valid");
425
+ })();
426
+ });
427
+
428
+ try {
429
+ const getSessionFromTokenStore = Reflect.get(clientApp, "_getSessionFromTokenStore");
430
+ if (typeof getSessionFromTokenStore !== "function") {
431
+ throw new Error("Expected StackClientApp to expose _getSessionFromTokenStore in tests.");
432
+ }
433
+ const oldSession = getSessionFromTokenStore.call(clientApp, tokenStore);
434
+ tokenStore.set({
435
+ refreshToken: "new-refresh-token",
436
+ accessToken: newAccessToken,
437
+ });
438
+
439
+ await oldSession.fetchNewTokens();
440
+ expect(tokenStore.get()).toEqual({
441
+ refreshToken: "new-refresh-token",
442
+ accessToken: newAccessToken,
443
+ });
444
+
445
+ oldSession.markInvalid();
446
+ expect(tokenStore.get()).toEqual({
447
+ refreshToken: "new-refresh-token",
448
+ accessToken: newAccessToken,
449
+ });
450
+ } finally {
451
+ Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
452
+ }
453
+ });
141
454
  });