@hexclave/js 1.0.51 → 1.0.52
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-Cz9Ub_C8.d.ts → admin-app-impl-DHs4il4z.d.ts} +11 -8
- package/dist/{admin-app-impl-Cz9Ub_C8.d.ts.map → admin-app-impl-DHs4il4z.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-BgdvwktS.d.ts → client-app-BoOM8rxo.d.ts} +2 -1
- package/dist/{client-app-BgdvwktS.d.ts.map → client-app-BoOM8rxo.d.ts.map} +1 -1
- package/dist/common-CRu2FsO9.d.ts.map +1 -1
- 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/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/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 +24 -3
- 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/pushed-config-error-overlay/index.test.js +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/index.d.ts +1 -1
- 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 +24 -3
- 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/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/pushed-config-error-overlay/index.test.js +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 +2 -2
- 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 +27 -2
- 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
|
@@ -57,6 +57,8 @@ import { TeamPermission } from "../../permissions";
|
|
|
57
57
|
import { AdminOwnedProject, AdminProjectUpdateOptions, Project, adminProjectCreateOptionsToCrud } from "../../projects";
|
|
58
58
|
import { EditableTeamMemberProfile, ReceivedTeamInvitation, SentTeamInvitation, Team, TeamCreateOptions, TeamUpdateOptions, TeamUser, teamCreateOptionsToCrud, teamUpdateOptionsToCrud } from "../../teams";
|
|
59
59
|
import { buildCliAuthConfirmUrl, getHostedHandlerUrl, isHostedHandlerUrlForProject, resolveHandlerUrls } from "../../url-targets";
|
|
60
|
+
import { augmentUrlWithPersistedRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
|
|
61
|
+
import { recordRedirectAndThrowIfLoopDetected } from "./redirect-loop-breaker";
|
|
60
62
|
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
|
|
61
63
|
import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app";
|
|
62
64
|
import { _HexclaveAdminAppImplIncomplete } from "./admin-app-impl";
|
|
@@ -703,6 +705,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
703
705
|
this._trackPendingAuthResolution(async () => {
|
|
704
706
|
await this._maybeHandleNestedCrossDomainAuth(urlAtConstructionTime);
|
|
705
707
|
});
|
|
708
|
+
|
|
709
|
+
// Mirror any redirect-back state from the page URL into sessionStorage, so the return trip
|
|
710
|
+
// survives later hops that drop the query params (OAuth provider round-trips, MFA, magic
|
|
711
|
+
// links, ...). See redirect-back-state.ts for details.
|
|
712
|
+
saveRedirectBackStateFromUrl({ url: urlAtConstructionTime, projectId: this.projectId });
|
|
706
713
|
}
|
|
707
714
|
|
|
708
715
|
if (isBrowserLike() && resolvedOptions.devTool !== false) {
|
|
@@ -2650,6 +2657,17 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2650
2657
|
}
|
|
2651
2658
|
|
|
2652
2659
|
protected async _redirectTo(options: { url: URL | string, replace?: boolean }) {
|
|
2660
|
+
if (this._redirectMethod !== "none" && isBrowserLike()) {
|
|
2661
|
+
const currentUrl = new URL(window.location.href);
|
|
2662
|
+
const targetUrl = new URL(options.url, currentUrl);
|
|
2663
|
+
// Circuit breaker: if this exact redirect keeps repeating, throw so the initiating page
|
|
2664
|
+
// renders its error state instead of bouncing the user between pages forever.
|
|
2665
|
+
recordRedirectAndThrowIfLoopDetected({ currentUrl, targetUrl });
|
|
2666
|
+
// Client-side navigations don't re-run the app constructor, so mirror redirect-back state
|
|
2667
|
+
// on every SDK-driven hop too (see redirect-back-state.ts).
|
|
2668
|
+
saveRedirectBackStateFromUrl({ url: targetUrl, projectId: this.projectId });
|
|
2669
|
+
}
|
|
2670
|
+
|
|
2653
2671
|
if (this._redirectMethod === "none") {
|
|
2654
2672
|
return;
|
|
2655
2673
|
} else if (typeof this._redirectMethod === "object" && this._redirectMethod.navigate) {
|
|
@@ -2700,9 +2718,21 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2700
2718
|
throw new Error(`No URL for handler name ${handlerName}`);
|
|
2701
2719
|
}
|
|
2702
2720
|
|
|
2703
|
-
|
|
2721
|
+
let currentUrl = isReactServer || typeof window === "undefined"
|
|
2704
2722
|
? null
|
|
2705
2723
|
: new URL(window.location.href);
|
|
2724
|
+
if (
|
|
2725
|
+
currentUrl != null
|
|
2726
|
+
&& options?.noRedirectBack !== true
|
|
2727
|
+
&& (handlerName === "afterSignIn" || handlerName === "afterSignUp")
|
|
2728
|
+
) {
|
|
2729
|
+
// If intermediate hops dropped the redirect-back query params, restore them from the
|
|
2730
|
+
// sessionStorage mirror so the user still returns to where they came from instead of
|
|
2731
|
+
// stranding on the default post-auth page (on hosted components, that would be the hosted
|
|
2732
|
+
// welcome page). The restored URL goes through the same _isTrusted / cross-domain-authorize
|
|
2733
|
+
// validation below as one read from query params.
|
|
2734
|
+
currentUrl = augmentUrlWithPersistedRedirectBackState({ currentUrl, projectId: this.projectId });
|
|
2735
|
+
}
|
|
2706
2736
|
const plan = await planRedirectToHandler({
|
|
2707
2737
|
handlerName,
|
|
2708
2738
|
rawHandlerUrl,
|
|
@@ -3684,6 +3714,9 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
3684
3714
|
awaitPendingAuthResolutions: async () => {
|
|
3685
3715
|
await this._awaitPendingAuthResolutions();
|
|
3686
3716
|
},
|
|
3717
|
+
isTrustedRedirectUrl: async (url: string) => {
|
|
3718
|
+
return await this._isTrusted(url);
|
|
3719
|
+
},
|
|
3687
3720
|
};
|
|
3688
3721
|
};
|
|
3689
3722
|
|
|
@@ -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 { envVars } from "../../../../generated/env";
|
|
17
18
|
import { HandlerUrlOptions, ResolvedHandlerUrls, hexclaveAppInternalsSymbol } from "../../common";
|
|
@@ -43,7 +44,23 @@ const showMissingConfigAlertInBrowser = (message: string) => {
|
|
|
43
44
|
};
|
|
44
45
|
|
|
45
46
|
const throwMissingProjectIdError = (): never => {
|
|
46
|
-
const message =
|
|
47
|
+
const message = deindent`
|
|
48
|
+
Welcome to Hexclave!
|
|
49
|
+
|
|
50
|
+
It seems that you haven't provided a project ID.
|
|
51
|
+
|
|
52
|
+
${envVars.NODE_ENV?.includes("dev") ? deindent`
|
|
53
|
+
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>\`
|
|
54
|
+
|
|
55
|
+
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.
|
|
56
|
+
` : envVars.NODE_ENV?.includes("prod") ? deindent`
|
|
57
|
+
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.
|
|
58
|
+
` : deindent`
|
|
59
|
+
- 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>\`
|
|
60
|
+
|
|
61
|
+
- 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.
|
|
62
|
+
`}
|
|
63
|
+
`;
|
|
47
64
|
showMissingConfigAlertInBrowser(message);
|
|
48
65
|
return throwErr(new Error(message));
|
|
49
66
|
};
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
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 { HexclaveAssertionError, captureError } from "@hexclave/shared/dist/utils/errors";
|
|
6
|
+
import { readSessionStorageItem, removeSessionStorageItem, writeSessionStorageItem } from "../../../../utils/session-storage";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Last line of defense against redirect loops (most importantly in hosted components flows, where
|
|
10
|
+
* redirects bounce between the app domain and the hosted domain and a bug anywhere in the chain
|
|
11
|
+
* can bounce the user forever).
|
|
12
|
+
*
|
|
13
|
+
* Every SDK-driven redirect records a breadcrumb, and when the exact same redirect keeps repeating
|
|
14
|
+
* within a short window, the redirect throws instead of navigating. The page that initiated the
|
|
15
|
+
* redirect then renders its error state (eg. the auth page's error card), which both stops the
|
|
16
|
+
* loop and surfaces the bug loudly.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
// sessionStorage (not module state) because most redirect loops involve full page loads, so each
|
|
20
|
+
// loop iteration runs in a fresh JS context. sessionStorage is also per-tab, which is exactly the
|
|
21
|
+
// scope of a redirect loop.
|
|
22
|
+
const breadcrumbsStorageKey = "hexclave-redirect-loop-breadcrumbs";
|
|
23
|
+
|
|
24
|
+
// A redirect loop cycles within a few seconds per iteration even with full cross-domain page
|
|
25
|
+
// loads, so a real loop reaches 5 identical redirects well within 30 seconds. A human bouncing
|
|
26
|
+
// between pages (eg. toggling sign-in <-> sign-up) produces the same (from, to) pair far less
|
|
27
|
+
// often, which keeps false positives implausible.
|
|
28
|
+
const loopWindowMillis = 30_000;
|
|
29
|
+
const maxIdenticalRedirectsPerWindow = 5;
|
|
30
|
+
const maxStoredBreadcrumbs = 50;
|
|
31
|
+
|
|
32
|
+
type RedirectBreadcrumb = {
|
|
33
|
+
from: string,
|
|
34
|
+
to: string,
|
|
35
|
+
/**
|
|
36
|
+
* Wall-clock milliseconds (Date.now()). Although this is used to measure elapsed time (where we
|
|
37
|
+
* would normally use performance.now()), breadcrumbs must be comparable across full page loads,
|
|
38
|
+
* and performance.now() restarts at zero on every navigation.
|
|
39
|
+
*/
|
|
40
|
+
at: number,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function isRedirectBreadcrumb(value: unknown): value is RedirectBreadcrumb {
|
|
44
|
+
return (
|
|
45
|
+
typeof value === "object"
|
|
46
|
+
&& value !== null
|
|
47
|
+
&& "from" in value && typeof value.from === "string"
|
|
48
|
+
&& "to" in value && typeof value.to === "string"
|
|
49
|
+
&& "at" in value && typeof value.at === "number"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function readBreadcrumbs(): RedirectBreadcrumb[] {
|
|
54
|
+
const raw = readSessionStorageItem(breadcrumbsStorageKey);
|
|
55
|
+
if (raw == null) {
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
let parsed: unknown;
|
|
59
|
+
try {
|
|
60
|
+
parsed = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
// A corrupted value (eg. written by an older SDK version) just means we lose loop protection
|
|
63
|
+
// for this one redirect; start over with a fresh list.
|
|
64
|
+
removeSessionStorageItem(breadcrumbsStorageKey);
|
|
65
|
+
return [];
|
|
66
|
+
}
|
|
67
|
+
if (!Array.isArray(parsed)) {
|
|
68
|
+
removeSessionStorageItem(breadcrumbsStorageKey);
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
return parsed.filter(isRedirectBreadcrumb);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function writeBreadcrumbs(breadcrumbs: RedirectBreadcrumb[]): void {
|
|
75
|
+
writeSessionStorageItem(breadcrumbsStorageKey, JSON.stringify(breadcrumbs));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Loop iterations usually differ only in nonce-style query params (`code`, `state`,
|
|
80
|
+
* `after_auth_return_to`, ...), so redirects are compared by origin + pathname only.
|
|
81
|
+
*/
|
|
82
|
+
function getComparableRedirectLocation(url: URL): string {
|
|
83
|
+
return `${url.origin}${url.pathname}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function recordRedirectAndThrowIfLoopDetected(options: { currentUrl: URL, targetUrl: URL }): void {
|
|
87
|
+
const from = getComparableRedirectLocation(options.currentUrl);
|
|
88
|
+
const to = getComparableRedirectLocation(options.targetUrl);
|
|
89
|
+
const now = Date.now();
|
|
90
|
+
|
|
91
|
+
// Clock jumps backwards (eg. NTP sync) would make breadcrumbs appear to be from the future;
|
|
92
|
+
// treat those as expired rather than counting them towards a loop.
|
|
93
|
+
const recentBreadcrumbs = readBreadcrumbs().filter((b) => now - b.at >= 0 && now - b.at < loopWindowMillis);
|
|
94
|
+
const identicalRedirectCount = recentBreadcrumbs.filter((b) => b.from === from && b.to === to).length + 1;
|
|
95
|
+
|
|
96
|
+
if (identicalRedirectCount >= maxIdenticalRedirectsPerWindow) {
|
|
97
|
+
// Drop the matching breadcrumbs so a manual retry (eg. the user reloading and clicking sign-in
|
|
98
|
+
// again) isn't instantly blocked by the history of the loop we just broke.
|
|
99
|
+
writeBreadcrumbs(recentBreadcrumbs.filter((b) => !(b.from === from && b.to === to)));
|
|
100
|
+
const error = new HexclaveAssertionError(
|
|
101
|
+
`Redirect loop detected: the redirect ${from} -> ${to} was attempted ${identicalRedirectCount} times within ${loopWindowMillis / 1000}s, so it was blocked. This is a bug in the auth flow; the recent redirect chain is included in the error details.`,
|
|
102
|
+
{
|
|
103
|
+
from,
|
|
104
|
+
to,
|
|
105
|
+
currentUrl: options.currentUrl.toString(),
|
|
106
|
+
targetUrl: options.targetUrl.toString(),
|
|
107
|
+
recentRedirects: recentBreadcrumbs.map((b) => `${b.from} -> ${b.to}`),
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
// Also captured (not just thrown) so the loop is reported even if a caller maps the thrown
|
|
111
|
+
// error to UI without reporting it.
|
|
112
|
+
captureError("redirect-loop-detected", error);
|
|
113
|
+
throw error;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
writeBreadcrumbs([...recentBreadcrumbs, { from, to, at: now }].slice(-maxStoredBreadcrumbs));
|
|
117
|
+
}
|