@hexclave/js 1.0.5 → 1.0.8

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 (68) hide show
  1. package/dist/dev-tool/dev-tool-core.js +2 -2
  2. package/dist/dev-tool/dev-tool-core.js.map +1 -1
  3. package/dist/esm/dev-tool/dev-tool-core.js +2 -2
  4. package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
  5. package/dist/esm/generated/env.d.ts +26 -0
  6. package/dist/esm/{lib → generated}/env.d.ts.map +1 -1
  7. package/dist/esm/generated/env.js +67 -0
  8. package/dist/esm/generated/env.js.map +1 -0
  9. package/dist/esm/global.d.ts +8 -0
  10. package/dist/esm/global.d.ts.map +1 -0
  11. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  12. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +263 -3
  13. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  14. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +3 -1
  15. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  16. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +53 -26
  17. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  18. package/dist/esm/lib/hexclave-app/apps/implementations/common.d.ts +8 -8
  19. package/dist/esm/lib/hexclave-app/apps/implementations/common.d.ts.map +1 -1
  20. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +28 -14
  21. package/dist/esm/lib/hexclave-app/apps/implementations/common.js.map +1 -1
  22. package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
  23. package/dist/esm/lib/hexclave-app/url-targets.d.ts.map +1 -1
  24. package/dist/esm/lib/hexclave-app/url-targets.js +25 -11
  25. package/dist/esm/lib/hexclave-app/url-targets.js.map +1 -1
  26. package/dist/esm/lib/hexclave-app/url-targets.test.js +12 -0
  27. package/dist/esm/lib/hexclave-app/url-targets.test.js.map +1 -1
  28. package/dist/generated/env.d.ts +26 -0
  29. package/dist/{lib → generated}/env.d.ts.map +1 -1
  30. package/dist/generated/env.js +69 -0
  31. package/dist/generated/env.js.map +1 -0
  32. package/dist/global.d.d.ts +0 -0
  33. package/dist/global.d.ts +8 -0
  34. package/dist/global.d.ts.map +1 -0
  35. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  36. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +263 -3
  37. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  38. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +3 -1
  39. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  40. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +52 -25
  41. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  42. package/dist/lib/hexclave-app/apps/implementations/common.d.ts +8 -8
  43. package/dist/lib/hexclave-app/apps/implementations/common.d.ts.map +1 -1
  44. package/dist/lib/hexclave-app/apps/implementations/common.js +28 -14
  45. package/dist/lib/hexclave-app/apps/implementations/common.js.map +1 -1
  46. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
  47. package/dist/lib/hexclave-app/url-targets.d.ts.map +1 -1
  48. package/dist/lib/hexclave-app/url-targets.js +25 -11
  49. package/dist/lib/hexclave-app/url-targets.js.map +1 -1
  50. package/dist/lib/hexclave-app/url-targets.test.js +12 -0
  51. package/dist/lib/hexclave-app/url-targets.test.js.map +1 -1
  52. package/package.json +8 -4
  53. package/src/dev-tool/dev-tool-core.ts +2 -2
  54. package/src/generated/.gitignore +3 -0
  55. package/src/global.css +13 -0
  56. package/src/global.d.ts +12 -0
  57. package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +316 -3
  58. package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +69 -25
  59. package/src/lib/hexclave-app/apps/implementations/common.ts +33 -14
  60. package/src/lib/hexclave-app/url-targets.test.ts +17 -0
  61. package/src/lib/hexclave-app/url-targets.ts +25 -7
  62. package/dist/esm/lib/env.d.ts +0 -42
  63. package/dist/esm/lib/env.js +0 -93
  64. package/dist/esm/lib/env.js.map +0 -1
  65. package/dist/lib/env.d.ts +0 -42
  66. package/dist/lib/env.js +0 -95
  67. package/dist/lib/env.js.map +0 -1
  68. package/src/lib/env.ts +0 -93
@@ -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
  });
@@ -39,14 +39,14 @@ import { Store, storeLock } from "@hexclave/shared/dist/utils/stores";
39
39
  import { deindent, mergeScopeStrings } from "@hexclave/shared/dist/utils/strings";
40
40
  import type { TurnstileAction } from "@hexclave/shared/dist/utils/turnstile";
41
41
  import { BotChallengeExecutionFailedError, BotChallengeUserCancelledError, withBotChallengeFlow } from "@hexclave/shared/dist/utils/turnstile-flow";
42
- import { createUrlIfValid, isRelative } from "@hexclave/shared/dist/utils/urls";
42
+ import { createUrlIfValid, getRelativePart, isRelative } from "@hexclave/shared/dist/utils/urls";
43
43
  import { generateUuid } from "@hexclave/shared/dist/utils/uuids";
44
44
  import * as cookie from "cookie";
45
45
  import type * as yup from "yup";
46
46
  import { constructRedirectUrl } from "../../../../utils/url";
47
47
  import { callOAuthCallback, getNewOAuthProviderOrScopeUrl } from "../../../auth";
48
48
  import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookie, deleteCookieClient, getCookieClient, isSecure as isSecureCookieContext, saveVerifierAndState, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie";
49
- import { envVars } from "../../../env";
49
+ import { envVars } from "../../../../generated/env";
50
50
  import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud } from "../../api-keys";
51
51
  import { ConvexCtx, GetCurrentPartialUserOptions, GetCurrentUserOptions, HandlerUrlOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, ResolvedHandlerUrls, TokenStoreInit, hexclaveAppInternalsSymbol } from "../../common";
52
52
  import { DeprecatedOAuthConnection, OAuthConnection } from "../../connected-accounts";
@@ -776,12 +776,16 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
776
776
  return currentUrl.toString();
777
777
  }
778
778
 
779
- protected async _getCurrentRefreshTokenIdIfSignedIn(options?: {
779
+ protected async _fetchCurrentRefreshTokenIdIfSignedIn(options?: {
780
780
  awaitPendingAuthResolutions?: boolean,
781
781
  overrideTokenStoreInit?: TokenStoreInit,
782
782
  }): Promise<string | null> {
783
783
  const session = await this._getSession(options?.overrideTokenStoreInit, options);
784
- const tokens = await session.getOrFetchLikelyValidTokens(0, null);
784
+ // Nested cross-domain auth passes this ID to another origin, which later
785
+ // asks us to prove the same raw refresh token. A cached access token can be
786
+ // valid but stale relative to the refresh token, so mint from the refresh
787
+ // token that owns this session before exposing the ID.
788
+ const tokens = await session.fetchNewTokens();
785
789
  if (tokens?.refreshToken == null) {
786
790
  return null;
787
791
  }
@@ -799,7 +803,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
799
803
  return options.url;
800
804
  }
801
805
 
802
- const refreshTokenId = await this._getCurrentRefreshTokenIdIfSignedIn({
806
+ const refreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({
803
807
  awaitPendingAuthResolutions: options.awaitPendingAuthResolutions,
804
808
  overrideTokenStoreInit: options.overrideTokenStoreInit,
805
809
  });
@@ -854,7 +858,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
854
858
  if (!await this._isTrusted(afterCallbackRedirectUrl.toString())) {
855
859
  throw new Error(`Nested cross-domain auth after-callback redirect URL ${afterCallbackRedirectUrlString} is not trusted.`);
856
860
  }
857
- const currentRefreshTokenId = await this._getCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
861
+ const currentRefreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
858
862
  if (currentRefreshTokenId !== refreshTokenId) {
859
863
  throw new Error("Nested cross-domain auth source session does not match the requested refresh token ID.");
860
864
  }
@@ -873,8 +877,12 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
873
877
 
874
878
  // We are on b.com. Bounce to the trusted callback on a.com with a normal OAuth request
875
879
  // shape; a.com will verify the source session and issue the one-time code.
876
- const currentRefreshTokenId = await this._getCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
880
+ const currentRefreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
877
881
  if (currentRefreshTokenId === refreshTokenId) return false;
882
+ if (currentRefreshTokenId != null) {
883
+ const session = await this._getSession(undefined, { awaitPendingAuthResolutions: false });
884
+ session.markInvalid();
885
+ }
878
886
  const callbackUrlString = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.callbackUrl);
879
887
  if (callbackUrlString == null) {
880
888
  throw new HexclaveAssertionError("Nested cross-domain auth URL is missing callback URL");
@@ -1067,6 +1075,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1067
1075
  accessToken,
1068
1076
  };
1069
1077
  }
1078
+ private _getCurrentBrowserCookieTokenStoreValue(old: TokenObject | null): TokenObject {
1079
+ const tokens = this._getTokensFromCookies(this._getAllBrowserCookies());
1080
+ return {
1081
+ refreshToken: tokens.refreshToken,
1082
+ accessToken: tokens.accessToken ?? (old?.refreshToken === tokens.refreshToken ? old.accessToken : null),
1083
+ };
1084
+ }
1070
1085
  protected get _accessTokenCookieName() {
1071
1086
  // The access token, unlike the refresh token, should not depend on the project ID. We never want to store the
1072
1087
  // access token in cookies more than once because of how big it is (there's a limit of 4096 bytes for all cookies
@@ -1209,20 +1224,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1209
1224
  }
1210
1225
 
1211
1226
  if (this._storedBrowserCookieTokenStore === null) {
1212
- const getCurrentValue = (old: TokenObject | null) => {
1213
- const tokens = this._getTokensFromCookies(this._getAllBrowserCookies());
1214
- return {
1215
- refreshToken: tokens.refreshToken,
1216
- accessToken: tokens.accessToken ?? (old?.refreshToken === tokens.refreshToken ? old.accessToken : null),
1217
- };
1218
- };
1219
- this._storedBrowserCookieTokenStore = new Store<TokenObject>(getCurrentValue(null));
1227
+ this._storedBrowserCookieTokenStore = new Store<TokenObject>(this._getCurrentBrowserCookieTokenStoreValue(null));
1220
1228
  let hasSucceededInWriting = true;
1221
1229
 
1222
1230
  setInterval(() => {
1223
1231
  if (hasSucceededInWriting) {
1224
1232
  const oldValue = this._storedBrowserCookieTokenStore!.get();
1225
- const currentValue = getCurrentValue(oldValue);
1233
+ const currentValue = this._getCurrentBrowserCookieTokenStoreValue(oldValue);
1226
1234
  if (!deepPlainEquals(currentValue, oldValue)) {
1227
1235
  this._storedBrowserCookieTokenStore!.set(currentValue);
1228
1236
  }
@@ -1256,6 +1264,12 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1256
1264
  }
1257
1265
  }
1258
1266
  });
1267
+ } else {
1268
+ const oldValue = this._storedBrowserCookieTokenStore.get();
1269
+ const currentValue = this._getCurrentBrowserCookieTokenStoreValue(oldValue);
1270
+ if (!deepPlainEquals(currentValue, oldValue)) {
1271
+ this._storedBrowserCookieTokenStore.set(currentValue);
1272
+ }
1259
1273
  }
1260
1274
 
1261
1275
  return this._storedBrowserCookieTokenStore;
@@ -1392,17 +1406,17 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
1392
1406
  accessToken: tokenObj.accessToken,
1393
1407
  });
1394
1408
  session.onAccessTokenChange((newAccessToken) => {
1395
- tokenStore.update((old) => ({
1409
+ tokenStore.update((old) => InternalSession.calculateSessionKey(old) === sessionKey ? {
1396
1410
  ...old,
1397
1411
  accessToken: newAccessToken?.token ?? null
1398
- }));
1412
+ } : old);
1399
1413
  });
1400
1414
  session.onInvalidate(() => {
1401
- tokenStore.update((old) => ({
1415
+ tokenStore.update((old) => InternalSession.calculateSessionKey(old) === sessionKey ? {
1402
1416
  ...old,
1403
1417
  accessToken: null,
1404
1418
  refreshToken: null,
1405
- }));
1419
+ } : old);
1406
1420
  });
1407
1421
 
1408
1422
  let sessionsBySessionKey = this._sessionsByTokenStoreAndSessionKey.get(tokenStore) ?? new Map();
@@ -2294,16 +2308,16 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2294
2308
  private _getBotChallengeSiteKeys(): { visibleSiteKey: string, invisibleSiteKey: string } | null {
2295
2309
  if (!isBrowserLike()) return null;
2296
2310
 
2297
- const visibleSiteKey = envVars.NEXT_PUBLIC_STACK_BOT_CHALLENGE_SITE_KEY;
2311
+ const visibleSiteKey = envVars.HEXCLAVE_BOT_CHALLENGE_SITE_KEY;
2298
2312
  if (!visibleSiteKey) {
2299
2313
  if (!this._botChallengeSiteKeysWarned) {
2300
2314
  this._botChallengeSiteKeysWarned = true;
2301
- console.warn("[stack-auth] NEXT_PUBLIC_STACK_BOT_CHALLENGE_SITE_KEY is not set — bot challenge fraud protection is disabled. Set the env variable to enable it.");
2315
+ console.warn("[stack-auth] HEXCLAVE_BOT_CHALLENGE_SITE_KEY is not set — bot challenge fraud protection is disabled. Set the env variable to enable it.");
2302
2316
  }
2303
2317
  return null;
2304
2318
  }
2305
2319
 
2306
- const invisibleSiteKey = envVars.NEXT_PUBLIC_STACK_BOT_CHALLENGE_INVISIBLE_SITE_KEY ?? visibleSiteKey;
2320
+ const invisibleSiteKey = envVars.HEXCLAVE_BOT_CHALLENGE_INVISIBLE_SITE_KEY ?? visibleSiteKey;
2307
2321
 
2308
2322
  return { visibleSiteKey, invisibleSiteKey };
2309
2323
  }
@@ -2485,6 +2499,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2485
2499
  overrideTokenStoreInit?: TokenStoreInit,
2486
2500
  }): Promise<string> {
2487
2501
  const session = await this._getSession(options.overrideTokenStoreInit, { awaitPendingAuthResolutions: options.awaitPendingAuthResolutions });
2502
+ // The authorize endpoint intentionally verifies that the access token and
2503
+ // raw refresh token describe the same DB session. Force the access token to
2504
+ // be minted from the refresh token we are about to send, instead of reusing
2505
+ // a still-valid cached token from a pre-handoff session snapshot.
2506
+ await session.fetchNewTokens();
2488
2507
  const response = await this._interface.sendClientRequest(
2489
2508
  "/auth/oauth/cross-domain/authorize",
2490
2509
  {
@@ -2611,7 +2630,13 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
2611
2630
 
2612
2631
  async redirectToSignIn(options?: RedirectToOptions) { return await this._redirectToHandler("signIn", options); }
2613
2632
  async redirectToSignUp(options?: RedirectToOptions) { return await this._redirectToHandler("signUp", options); }
2614
- async redirectToSignOut(options?: RedirectToOptions) { return await this._redirectToHandler("signOut", options); }
2633
+ async redirectToSignOut(options?: RedirectToOptions) {
2634
+ const configuredSignOutTarget = this._urlOptions.signOut ?? this._urlOptions.default;
2635
+ if (typeof configuredSignOutTarget !== "string" && configuredSignOutTarget?.type === "hosted") {
2636
+ return await this.signOut();
2637
+ }
2638
+ return await this._redirectToHandler("signOut", options);
2639
+ }
2615
2640
  async redirectToEmailVerification(options?: RedirectToOptions) { return await this._redirectToHandler("emailVerification", options); }
2616
2641
  async redirectToPasswordReset(options?: RedirectToOptions) { return await this._redirectToHandler("passwordReset", options); }
2617
2642
  async redirectToForgotPassword(options?: RedirectToOptions) { return await this._redirectToHandler("forgotPassword", options); }
@@ -3329,11 +3354,30 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
3329
3354
  if (options?.redirectUrl) {
3330
3355
  await this._redirectTo({ url: options.redirectUrl, replace: true });
3331
3356
  } else {
3332
- await this.redirectToAfterSignOut();
3357
+ await this._redirectToDefaultAfterSignOut();
3333
3358
  }
3334
3359
  });
3335
3360
  }
3336
3361
 
3362
+ protected async _redirectToDefaultAfterSignOut(): Promise<void> {
3363
+ if (this._urlOptions.afterSignOut != null) {
3364
+ await this.redirectToAfterSignOut({ replace: true });
3365
+ return;
3366
+ }
3367
+
3368
+ if (this._urlOptions.home != null) {
3369
+ await this.redirectToHome({ replace: true });
3370
+ return;
3371
+ }
3372
+
3373
+ if (this._urlOptions.default?.type === "hosted" && typeof window !== "undefined") {
3374
+ await this._redirectTo({ url: getRelativePart(new URL(window.location.href)), replace: true });
3375
+ return;
3376
+ }
3377
+
3378
+ await this.redirectToAfterSignOut({ replace: true });
3379
+ }
3380
+
3337
3381
  async signOut(options?: { redirectUrl?: URL | string, tokenStore?: TokenStoreInit }): Promise<void> {
3338
3382
  const user = await this.getUser({ tokenStore: options?.tokenStore ?? undefined as any });
3339
3383
  if (user) {