@hexclave/next 1.0.51 → 1.0.53
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.
- package/dist/{admin-app-impl-BGNL1KyZ.d.ts → admin-app-impl-Bga0o6cL.d.ts} +11 -8
- package/dist/{admin-app-impl-BGNL1KyZ.d.ts.map → admin-app-impl-Bga0o6cL.d.ts.map} +1 -1
- package/dist/clickmap/clickmap-core.d.ts +1 -1
- package/dist/clickmap/index.d.ts +1 -1
- package/dist/{client-app-Majjw8Xp.d.ts → client-app-B0kBC67Z.d.ts} +2 -1
- package/dist/{client-app-Majjw8Xp.d.ts.map → client-app-B0kBC67Z.d.ts.map} +1 -1
- package/dist/common-BK_sydxF.d.ts.map +1 -1
- package/dist/components/api-key-dialogs.d.ts +1 -1
- package/dist/components/selected-team-switcher.d.ts +1 -1
- package/dist/components/team-icon.d.ts +1 -1
- package/dist/components/team-switcher.d.ts +1 -1
- package/dist/components-page/account-settings/payments/payments-page.d.ts +1 -1
- package/dist/components-page/account-settings/teams/leave-team-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-api-keys-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-display-name-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-member-invitation-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-member-list-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-page.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-profile-image-section.d.ts +1 -1
- package/dist/components-page/account-settings/teams/team-profile-user-section.d.ts +1 -1
- package/dist/components-page/hexclave-handler-client.d.ts +1 -1
- package/dist/components-page/hexclave-handler.d.ts +1 -1
- package/dist/components-page/sign-out.js +11 -2
- package/dist/components-page/sign-out.js.map +1 -1
- package/dist/components-page/sign-out.test.d.ts +1 -0
- package/dist/components-page/sign-out.test.js +157 -0
- package/dist/components-page/sign-out.test.js.map +1 -0
- package/dist/dev-tool/dev-tool-core.d.ts +1 -1
- package/dist/dev-tool/dev-tool-core.d.ts.map +1 -1
- package/dist/dev-tool/dev-tool-core.js +58 -28
- package/dist/dev-tool/dev-tool-core.js.map +1 -1
- package/dist/dev-tool/dev-tool-styles.js +35 -0
- package/dist/dev-tool/dev-tool-styles.js.map +1 -1
- package/dist/dev-tool/index.d.ts +1 -1
- package/dist/esm/components/api-key-dialogs.d.ts +1 -1
- package/dist/esm/components-page/sign-out.d.ts.map +1 -1
- package/dist/esm/components-page/sign-out.js +12 -3
- package/dist/esm/components-page/sign-out.js.map +1 -1
- package/dist/esm/components-page/sign-out.test.d.ts +1 -0
- package/dist/esm/components-page/sign-out.test.js +156 -0
- package/dist/esm/components-page/sign-out.test.js.map +1 -0
- package/dist/esm/dev-tool/dev-tool-core.d.ts.map +1 -1
- package/dist/esm/dev-tool/dev-tool-core.js +58 -28
- package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
- package/dist/esm/dev-tool/dev-tool-styles.js +35 -0
- package/dist/esm/dev-tool/dev-tool-styles.js.map +1 -1
- package/dist/esm/generated/global-css.d.ts +1 -1
- package/dist/esm/generated/global-css.js +1 -1
- package/dist/esm/generated/global-css.js.map +1 -1
- package/dist/esm/generated/quetzal-translations.d.ts +2 -2
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +5 -4
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +97 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +3 -2
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js +26 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/common.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/common.js +21 -4
- package/dist/esm/lib/hexclave-app/apps/implementations/common.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts +37 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.js +89 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.test.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.test.js +134 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.test.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.d.ts +8 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.d.ts.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.js +73 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.js +92 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js +27 -6
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
- package/dist/esm/pushed-config-error-overlay/index.js +1 -1
- package/dist/esm/pushed-config-error-overlay/index.js.map +1 -1
- package/dist/esm/utils/session-storage.d.ts +12 -0
- package/dist/esm/utils/session-storage.d.ts.map +1 -0
- package/dist/esm/utils/session-storage.js +30 -0
- package/dist/esm/utils/session-storage.js.map +1 -0
- package/dist/generated/global-css.d.ts +1 -1
- package/dist/generated/global-css.js +1 -1
- package/dist/generated/global-css.js.map +1 -1
- package/dist/generated/quetzal-translations.d.ts +1 -1
- package/dist/{hexclave-provider-IAd7nlbi.d.ts → hexclave-provider-SQbs9rmZ.d.ts} +3 -3
- package/dist/{hexclave-provider-IAd7nlbi.d.ts.map → hexclave-provider-SQbs9rmZ.d.ts.map} +1 -1
- package/dist/{hooks-BKXOF6jh.d.ts → hooks-ykQ7Wsco.d.ts} +2 -2
- package/dist/{hooks-BKXOF6jh.d.ts.map → hooks-ykQ7Wsco.d.ts.map} +1 -1
- package/dist/{index-Ck1qKoin.d.ts → index-DwE1cNLH.d.ts} +2 -2
- package/dist/{index-Ck1qKoin.d.ts.map → index-DwE1cNLH.d.ts.map} +1 -1
- package/dist/index.d.ts +4 -4
- package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +97 -0
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +26 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/common.js +21 -4
- package/dist/lib/hexclave-app/apps/implementations/common.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/index.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts +37 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.js +92 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.js.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.test.d.ts +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.test.js +133 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.test.js.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.d.ts +8 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.d.ts.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.js +74 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.js.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.d.ts +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.js +91 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.js.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js +27 -6
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js.map +1 -1
- package/dist/lib/hexclave-app/apps/index.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/interfaces/admin-app.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/interfaces/client-app.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/interfaces/client-app.js.map +1 -1
- package/dist/lib/hexclave-app/apps/interfaces/server-app.d.ts +1 -1
- package/dist/lib/hexclave-app/index.d.ts +1 -1
- package/dist/lib/hexclave-app/projects/index.d.ts +1 -1
- package/dist/lib/hexclave-app/teams/index.d.ts +1 -1
- package/dist/lib/hexclave-app/users/index.d.ts +1 -1
- package/dist/lib/hooks.d.ts +1 -1
- package/dist/providers/hexclave-context.d.ts +1 -1
- package/dist/providers/hexclave-provider-client.d.ts +1 -1
- package/dist/providers/hexclave-provider.d.ts +1 -1
- package/dist/providers/translation-provider.d.ts +1 -1
- package/dist/pushed-config-error-overlay/index.d.ts +1 -1
- package/dist/pushed-config-error-overlay/index.js +1 -1
- package/dist/pushed-config-error-overlay/index.js.map +1 -1
- package/dist/{quetzal-translations-XeH3zQ9l.d.ts → quetzal-translations-CK45_4Dm.d.ts} +3 -3
- package/dist/{quetzal-translations-XeH3zQ9l.d.ts.map → quetzal-translations-CK45_4Dm.d.ts.map} +1 -1
- package/dist/sign-out-DBnSkfDz.d.ts.map +1 -1
- package/dist/{translation-provider-WOaZXqAz.d.ts → translation-provider-DQ3BJepJ.d.ts} +2 -2
- package/dist/{translation-provider-WOaZXqAz.d.ts.map → translation-provider-DQ3BJepJ.d.ts.map} +1 -1
- package/dist/utils/session-storage.d.ts +12 -0
- package/dist/utils/session-storage.d.ts.map +1 -0
- package/dist/utils/session-storage.js +33 -0
- package/dist/utils/session-storage.js.map +1 -0
- package/package.json +4 -4
- package/src/components-page/sign-out.test.tsx +190 -0
- package/src/components-page/sign-out.tsx +22 -3
- package/src/dev-tool/dev-tool-core.ts +100 -35
- package/src/dev-tool/dev-tool-styles.ts +35 -0
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +127 -0
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +34 -1
- package/src/lib/hexclave-app/apps/implementations/common.ts +18 -1
- package/src/lib/hexclave-app/apps/implementations/redirect-back-state.test.ts +134 -0
- package/src/lib/hexclave-app/apps/implementations/redirect-back-state.ts +140 -0
- package/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.test.ts +127 -0
- package/src/lib/hexclave-app/apps/implementations/redirect-loop-breaker.ts +117 -0
- package/src/lib/hexclave-app/apps/implementations/server-app-impl.ts +31 -5
- package/src/lib/hexclave-app/apps/interfaces/client-app.ts +1 -0
- package/src/pushed-config-error-overlay/index.ts +1 -1
- package/src/utils/session-storage.ts +45 -0
|
@@ -648,6 +648,133 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
648
648
|
expect(redirectedUrl).toBe("/settings?tab=profile");
|
|
649
649
|
});
|
|
650
650
|
|
|
651
|
+
it("restores a dropped after_auth_return_to from the sessionStorage mirror when redirecting after sign-in", async () => {
|
|
652
|
+
const previousWindow = globalThis.window;
|
|
653
|
+
const previousDocument = globalThis.document;
|
|
654
|
+
const hadPreviousWindow = Reflect.has(globalThis, "window");
|
|
655
|
+
const sessionStorageMap = new Map<string, string>();
|
|
656
|
+
const windowMock = {
|
|
657
|
+
location: {
|
|
658
|
+
href: "https://demo.example.test/handler/sign-in?after_auth_return_to=%2Fmusic%3Ftrack%3D1",
|
|
659
|
+
},
|
|
660
|
+
sessionStorage: {
|
|
661
|
+
getItem: (key: string) => sessionStorageMap.get(key) ?? null,
|
|
662
|
+
setItem: (key: string, value: string) => {
|
|
663
|
+
sessionStorageMap.set(key, value);
|
|
664
|
+
},
|
|
665
|
+
removeItem: (key: string) => {
|
|
666
|
+
sessionStorageMap.delete(key);
|
|
667
|
+
},
|
|
668
|
+
},
|
|
669
|
+
};
|
|
670
|
+
Reflect.set(globalThis, "window", windowMock);
|
|
671
|
+
globalThis.document = createMockDocument();
|
|
672
|
+
|
|
673
|
+
try {
|
|
674
|
+
// The constructor mirrors the redirect-back state from the arrival URL into sessionStorage.
|
|
675
|
+
const clientApp = new StackClientApp({
|
|
676
|
+
baseUrl: "http://localhost:12345",
|
|
677
|
+
projectId: "00000000-0000-4000-8000-000000000011",
|
|
678
|
+
publishableClientKey: "stack-pk-test",
|
|
679
|
+
tokenStore: "memory",
|
|
680
|
+
redirectMethod: "window",
|
|
681
|
+
noAutomaticPrefetch: true,
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
// Intermediate hops (MFA, magic link, OAuth round-trips, ...) dropped the query params.
|
|
685
|
+
windowMock.location.href = "https://demo.example.test/handler/mfa";
|
|
686
|
+
|
|
687
|
+
const redirectUrl = await clientApp[hexclaveAppInternalsSymbol].getRedirectToHandlerUrl("afterSignIn");
|
|
688
|
+
expect(redirectUrl).toBe("/music?track=1");
|
|
689
|
+
|
|
690
|
+
// noRedirectBack opts out of the mirror just like it opts out of the query param.
|
|
691
|
+
const noRedirectBackUrl = await clientApp[hexclaveAppInternalsSymbol].getRedirectToHandlerUrl("afterSignIn", { noRedirectBack: true });
|
|
692
|
+
expect(new URL(noRedirectBackUrl, "https://demo.example.test").pathname).toBe("/");
|
|
693
|
+
} finally {
|
|
694
|
+
globalThis.document = previousDocument;
|
|
695
|
+
if (hadPreviousWindow) {
|
|
696
|
+
Reflect.set(globalThis, "window", previousWindow);
|
|
697
|
+
} else {
|
|
698
|
+
Reflect.deleteProperty(globalThis, "window");
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it("restores dropped cross-domain handoff state from the sessionStorage mirror when redirecting after sign-in", async () => {
|
|
704
|
+
const projectId = "00000000-0000-4000-8000-000000000012";
|
|
705
|
+
const previousWindow = globalThis.window;
|
|
706
|
+
const hadPreviousWindow = Reflect.has(globalThis, "window");
|
|
707
|
+
const sessionStorageMap = new Map<string, string>();
|
|
708
|
+
|
|
709
|
+
const handoffState = "mirror-handoff-state";
|
|
710
|
+
const handoffCodeChallenge = "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~";
|
|
711
|
+
const handoffAfterCallbackRedirect = "https://demo.example.test/dashboard";
|
|
712
|
+
const redirectBackUrl = new URL("https://demo.example.test/handler/oauth-callback");
|
|
713
|
+
redirectBackUrl.searchParams.set("hexclave_cross_domain_auth", "1");
|
|
714
|
+
redirectBackUrl.searchParams.set("hexclave_cross_domain_state", handoffState);
|
|
715
|
+
redirectBackUrl.searchParams.set("hexclave_cross_domain_code_challenge", handoffCodeChallenge);
|
|
716
|
+
redirectBackUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", handoffAfterCallbackRedirect);
|
|
717
|
+
|
|
718
|
+
const arrivalUrl = new URL(`https://${projectId}.built-with-stack-auth.com/handler/sign-in`);
|
|
719
|
+
arrivalUrl.searchParams.set("after_auth_return_to", redirectBackUrl.toString());
|
|
720
|
+
arrivalUrl.searchParams.set("hexclave_cross_domain_state", handoffState);
|
|
721
|
+
arrivalUrl.searchParams.set("hexclave_cross_domain_code_challenge", handoffCodeChallenge);
|
|
722
|
+
arrivalUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", handoffAfterCallbackRedirect);
|
|
723
|
+
|
|
724
|
+
const previousDocument = globalThis.document;
|
|
725
|
+
const windowMock = {
|
|
726
|
+
location: {
|
|
727
|
+
href: arrivalUrl.toString(),
|
|
728
|
+
},
|
|
729
|
+
sessionStorage: {
|
|
730
|
+
getItem: (key: string) => sessionStorageMap.get(key) ?? null,
|
|
731
|
+
setItem: (key: string, value: string) => {
|
|
732
|
+
sessionStorageMap.set(key, value);
|
|
733
|
+
},
|
|
734
|
+
removeItem: (key: string) => {
|
|
735
|
+
sessionStorageMap.delete(key);
|
|
736
|
+
},
|
|
737
|
+
},
|
|
738
|
+
};
|
|
739
|
+
Reflect.set(globalThis, "window", windowMock);
|
|
740
|
+
globalThis.document = createMockDocument();
|
|
741
|
+
|
|
742
|
+
try {
|
|
743
|
+
const clientApp = new StackClientApp({
|
|
744
|
+
baseUrl: "http://localhost:12345",
|
|
745
|
+
projectId,
|
|
746
|
+
publishableClientKey: "stack-pk-test",
|
|
747
|
+
tokenStore: "memory",
|
|
748
|
+
redirectMethod: "window",
|
|
749
|
+
noAutomaticPrefetch: true,
|
|
750
|
+
});
|
|
751
|
+
const crossDomainAuthorizeRedirect = "https://demo.example.test/handler/oauth-callback?code=minted-code&state=mirror-handoff-state";
|
|
752
|
+
const createCrossDomainAuthRedirectUrlSpy = vi
|
|
753
|
+
.spyOn(clientApp as any, "_createCrossDomainAuthRedirectUrl")
|
|
754
|
+
.mockResolvedValue(crossDomainAuthorizeRedirect);
|
|
755
|
+
|
|
756
|
+
// All redirect-back query params were dropped before the after-sign-in redirect.
|
|
757
|
+
windowMock.location.href = `https://${projectId}.built-with-stack-auth.com/handler/sign-in`;
|
|
758
|
+
|
|
759
|
+
const redirectUrl = await clientApp[hexclaveAppInternalsSymbol].getRedirectToHandlerUrl("afterSignIn");
|
|
760
|
+
|
|
761
|
+
expect(createCrossDomainAuthRedirectUrlSpy).toHaveBeenCalledWith(expect.objectContaining({
|
|
762
|
+
redirectUri: redirectBackUrl.toString(),
|
|
763
|
+
state: handoffState,
|
|
764
|
+
codeChallenge: handoffCodeChallenge,
|
|
765
|
+
afterCallbackRedirectUrl: handoffAfterCallbackRedirect,
|
|
766
|
+
}));
|
|
767
|
+
expect(redirectUrl).toBe(crossDomainAuthorizeRedirect);
|
|
768
|
+
} finally {
|
|
769
|
+
globalThis.document = previousDocument;
|
|
770
|
+
if (hadPreviousWindow) {
|
|
771
|
+
Reflect.set(globalThis, "window", previousWindow);
|
|
772
|
+
} else {
|
|
773
|
+
Reflect.deleteProperty(globalThis, "window");
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
});
|
|
777
|
+
|
|
651
778
|
it("ignores stale session callbacks after a newer refresh token owns the token store", async () => {
|
|
652
779
|
const clientApp = new StackClientApp({
|
|
653
780
|
baseUrl: "http://localhost:12345",
|
|
@@ -59,6 +59,8 @@ import { TeamPermission } from "../../permissions";
|
|
|
59
59
|
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
|
|
60
60
|
import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, Team, TeamCreateOptions, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
|
|
61
61
|
import { buildCliAuthConfirmUrl, getHostedHandlerUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
|
|
62
|
+
import { augmentUrlWithPersistedRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
|
|
63
|
+
import { recordRedirectAndThrowIfLoopDetected } from "./redirect-loop-breaker";
|
|
62
64
|
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
|
|
63
65
|
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
|
|
64
66
|
import { _HexclaveAdminAppImplIncomplete } from "./admin-app-impl";
|
|
@@ -732,6 +734,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
732
734
|
this._trackPendingAuthResolution(async () => {
|
|
733
735
|
await this._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime);
|
|
734
736
|
});
|
|
737
|
+
|
|
738
|
+
// Mirror any redirect-back state from the page URL into sessionStorage, so the return trip
|
|
739
|
+
// survives later hops that drop the query params (OAuth provider round-trips, MFA, magic
|
|
740
|
+
// links, ...). See redirect-back-state.ts for details.
|
|
741
|
+
saveRedirectBackStateFromUrl({ url: urlAtConstructionTime, projectId: this.projectId });
|
|
735
742
|
}
|
|
736
743
|
|
|
737
744
|
if (isBrowserLike() && resolvedOptions.devTool !== false) {
|
|
@@ -2903,6 +2910,17 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2903
2910
|
}
|
|
2904
2911
|
|
|
2905
2912
|
protected async _redirectTo(options: { url: URL | string, replace?: boolean }) {
|
|
2913
|
+
if (this._redirectMethod !== "none" && isBrowserLike()) {
|
|
2914
|
+
const currentUrl = new URL(window.location.href);
|
|
2915
|
+
const targetUrl = new URL(options.url, currentUrl);
|
|
2916
|
+
// Circuit breaker: if this exact redirect keeps repeating, throw so the initiating page
|
|
2917
|
+
// renders its error state instead of bouncing the user between pages forever.
|
|
2918
|
+
recordRedirectAndThrowIfLoopDetected({ currentUrl, targetUrl });
|
|
2919
|
+
// Client-side navigations don't re-run the app constructor, so mirror redirect-back state
|
|
2920
|
+
// on every SDK-driven hop too (see redirect-back-state.ts).
|
|
2921
|
+
saveRedirectBackStateFromUrl({ url: targetUrl, projectId: this.projectId });
|
|
2922
|
+
}
|
|
2923
|
+
|
|
2906
2924
|
if (this._redirectMethod === "none") {
|
|
2907
2925
|
return;
|
|
2908
2926
|
} else if (isReactServer && this._redirectMethod === "nextjs") {
|
|
@@ -2967,9 +2985,21 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2967
2985
|
throw new Error(`No URL for handler name ${handlerName}`);
|
|
2968
2986
|
}
|
|
2969
2987
|
|
|
2970
|
-
|
|
2988
|
+
let currentUrl = isReactServer || typeof window === "undefined"
|
|
2971
2989
|
? null
|
|
2972
2990
|
: new URL(window.location.href);
|
|
2991
|
+
if (
|
|
2992
|
+
currentUrl != null
|
|
2993
|
+
&& options?.noRedirectBack !== true
|
|
2994
|
+
&& (handlerName === "afterSignIn" || handlerName === "afterSignUp")
|
|
2995
|
+
) {
|
|
2996
|
+
// If intermediate hops dropped the redirect-back query params, restore them from the
|
|
2997
|
+
// sessionStorage mirror so the user still returns to where they came from instead of
|
|
2998
|
+
// stranding on the default post-auth page (on hosted components, that would be the hosted
|
|
2999
|
+
// welcome page). The restored URL goes through the same _isTrusted / cross-domain-authorize
|
|
3000
|
+
// validation below as one read from query params.
|
|
3001
|
+
currentUrl = augmentUrlWithPersistedRedirectBackState({ currentUrl, projectId: this.projectId });
|
|
3002
|
+
}
|
|
2973
3003
|
const plan = await planRedirectToHandler({
|
|
2974
3004
|
handlerName,
|
|
2975
3005
|
rawHandlerUrl,
|
|
@@ -4071,6 +4101,9 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
4071
4101
|
awaitPendingAuthResolutions: async () => {
|
|
4072
4102
|
await this._awaitPendingAuthResolutions();
|
|
4073
4103
|
},
|
|
4104
|
+
isTrustedRedirectUrl: async (url: string) => {
|
|
4105
|
+
return await this._isTrusted(url);
|
|
4106
|
+
},
|
|
4074
4107
|
};
|
|
4075
4108
|
};
|
|
4076
4109
|
|
|
@@ -12,6 +12,7 @@ import { ReactPromise, runAsynchronously } from "@hexclave/shared/dist/utils/pro
|
|
|
12
12
|
import { suspendIfSsr, use } from "@hexclave/shared/dist/utils/react";
|
|
13
13
|
import { Result } from "@hexclave/shared/dist/utils/results";
|
|
14
14
|
import { Store } from "@hexclave/shared/dist/utils/stores";
|
|
15
|
+
import { deindent } from "@hexclave/shared/dist/utils/strings";
|
|
15
16
|
import { getDefaultApiUrls } from "@hexclave/shared/dist/utils/urls";
|
|
16
17
|
import React, { useCallback } from "react"; // THIS_LINE_PLATFORM react-like
|
|
17
18
|
import { envVars } from "../../../../generated/env";
|
|
@@ -44,7 +45,23 @@ const showMissingConfigAlertInBrowser = (message: string) => {
|
|
|
44
45
|
};
|
|
45
46
|
|
|
46
47
|
const throwMissingProjectIdError = (): never => {
|
|
47
|
-
const message =
|
|
48
|
+
const message = deindent`
|
|
49
|
+
Welcome to Hexclave!
|
|
50
|
+
|
|
51
|
+
It seems that you haven't provided a project ID.
|
|
52
|
+
|
|
53
|
+
${envVars.NODE_ENV?.includes("dev") ? deindent`
|
|
54
|
+
You are running this app in development mode. Make sure that your start command is wrapped in a call to the Hexclave CLI: \`npx @hexclave/cli dev --config-file <file> -- <command>\`
|
|
55
|
+
|
|
56
|
+
Alternatively, you can also connect to a Cloud project's environment variables instead by creating a project on the Hexclave dashboard at https://app.hexclave.com and put its information in the HEXCLAVE_PROJECT_ID environment variable.
|
|
57
|
+
` : envVars.NODE_ENV?.includes("prod") ? deindent`
|
|
58
|
+
It seems you are running a project in production. Please create a project on the Hexclave dashboard at https://app.hexclave.com and put it in the HEXCLAVE_PROJECT_ID environment variable.
|
|
59
|
+
` : deindent`
|
|
60
|
+
- If you are running a development project and would like to connect the local Hexclave dashboard, wrap your start command in: \`npx @hexclave/cli dev --config-file <file> -- <command>\`
|
|
61
|
+
|
|
62
|
+
- If you are running a project in production and would like to connect to Hexclave Cloud, create a project on the Hexclave dashboard at https://app.hexclave.com and put its information in the HEXCLAVE_PROJECT_ID environment variable.
|
|
63
|
+
`}
|
|
64
|
+
`;
|
|
48
65
|
showMissingConfigAlertInBrowser(message);
|
|
49
66
|
return throwErr(new Error(message));
|
|
50
67
|
};
|
|
@@ -0,0 +1,134 @@
|
|
|
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { augmentUrlWithPersistedRedirectBackState, readRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
|
|
7
|
+
|
|
8
|
+
function createMockSessionStorage() {
|
|
9
|
+
const map = new Map<string, string>();
|
|
10
|
+
return {
|
|
11
|
+
getItem: (key: string) => map.get(key) ?? null,
|
|
12
|
+
setItem: (key: string, value: string) => {
|
|
13
|
+
map.set(key, value);
|
|
14
|
+
},
|
|
15
|
+
removeItem: (key: string) => {
|
|
16
|
+
map.delete(key);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const previousWindow = Reflect.get(globalThis, "window");
|
|
22
|
+
const hadPreviousWindow = Reflect.has(globalThis, "window");
|
|
23
|
+
const projectId = "00000000-0000-4000-8000-000000000000";
|
|
24
|
+
|
|
25
|
+
describe("redirect-back state mirror", () => {
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
Reflect.set(globalThis, "window", { sessionStorage: createMockSessionStorage() });
|
|
28
|
+
vi.useFakeTimers();
|
|
29
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.useRealTimers();
|
|
34
|
+
if (hadPreviousWindow) {
|
|
35
|
+
Reflect.set(globalThis, "window", previousWindow);
|
|
36
|
+
} else {
|
|
37
|
+
Reflect.deleteProperty(globalThis, "window");
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("round-trips redirect-back state including the cross-domain handoff params", () => {
|
|
42
|
+
const url = new URL("https://hosted.example.test/handler/sign-in");
|
|
43
|
+
url.searchParams.set("after_auth_return_to", "https://app.example.test/handler/oauth-callback?hexclave_cross_domain_auth=1");
|
|
44
|
+
url.searchParams.set("hexclave_cross_domain_state", "the-state");
|
|
45
|
+
url.searchParams.set("hexclave_cross_domain_code_challenge", "the-code-challenge");
|
|
46
|
+
url.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://app.example.test/dashboard");
|
|
47
|
+
|
|
48
|
+
saveRedirectBackStateFromUrl({ url, projectId });
|
|
49
|
+
|
|
50
|
+
expect(readRedirectBackState({ projectId })).toEqual({
|
|
51
|
+
afterAuthReturnTo: "https://app.example.test/handler/oauth-callback?hexclave_cross_domain_auth=1",
|
|
52
|
+
crossDomainState: "the-state",
|
|
53
|
+
crossDomainCodeChallenge: "the-code-challenge",
|
|
54
|
+
crossDomainAfterCallbackRedirectUrl: "https://app.example.test/dashboard",
|
|
55
|
+
savedAtMillis: Date.now(),
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("returns null when nothing was saved and does not save URLs without after_auth_return_to", () => {
|
|
60
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
61
|
+
saveRedirectBackStateFromUrl({ url: new URL("https://hosted.example.test/handler/sign-in?foo=bar"), projectId });
|
|
62
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("expires the state after the TTL", () => {
|
|
66
|
+
const url = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/music");
|
|
67
|
+
saveRedirectBackStateFromUrl({ url, projectId });
|
|
68
|
+
|
|
69
|
+
vi.advanceTimersByTime(29 * 60 * 1000);
|
|
70
|
+
expect(readRedirectBackState({ projectId })).not.toBeNull();
|
|
71
|
+
|
|
72
|
+
vi.advanceTimersByTime(2 * 60 * 1000);
|
|
73
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("scopes the state per project", () => {
|
|
77
|
+
const url = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/music");
|
|
78
|
+
saveRedirectBackStateFromUrl({ url, projectId });
|
|
79
|
+
expect(readRedirectBackState({ projectId: "11111111-1111-4111-8111-111111111111" })).toBeNull();
|
|
80
|
+
expect(readRedirectBackState({ projectId })).not.toBeNull();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("discards malformed stored values", () => {
|
|
84
|
+
const storage = createMockSessionStorage();
|
|
85
|
+
storage.setItem(`hexclave-redirect-back-state-${projectId}`, "{not json");
|
|
86
|
+
Reflect.set(globalThis, "window", { sessionStorage: storage });
|
|
87
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
88
|
+
|
|
89
|
+
storage.setItem(`hexclave-redirect-back-state-${projectId}`, JSON.stringify({ some: "other-shape" }));
|
|
90
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("restores dropped params onto a URL, but never overrides an explicit after_auth_return_to", () => {
|
|
94
|
+
const arrivalUrl = new URL("https://hosted.example.test/handler/sign-in");
|
|
95
|
+
arrivalUrl.searchParams.set("after_auth_return_to", "https://app.example.test/handler/oauth-callback");
|
|
96
|
+
arrivalUrl.searchParams.set("hexclave_cross_domain_state", "the-state");
|
|
97
|
+
arrivalUrl.searchParams.set("hexclave_cross_domain_code_challenge", "the-code-challenge");
|
|
98
|
+
arrivalUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://app.example.test/dashboard");
|
|
99
|
+
saveRedirectBackStateFromUrl({ url: arrivalUrl, projectId });
|
|
100
|
+
|
|
101
|
+
// Params were dropped along the way: restore all of them.
|
|
102
|
+
const strippedUrl = new URL("https://hosted.example.test/handler/mfa");
|
|
103
|
+
const augmented = augmentUrlWithPersistedRedirectBackState({ currentUrl: strippedUrl, projectId });
|
|
104
|
+
expect(augmented.searchParams.get("after_auth_return_to")).toBe("https://app.example.test/handler/oauth-callback");
|
|
105
|
+
expect(augmented.searchParams.get("hexclave_cross_domain_state")).toBe("the-state");
|
|
106
|
+
expect(augmented.searchParams.get("hexclave_cross_domain_code_challenge")).toBe("the-code-challenge");
|
|
107
|
+
expect(augmented.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe("https://app.example.test/dashboard");
|
|
108
|
+
// The input URL is not mutated.
|
|
109
|
+
expect(strippedUrl.searchParams.has("after_auth_return_to")).toBe(false);
|
|
110
|
+
|
|
111
|
+
// Explicit query params always win over the mirror.
|
|
112
|
+
const explicitUrl = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/somewhere-else");
|
|
113
|
+
const unchanged = augmentUrlWithPersistedRedirectBackState({ currentUrl: explicitUrl, projectId });
|
|
114
|
+
expect(unchanged.toString()).toBe(explicitUrl.toString());
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("leaves the URL unchanged when there is no mirrored state", () => {
|
|
118
|
+
const url = new URL("https://hosted.example.test/handler/sign-in");
|
|
119
|
+
const augmented = augmentUrlWithPersistedRedirectBackState({ currentUrl: url, projectId });
|
|
120
|
+
expect(augmented.toString()).toBe(url.toString());
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("degrades to a no-op when sessionStorage is unavailable", () => {
|
|
124
|
+
Reflect.set(globalThis, "window", {
|
|
125
|
+
get sessionStorage(): Storage {
|
|
126
|
+
throw new Error("Storage is disabled");
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
const url = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/music");
|
|
130
|
+
saveRedirectBackStateFromUrl({ url, projectId });
|
|
131
|
+
expect(readRedirectBackState({ projectId })).toBeNull();
|
|
132
|
+
expect(augmentUrlWithPersistedRedirectBackState({ currentUrl: url, projectId }).toString()).toBe(url.toString());
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
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 { readSessionStorageItem, removeSessionStorageItem, writeSessionStorageItem } from "../../../../utils/session-storage";
|
|
6
|
+
import { crossDomainAuthQueryParams } from "./redirect-page-urls";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resilience layer for the redirect-back ("return to where you came from") state of auth flows.
|
|
10
|
+
*
|
|
11
|
+
* The primary transport for this state is query params (`after_auth_return_to` +
|
|
12
|
+
* `hexclave_cross_domain_*`), which must survive every hop of the auth flow (sign-in page, OAuth
|
|
13
|
+
* provider round-trips, MFA, magic links, ...). Historically, any hop that dropped these params
|
|
14
|
+
* stranded the user on the wrong page after auth — on hosted components, that meant the hosted
|
|
15
|
+
* welcome page instead of the customer's app.
|
|
16
|
+
*
|
|
17
|
+
* This module mirrors the state into sessionStorage whenever it appears in a URL, so
|
|
18
|
+
* `redirectToAfterSignIn`/`redirectToAfterSignUp` can restore it if the params were dropped along
|
|
19
|
+
* the way. Query params always take precedence; the mirror is only a fallback. It is scoped
|
|
20
|
+
* per-tab (sessionStorage), per-origin (sessionStorage again), and per-project, and it expires
|
|
21
|
+
* after a TTL. Restored URLs still go through the exact same trust validation as URLs read from
|
|
22
|
+
* query params (the `_isTrusted` gate client-side and the cross-domain authorize endpoint
|
|
23
|
+
* server-side), so the mirror never widens the set of allowed destinations.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
// Deliberately NOT consumed/cleared on use: React can invoke the after-sign-in redirect twice
|
|
27
|
+
// (strict mode, remounts), and consuming on first read would make the second invocation fall back
|
|
28
|
+
// to the default post-auth page and race the correct redirect. Staleness is bounded by the TTL
|
|
29
|
+
// instead, and a stale entry merely returns the user to the last page that started an auth flow
|
|
30
|
+
// in this tab — which is also what they'd expect.
|
|
31
|
+
//
|
|
32
|
+
// The TTL must stay below the 1h lifetime of the outer PKCE verifier cookie (see
|
|
33
|
+
// saveVerifierAndState in cookie.ts): the mirrored cross-domain params are useless once their
|
|
34
|
+
// verifier cookie on the app domain has expired.
|
|
35
|
+
const redirectBackStateTtlMillis = 30 * 60 * 1000;
|
|
36
|
+
|
|
37
|
+
export type PersistedRedirectBackState = {
|
|
38
|
+
/**
|
|
39
|
+
* The `after_auth_return_to` value exactly as it appeared in the URL (possibly relative).
|
|
40
|
+
* sessionStorage is per-origin, so restoring it into a URL later always resolves it against the
|
|
41
|
+
* same origin it was saved from.
|
|
42
|
+
*/
|
|
43
|
+
afterAuthReturnTo: string,
|
|
44
|
+
crossDomainState: string | null,
|
|
45
|
+
crossDomainCodeChallenge: string | null,
|
|
46
|
+
crossDomainAfterCallbackRedirectUrl: string | null,
|
|
47
|
+
/** Wall-clock ms (Date.now()); must survive page loads, so performance.now() is not usable. */
|
|
48
|
+
savedAtMillis: number,
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function getStorageKey(projectId: string): string {
|
|
52
|
+
return `hexclave-redirect-back-state-${projectId}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isPersistedRedirectBackState(value: unknown): value is PersistedRedirectBackState {
|
|
56
|
+
return (
|
|
57
|
+
typeof value === "object"
|
|
58
|
+
&& value !== null
|
|
59
|
+
&& "afterAuthReturnTo" in value && typeof value.afterAuthReturnTo === "string"
|
|
60
|
+
&& "crossDomainState" in value && (value.crossDomainState === null || typeof value.crossDomainState === "string")
|
|
61
|
+
&& "crossDomainCodeChallenge" in value && (value.crossDomainCodeChallenge === null || typeof value.crossDomainCodeChallenge === "string")
|
|
62
|
+
&& "crossDomainAfterCallbackRedirectUrl" in value && (value.crossDomainAfterCallbackRedirectUrl === null || typeof value.crossDomainAfterCallbackRedirectUrl === "string")
|
|
63
|
+
&& "savedAtMillis" in value && typeof value.savedAtMillis === "number"
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Mirrors the redirect-back state from the given URL into sessionStorage, if it has any. Called
|
|
69
|
+
* with the page URL at app construction time (full page loads) and with the target URL of every
|
|
70
|
+
* SDK-driven redirect (client-side navigations, which don't re-run the constructor).
|
|
71
|
+
*/
|
|
72
|
+
export function saveRedirectBackStateFromUrl(options: { url: URL, projectId: string }): void {
|
|
73
|
+
const afterAuthReturnTo = options.url.searchParams.get("after_auth_return_to");
|
|
74
|
+
if (afterAuthReturnTo == null) {
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
const state: PersistedRedirectBackState = {
|
|
78
|
+
afterAuthReturnTo,
|
|
79
|
+
crossDomainState: options.url.searchParams.get(crossDomainAuthQueryParams.state),
|
|
80
|
+
crossDomainCodeChallenge: options.url.searchParams.get(crossDomainAuthQueryParams.codeChallenge),
|
|
81
|
+
crossDomainAfterCallbackRedirectUrl: options.url.searchParams.get(crossDomainAuthQueryParams.afterCallbackRedirectUrl),
|
|
82
|
+
savedAtMillis: Date.now(),
|
|
83
|
+
};
|
|
84
|
+
writeSessionStorageItem(getStorageKey(options.projectId), JSON.stringify(state));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function readRedirectBackState(options: { projectId: string }): PersistedRedirectBackState | null {
|
|
88
|
+
const storageKey = getStorageKey(options.projectId);
|
|
89
|
+
const raw = readSessionStorageItem(storageKey);
|
|
90
|
+
if (raw == null) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
let parsed: unknown;
|
|
94
|
+
try {
|
|
95
|
+
parsed = JSON.parse(raw);
|
|
96
|
+
} catch {
|
|
97
|
+
// Corrupted value (eg. written by a different SDK version); losing the fallback for this flow
|
|
98
|
+
// is the pre-existing behavior, so just discard it.
|
|
99
|
+
removeSessionStorageItem(storageKey);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
if (!isPersistedRedirectBackState(parsed)) {
|
|
103
|
+
removeSessionStorageItem(storageKey);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
const ageMillis = Date.now() - parsed.savedAtMillis;
|
|
107
|
+
if (ageMillis < 0 || ageMillis > redirectBackStateTtlMillis) {
|
|
108
|
+
removeSessionStorageItem(storageKey);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
return parsed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Returns a copy of `currentUrl` with any redirect-back query params that were dropped along the
|
|
116
|
+
* way restored from the sessionStorage mirror. If `currentUrl` already carries
|
|
117
|
+
* `after_auth_return_to`, it is returned unchanged — explicit query params always win.
|
|
118
|
+
*/
|
|
119
|
+
export function augmentUrlWithPersistedRedirectBackState(options: { currentUrl: URL, projectId: string }): URL {
|
|
120
|
+
if (options.currentUrl.searchParams.has("after_auth_return_to")) {
|
|
121
|
+
return options.currentUrl;
|
|
122
|
+
}
|
|
123
|
+
const persisted = readRedirectBackState({ projectId: options.projectId });
|
|
124
|
+
if (persisted == null) {
|
|
125
|
+
return options.currentUrl;
|
|
126
|
+
}
|
|
127
|
+
const augmented = new URL(options.currentUrl.toString());
|
|
128
|
+
augmented.searchParams.set("after_auth_return_to", persisted.afterAuthReturnTo);
|
|
129
|
+
const crossDomainParamValues = [
|
|
130
|
+
[crossDomainAuthQueryParams.state, persisted.crossDomainState],
|
|
131
|
+
[crossDomainAuthQueryParams.codeChallenge, persisted.crossDomainCodeChallenge],
|
|
132
|
+
[crossDomainAuthQueryParams.afterCallbackRedirectUrl, persisted.crossDomainAfterCallbackRedirectUrl],
|
|
133
|
+
] as const;
|
|
134
|
+
for (const [paramName, persistedValue] of crossDomainParamValues) {
|
|
135
|
+
if (persistedValue != null && !augmented.searchParams.has(paramName)) {
|
|
136
|
+
augmented.searchParams.set(paramName, persistedValue);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return augmented;
|
|
140
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { recordRedirectAndThrowIfLoopDetected } from "./redirect-loop-breaker";
|
|
7
|
+
|
|
8
|
+
function createMockSessionStorage() {
|
|
9
|
+
const map = new Map<string, string>();
|
|
10
|
+
return {
|
|
11
|
+
getItem: (key: string) => map.get(key) ?? null,
|
|
12
|
+
setItem: (key: string, value: string) => {
|
|
13
|
+
map.set(key, value);
|
|
14
|
+
},
|
|
15
|
+
removeItem: (key: string) => {
|
|
16
|
+
map.delete(key);
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const previousWindow = Reflect.get(globalThis, "window");
|
|
22
|
+
const hadPreviousWindow = Reflect.has(globalThis, "window");
|
|
23
|
+
|
|
24
|
+
function record(from: string, to: string) {
|
|
25
|
+
recordRedirectAndThrowIfLoopDetected({
|
|
26
|
+
currentUrl: new URL(from),
|
|
27
|
+
targetUrl: new URL(to),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("recordRedirectAndThrowIfLoopDetected", () => {
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
Reflect.set(globalThis, "window", { sessionStorage: createMockSessionStorage() });
|
|
34
|
+
vi.useFakeTimers();
|
|
35
|
+
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
afterEach(() => {
|
|
39
|
+
vi.useRealTimers();
|
|
40
|
+
if (hadPreviousWindow) {
|
|
41
|
+
Reflect.set(globalThis, "window", previousWindow);
|
|
42
|
+
} else {
|
|
43
|
+
Reflect.deleteProperty(globalThis, "window");
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("allows distinct redirects in quick succession", () => {
|
|
48
|
+
for (let i = 0; i < 20; i++) {
|
|
49
|
+
record(`https://app.example.test/page-${i}`, `https://app.example.test/page-${i + 1}`);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("throws once the same redirect repeats 5 times within the window", () => {
|
|
54
|
+
for (let i = 0; i < 4; i++) {
|
|
55
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
56
|
+
vi.advanceTimersByTime(1000);
|
|
57
|
+
}
|
|
58
|
+
expect(() => {
|
|
59
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
60
|
+
}).toThrowError(/Redirect loop detected/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("treats redirects that differ only in query params or hash as identical", () => {
|
|
64
|
+
// Real loops usually rotate nonce-style params (code, state, after_auth_return_to) each cycle.
|
|
65
|
+
for (let i = 0; i < 4; i++) {
|
|
66
|
+
record(
|
|
67
|
+
`https://app.example.test/dashboard?code=code-${i}`,
|
|
68
|
+
`https://hosted.example.test/handler/sign-in?state=state-${i}#frag-${i}`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
expect(() => {
|
|
72
|
+
record("https://app.example.test/dashboard?code=final", "https://hosted.example.test/handler/sign-in?state=final");
|
|
73
|
+
}).toThrowError(/Redirect loop detected/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("does not throw when the identical redirects are spread out beyond the window", () => {
|
|
77
|
+
for (let i = 0; i < 20; i++) {
|
|
78
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
79
|
+
vi.advanceTimersByTime(31_000);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("allows the same redirect again after a loop was broken", () => {
|
|
84
|
+
for (let i = 0; i < 4; i++) {
|
|
85
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
86
|
+
}
|
|
87
|
+
expect(() => {
|
|
88
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
89
|
+
}).toThrowError(/Redirect loop detected/);
|
|
90
|
+
// The breadcrumbs for the broken loop are dropped, so a manual retry isn't instantly blocked.
|
|
91
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("counts self-redirects (same origin and path) towards loops", () => {
|
|
95
|
+
for (let i = 0; i < 4; i++) {
|
|
96
|
+
record(`https://hosted.example.test/handler/sign-in?attempt=${i}`, "https://hosted.example.test/handler/sign-in");
|
|
97
|
+
}
|
|
98
|
+
expect(() => {
|
|
99
|
+
record("https://hosted.example.test/handler/sign-in?attempt=final", "https://hosted.example.test/handler/sign-in");
|
|
100
|
+
}).toThrowError(/Redirect loop detected/);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("degrades to a no-op when sessionStorage is unavailable", () => {
|
|
104
|
+
Reflect.set(globalThis, "window", {
|
|
105
|
+
get sessionStorage(): Storage {
|
|
106
|
+
throw new Error("Storage is disabled");
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
for (let i = 0; i < 20; i++) {
|
|
110
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("recovers from corrupted breadcrumb storage", () => {
|
|
115
|
+
const storage = createMockSessionStorage();
|
|
116
|
+
storage.setItem("hexclave-redirect-loop-breadcrumbs", "{not json");
|
|
117
|
+
Reflect.set(globalThis, "window", { sessionStorage: storage });
|
|
118
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
119
|
+
// After recovery, loop detection works again.
|
|
120
|
+
for (let i = 0; i < 3; i++) {
|
|
121
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
122
|
+
}
|
|
123
|
+
expect(() => {
|
|
124
|
+
record("https://app.example.test/dashboard", "https://hosted.example.test/handler/sign-in");
|
|
125
|
+
}).toThrowError(/Redirect loop detected/);
|
|
126
|
+
});
|
|
127
|
+
});
|