@hexclave/js 1.0.11 → 1.0.13

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 (20) hide show
  1. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  2. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +78 -0
  3. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  4. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -1
  5. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  6. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +8 -4
  7. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  8. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +1 -1
  9. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  10. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +78 -0
  11. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  12. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -1
  13. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  14. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +8 -4
  15. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  16. package/dist/lib/hexclave-app/apps/implementations/common.js +1 -1
  17. package/package.json +2 -2
  18. package/src/integrations/convex/component/README.md +1 -1
  19. package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +108 -0
  20. package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +10 -2
@@ -14,7 +14,7 @@ let _hexclave_shared_dist_utils_caches = require("@hexclave/shared/dist/utils/ca
14
14
  let _hexclave_shared_dist_utils_globals = require("@hexclave/shared/dist/utils/globals");
15
15
 
16
16
  //#region src/lib/hexclave-app/apps/implementations/common.ts
17
- const clientVersion = "js @hexclave/js@1.0.11";
17
+ const clientVersion = "js @hexclave/js@1.0.13";
18
18
  if (clientVersion.startsWith("STACK_COMPILE_TIME")) throw new _hexclave_shared_dist_utils_errors.HexclaveAssertionError("Client version was not replaced. Something went wrong during build!");
19
19
  const replaceHexclavePortPrefix = (input) => {
20
20
  if (!input) return input;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "//": "THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template (FOR package.json FILES, PLEASE EDIT package-template.json)",
3
3
  "name": "@hexclave/js",
4
- "version": "1.0.11",
4
+ "version": "1.0.13",
5
5
  "repository": "https://github.com/hexclave/hexclave",
6
6
  "sideEffects": false,
7
7
  "main": "./dist/index.js",
@@ -66,7 +66,7 @@
66
66
  "rrweb": "^1.1.3",
67
67
  "tsx": "^4.21.0",
68
68
  "yup": "^1.7.1",
69
- "@hexclave/shared": "1.0.11"
69
+ "@hexclave/shared": "1.0.13"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@quetzallabs/i18n": "^0.1.19",
@@ -23,7 +23,7 @@ import { getConvexProvidersConfig } from "@hexclave/js/convex-auth.config"; //
23
23
 
24
24
  export default {
25
25
  providers: getConvexProvidersConfig({
26
- projectId: process.env.STACK_PROJECT_ID, // or: process.env.NEXT_PUBLIC_STACK_PROJECT_ID
26
+ projectId: process.env.HEXCLAVE_PROJECT_ID, // or: process.env.NEXT_PUBLIC_HEXCLAVE_PROJECT_ID
27
27
  }),
28
28
  }
29
29
  ```
@@ -287,6 +287,12 @@ describe("StackClientApp cross-domain auth", () => {
287
287
  const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
288
288
  const refreshedRawRefreshTokens: string[] = [];
289
289
 
290
+ // Cookie-store writes queue a background trusted-parent-domain lookup. Without this stub, that
291
+ // lookup fetches the (unreachable) baseUrl with retries while holding the global store lock,
292
+ // which starves any later test that needs the write lock (e.g. signOut). Not restored on
293
+ // purpose: queued tasks can still run after this test body finishes.
294
+ vi.spyOn(clientApp as any, "_getTrustedParentDomain").mockResolvedValue(null);
295
+
290
296
  try {
291
297
  const getBrowserCookieTokenStore = Reflect.get(clientApp, "_getBrowserCookieTokenStore");
292
298
  if (typeof getBrowserCookieTokenStore !== "function") {
@@ -329,6 +335,108 @@ describe("StackClientApp cross-domain auth", () => {
329
335
  expect(refreshedRawRefreshTokens).toEqual(["new-refresh-token"]);
330
336
  });
331
337
 
338
+ it("does not re-bounce nested cross-domain auth after the OAuth callback consumed code+state from the URL", async () => {
339
+ const projectId = "00000000-0000-4000-8000-000000000008";
340
+ const previousWindow = globalThis.window;
341
+ const previousDocument = globalThis.document;
342
+
343
+ const strippedUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`);
344
+ strippedUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-refresh-token-id");
345
+ strippedUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", "https://demo.stack-auth.com/");
346
+ const urlAtConstructionTime = new URL(strippedUrl);
347
+ urlAtConstructionTime.searchParams.set("code", "one-time-code");
348
+ urlAtConstructionTime.searchParams.set("state", "nested-oauth-state");
349
+
350
+ // Construct before installing the window mock so the constructor does not schedule its own
351
+ // nested-auth resolution; the assertions below drive the handler explicitly.
352
+ const clientApp = new StackClientApp({
353
+ baseUrl: "http://localhost:12345",
354
+ projectId,
355
+ publishableClientKey: "stack-pk-test",
356
+ tokenStore: "memory",
357
+ redirectMethod: "window",
358
+ noAutomaticPrefetch: true,
359
+ });
360
+
361
+ globalThis.document = createMockDocument();
362
+ globalThis.window = {
363
+ location: {
364
+ href: strippedUrl.toString(),
365
+ replace: () => {
366
+ throw new Error("INTENTIONAL_TEST_ABORT");
367
+ },
368
+ },
369
+ } as any;
370
+
371
+ vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
372
+ vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({
373
+ state: "fresh-nested-state",
374
+ codeChallenge: "fresh-nested-code-challenge",
375
+ });
376
+ vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true);
377
+
378
+ try {
379
+ // Without the construction-time URL, the handler re-bounces (location.replace aborts).
380
+ await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
381
+ // With it, the in-flight OAuth callback wins and the handler stands down.
382
+ await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime)).resolves.toBe(false);
383
+ // The live-URL guard must also stand down on its own when code+state are still present.
384
+ (globalThis.window as any).location.href = urlAtConstructionTime.toString();
385
+ await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).resolves.toBe(false);
386
+ } finally {
387
+ globalThis.window = previousWindow;
388
+ globalThis.document = previousDocument;
389
+ }
390
+ });
391
+
392
+ it("passes the construction-time URL to the nested cross-domain auth handler", async () => {
393
+ const projectId = "00000000-0000-4000-8000-000000000009";
394
+ const previousWindow = globalThis.window;
395
+ const previousDocument = globalThis.document;
396
+
397
+ const callbackUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`);
398
+ callbackUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-refresh-token-id");
399
+ callbackUrl.searchParams.set("code", "one-time-code");
400
+ callbackUrl.searchParams.set("state", "nested-oauth-state");
401
+ const strippedUrl = new URL(callbackUrl);
402
+ strippedUrl.searchParams.delete("code");
403
+ strippedUrl.searchParams.delete("state");
404
+
405
+ globalThis.document = createMockDocument();
406
+ globalThis.window = {
407
+ location: {
408
+ href: callbackUrl.toString(),
409
+ },
410
+ } as any;
411
+
412
+ const nestedAuthSpy = vi.spyOn(StackClientApp.prototype as any, "_maybeHandleNestedCrossDomainAuth").mockResolvedValue(false);
413
+
414
+ try {
415
+ new StackClientApp({
416
+ baseUrl: "http://localhost:12345",
417
+ projectId,
418
+ publishableClientKey: "stack-pk-test",
419
+ tokenStore: "memory",
420
+ redirectMethod: "window",
421
+ noAutomaticPrefetch: true,
422
+ });
423
+
424
+ // Simulate consumeOAuthCallbackQueryParams stripping code+state before microtasks run.
425
+ (globalThis.window as any).location.href = strippedUrl.toString();
426
+ await new Promise((resolve) => setTimeout(resolve, 0));
427
+
428
+ expect(nestedAuthSpy).toHaveBeenCalledTimes(1);
429
+ const urlArgument = nestedAuthSpy.mock.calls[0][0] as URL;
430
+ expect(urlArgument).toBeInstanceOf(URL);
431
+ expect(urlArgument.searchParams.get("code")).toBe("one-time-code");
432
+ expect(urlArgument.searchParams.get("state")).toBe("nested-oauth-state");
433
+ } finally {
434
+ nestedAuthSpy.mockRestore();
435
+ globalThis.window = previousWindow;
436
+ globalThis.document = previousDocument;
437
+ }
438
+ });
439
+
332
440
  it("uses direct sign-out instead of hosted sign-out redirects when code execution is available", async () => {
333
441
  const clientApp = new StackClientApp({
334
442
  baseUrl: "http://localhost:12345",
@@ -661,8 +661,12 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
661
661
  }
662
662
 
663
663
  if (isBrowserLike()) {
664
+ // The OAuth callback resolution scheduled above synchronously strips `code` and `state`
665
+ // from the URL before its token exchange, so the nested handler must decide based on the
666
+ // URL the page was loaded with, not whatever is in the address bar when it runs.
667
+ const urlAtConstructionTime = new URL(window.location.href);
664
668
  this._trackPendingAuthResolution(async () => {
665
- await this._maybeHandleNestedCrossDomainAuth();
669
+ await this._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime);
666
670
  });
667
671
  }
668
672
 
@@ -819,11 +823,15 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
819
823
  return targetUrl.toString();
820
824
  }
821
825
 
822
- protected async _maybeHandleNestedCrossDomainAuth(): Promise<boolean> {
826
+ protected async _maybeHandleNestedCrossDomainAuth(urlAtConstructionTime?: URL): Promise<boolean> {
823
827
  if (typeof window === "undefined") return false;
824
828
  const currentUrl = new URL(window.location.href);
825
829
  // A real OAuth callback wins over nested handoff detection on the final return to b.com.
830
+ // The OAuth callback resolution strips `code` and `state` from the live URL before this
831
+ // runs, so the check must also consult the URL captured at construction time — otherwise
832
+ // we'd re-bounce to the source domain while the token exchange is still in flight.
826
833
  if (currentUrl.searchParams.has("code") && currentUrl.searchParams.has("state")) return false;
834
+ if (urlAtConstructionTime != null && urlAtConstructionTime.searchParams.has("code") && urlAtConstructionTime.searchParams.has("state")) return false;
827
835
  const refreshTokenId = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.refreshTokenId);
828
836
  if (refreshTokenId == null) return false;
829
837