@hexclave/next 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 (24) hide show
  1. package/dist/esm/generated/quetzal-translations.d.ts +2 -2
  2. package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  3. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +78 -0
  4. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  5. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -1
  6. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  7. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +8 -4
  8. package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  9. package/dist/esm/lib/hexclave-app/apps/implementations/common.js +1 -1
  10. package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
  11. package/dist/generated/quetzal-translations.d.ts +2 -2
  12. package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
  13. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +78 -0
  14. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
  15. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -1
  16. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
  17. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +8 -4
  18. package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
  19. package/dist/lib/hexclave-app/apps/implementations/common.js +1 -1
  20. package/dist/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
  21. package/package.json +4 -4
  22. package/src/integrations/convex/component/README.md +1 -1
  23. package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +108 -0
  24. package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +10 -2
@@ -17,7 +17,7 @@ let ____________generated_env_js = require("../../../../generated/env.js");
17
17
  let ______url_targets_js = require("../../url-targets.js");
18
18
 
19
19
  //#region src/lib/hexclave-app/apps/implementations/common.ts
20
- const clientVersion = "js @hexclave/next@1.0.11";
20
+ const clientVersion = "js @hexclave/next@1.0.13";
21
21
  if (clientVersion.startsWith("STACK_COMPILE_TIME")) throw new _hexclave_shared_dist_utils_errors.HexclaveAssertionError("Client version was not replaced. Something went wrong during build!");
22
22
  const replaceHexclavePortPrefix = (input) => {
23
23
  if (!input) return input;
@@ -70,7 +70,7 @@ declare class _HexclaveServerAppImplIncomplete<HasTokenStore extends boolean, Pr
70
70
  protected _serverNotificationCategoryFromCrud(userId: string, crud: NotificationPreferenceCrud['Server']['Read']): NotificationCategory;
71
71
  protected _serverOAuthProviderFromCrud(crud: OAuthProviderCrud['Server']['Read']): {
72
72
  id: string;
73
- type: "x" | "google" | "github" | "microsoft" | "spotify" | "facebook" | "discord" | "gitlab" | "bitbucket" | "linkedin" | "apple" | "twitch";
73
+ type: "google" | "github" | "microsoft" | "spotify" | "facebook" | "discord" | "gitlab" | "bitbucket" | "linkedin" | "apple" | "x" | "twitch";
74
74
  userId: string;
75
75
  accountId: string;
76
76
  email: string | undefined;
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/next",
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",
@@ -75,9 +75,9 @@
75
75
  "rrweb": "^1.1.3",
76
76
  "tsx": "^4.21.0",
77
77
  "yup": "^1.7.1",
78
- "@hexclave/sc": "1.0.11",
79
- "@hexclave/ui": "1.0.11",
80
- "@hexclave/shared": "1.0.11"
78
+ "@hexclave/shared": "1.0.13",
79
+ "@hexclave/sc": "1.0.13",
80
+ "@hexclave/ui": "1.0.13"
81
81
  },
82
82
  "peerDependencies": {
83
83
  "@types/react": ">=18.0.0",
@@ -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",
@@ -690,8 +690,12 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
690
690
  }
691
691
 
692
692
  if (isBrowserLike()) {
693
+ // The OAuth callback resolution scheduled above synchronously strips `code` and `state`
694
+ // from the URL before its token exchange, so the nested handler must decide based on the
695
+ // URL the page was loaded with, not whatever is in the address bar when it runs.
696
+ const urlAtConstructionTime = new URL(window.location.href);
693
697
  this._trackPendingAuthResolution(async () => {
694
- await this._maybeHandleNestedCrossDomainAuth();
698
+ await this._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime);
695
699
  });
696
700
  }
697
701
 
@@ -858,11 +862,15 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
858
862
  return targetUrl.toString();
859
863
  }
860
864
 
861
- protected async _maybeHandleNestedCrossDomainAuth(): Promise<boolean> {
865
+ protected async _maybeHandleNestedCrossDomainAuth(urlAtConstructionTime?: URL): Promise<boolean> {
862
866
  if (typeof window === "undefined") return false;
863
867
  const currentUrl = new URL(window.location.href);
864
868
  // A real OAuth callback wins over nested handoff detection on the final return to b.com.
869
+ // The OAuth callback resolution strips `code` and `state` from the live URL before this
870
+ // runs, so the check must also consult the URL captured at construction time — otherwise
871
+ // we'd re-bounce to the source domain while the token exchange is still in flight.
865
872
  if (currentUrl.searchParams.has("code") && currentUrl.searchParams.has("state")) return false;
873
+ if (urlAtConstructionTime != null && urlAtConstructionTime.searchParams.has("code") && urlAtConstructionTime.searchParams.has("state")) return false;
866
874
  const refreshTokenId = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.refreshTokenId);
867
875
  if (refreshTokenId == null) return false;
868
876