@hexclave/js 1.0.72 → 1.0.74
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-BBSNNr6S.d.ts → admin-app-impl-BgUMxiV3.d.ts} +15 -9
- package/dist/admin-app-impl-BgUMxiV3.d.ts.map +1 -0
- package/dist/clickmap/clickmap-core.d.ts +1 -1
- package/dist/clickmap/index.d.ts +1 -1
- package/dist/{client-app-C42iaDoZ.d.ts → client-app-WOX3pOo1.d.ts} +12 -4
- package/dist/{client-app-C42iaDoZ.d.ts.map → client-app-WOX3pOo1.d.ts.map} +1 -1
- package/dist/dev-tool/dev-tool-core.d.ts +1 -1
- package/dist/dev-tool/index.d.ts +1 -1
- package/dist/esm/lib/auth.d.ts.map +1 -1
- package/dist/esm/lib/auth.js +1 -1
- package/dist/esm/lib/auth.js.map +1 -1
- package/dist/esm/lib/auth.test.js +2 -2
- package/dist/esm/lib/auth.test.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +6 -3
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.js +4 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js +102 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +57 -1
- 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 +7 -4
- 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 +44 -12
- package/dist/esm/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/common.js +2 -2
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts +13 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.js +15 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.test.js +25 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-back-state.test.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.js +67 -12
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.d.ts +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js +87 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/interfaces/client-app.d.ts +9 -1
- 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/lib/hexclave-app/project-configs/index.d.ts +4 -1
- package/dist/esm/lib/hexclave-app/project-configs/index.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/projects/index.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/projects/index.js +3 -0
- package/dist/esm/lib/hexclave-app/projects/index.js.map +1 -1
- package/dist/esm/utils/url.js +10 -2
- package/dist/esm/utils/url.js.map +1 -1
- package/dist/esm/utils/url.test.d.ts +1 -0
- package/dist/esm/utils/url.test.js +28 -0
- package/dist/esm/utils/url.test.js.map +1 -0
- package/dist/{index-Dq4xsW7_.d.ts → index-B-MazpM_.d.ts} +5 -2
- package/dist/{index-Dq4xsW7_.d.ts.map → index-B-MazpM_.d.ts.map} +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/lib/auth.d.ts.map +1 -1
- package/dist/lib/auth.js +1 -1
- package/dist/lib/auth.js.map +1 -1
- package/dist/lib/auth.test.js +2 -2
- package/dist/lib/auth.test.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.js +4 -1
- package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.d.ts +1 -0
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js +101 -0
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.js.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +57 -1
- 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 +43 -11
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/common.js +2 -2
- package/dist/lib/hexclave-app/apps/implementations/index.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts +13 -1
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.js +15 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.test.js +24 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-back-state.test.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.d.ts +1 -1
- package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.js +67 -12
- package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.test.d.ts +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js +86 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.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/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 +2 -2
- package/dist/lib/hexclave-app/project-configs/index.d.ts +1 -1
- package/dist/lib/hexclave-app/projects/index.d.ts +1 -1
- package/dist/lib/hexclave-app/projects/index.js +3 -0
- package/dist/lib/hexclave-app/projects/index.js.map +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/{redirect-page-urls-elbPx2J1.d.ts → redirect-page-urls-Cu5BRQwm.d.ts} +2 -1
- package/dist/redirect-page-urls-Cu5BRQwm.d.ts.map +1 -0
- package/dist/utils/url.js +10 -2
- package/dist/utils/url.js.map +1 -1
- package/dist/utils/url.test.d.ts +1 -0
- package/dist/utils/url.test.js +27 -0
- package/dist/utils/url.test.js.map +1 -0
- package/package.json +2 -2
- package/src/lib/auth.test.ts +2 -2
- package/src/lib/auth.ts +3 -1
- package/src/lib/hexclave-app/apps/implementations/admin-app-impl.ts +4 -1
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.automatic-side-effects.test.ts +126 -0
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +75 -0
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +73 -16
- package/src/lib/hexclave-app/apps/implementations/redirect-back-state.test.ts +29 -1
- package/src/lib/hexclave-app/apps/implementations/redirect-back-state.ts +17 -0
- package/src/lib/hexclave-app/apps/implementations/redirect-page-urls.test.ts +119 -0
- package/src/lib/hexclave-app/apps/implementations/redirect-page-urls.ts +104 -12
- package/src/lib/hexclave-app/apps/interfaces/client-app.ts +12 -2
- package/src/lib/hexclave-app/project-configs/index.ts +4 -1
- package/src/lib/hexclave-app/projects/index.ts +3 -0
- package/src/utils/url.test.ts +54 -0
- package/src/utils/url.ts +12 -3
- package/dist/admin-app-impl-BBSNNr6S.d.ts.map +0 -1
- package/dist/redirect-page-urls-elbPx2J1.d.ts.map +0 -1
|
@@ -7,6 +7,7 @@ import { AccessToken } from "@hexclave/shared/dist/sessions";
|
|
|
7
7
|
import { Store } from "@hexclave/shared/dist/utils/stores";
|
|
8
8
|
import { hexclaveAppInternalsSymbol } from "../../common";
|
|
9
9
|
import { StackClientApp } from "../interfaces/client-app";
|
|
10
|
+
import { planRedirectToHandler } from "./redirect-page-urls";
|
|
10
11
|
|
|
11
12
|
function createAccessTokenString(refreshTokenId: string): string {
|
|
12
13
|
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
@@ -54,6 +55,76 @@ function createMockDocument(): Document {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
describe("StackClientApp cross-domain auth", () => {
|
|
58
|
+
it("keeps noRedirectBack same-domain redirects free of redirect-back state", async () => {
|
|
59
|
+
const redirectPlan = await planRedirectToHandler({
|
|
60
|
+
handlerName: "signIn",
|
|
61
|
+
rawHandlerUrl: "/handler/sign-in",
|
|
62
|
+
noRedirectBack: true,
|
|
63
|
+
currentUrl: new URL("https://app.example.test/settings?tab=profile"),
|
|
64
|
+
localOAuthCallbackUrl: "/handler/oauth-callback",
|
|
65
|
+
rawHomeUrl: "/home",
|
|
66
|
+
getCrossDomainHandoffParams: async () => {
|
|
67
|
+
throw new Error("Same-domain redirects must not create cross-domain handoff parameters.");
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(redirectPlan).toEqual({
|
|
72
|
+
type: "redirect",
|
|
73
|
+
url: "/handler/sign-in",
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("returns noRedirectBack cross-domain auth to the source app home", async () => {
|
|
78
|
+
const handoffState = "no-redirect-back-state";
|
|
79
|
+
const handoffCodeChallenge = "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~";
|
|
80
|
+
const currentUrl = new URL("https://app.example.test/settings?tab=profile");
|
|
81
|
+
currentUrl.searchParams.set("hexclave_cross_domain_state", handoffState);
|
|
82
|
+
currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", handoffCodeChallenge);
|
|
83
|
+
currentUrl.searchParams.set(
|
|
84
|
+
"hexclave_cross_domain_after_callback_redirect_url",
|
|
85
|
+
"https://app.example.test/settings?tab=profile",
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
const redirectPlan = await planRedirectToHandler({
|
|
89
|
+
handlerName: "signIn",
|
|
90
|
+
rawHandlerUrl: "https://auth.example.test/handler/sign-in",
|
|
91
|
+
noRedirectBack: true,
|
|
92
|
+
currentUrl,
|
|
93
|
+
localOAuthCallbackUrl: "/handler/oauth-callback",
|
|
94
|
+
rawHomeUrl: "/home",
|
|
95
|
+
getCrossDomainHandoffParams: async () => {
|
|
96
|
+
throw new Error("Existing cross-domain handoff parameters must be reused.");
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(redirectPlan.type).toBe("redirect");
|
|
101
|
+
if (redirectPlan.type !== "redirect") {
|
|
102
|
+
throw new Error("Expected noRedirectBack sign-in to produce a redirect URL.");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const hostedUrl = new URL(redirectPlan.url);
|
|
106
|
+
expect(hostedUrl.origin).toBe("https://auth.example.test");
|
|
107
|
+
expect(hostedUrl.pathname).toBe("/handler/sign-in");
|
|
108
|
+
expect(hostedUrl.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe(
|
|
109
|
+
"https://app.example.test/home",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const rawSourceCallbackUrl = hostedUrl.searchParams.get("after_auth_return_to");
|
|
113
|
+
if (rawSourceCallbackUrl == null) {
|
|
114
|
+
throw new Error("Expected cross-domain noRedirectBack to include its internal source callback URL.");
|
|
115
|
+
}
|
|
116
|
+
const sourceCallbackUrl = new URL(rawSourceCallbackUrl);
|
|
117
|
+
expect(sourceCallbackUrl.origin).toBe("https://app.example.test");
|
|
118
|
+
expect(sourceCallbackUrl.pathname).toBe("/handler/oauth-callback");
|
|
119
|
+
expect(sourceCallbackUrl.searchParams.get("hexclave_cross_domain_auth")).toBe("1");
|
|
120
|
+
expect(sourceCallbackUrl.searchParams.get("hexclave_cross_domain_state")).toBe(handoffState);
|
|
121
|
+
expect(sourceCallbackUrl.searchParams.get("hexclave_cross_domain_code_challenge")).toBe(handoffCodeChallenge);
|
|
122
|
+
expect(sourceCallbackUrl.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe(
|
|
123
|
+
"https://app.example.test/home",
|
|
124
|
+
);
|
|
125
|
+
expect(sourceCallbackUrl.toString()).not.toContain("/settings");
|
|
126
|
+
});
|
|
127
|
+
|
|
57
128
|
it("exposes redirect-back-aware handler URLs for devtool previews", async () => {
|
|
58
129
|
const previousWindow = Reflect.get(globalThis, "window");
|
|
59
130
|
const hadPreviousWindow = Reflect.has(globalThis, "window");
|
|
@@ -486,6 +557,8 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
486
557
|
callbackUrl.searchParams.set("details", JSON.stringify({
|
|
487
558
|
message: "Your sign up was rejected by an administrator's sign-up rule.",
|
|
488
559
|
}));
|
|
560
|
+
callbackUrl.searchParams.set("after_auth_return_to", "/dashboard");
|
|
561
|
+
callbackUrl.searchParams.set("after_callback_redirect_url", "https://customer.example.test/settings?tab=connected-accounts");
|
|
489
562
|
let currentHref = callbackUrl.toString();
|
|
490
563
|
let redirectedUrl = "";
|
|
491
564
|
const redirectSpy = vi.spyOn(StackClientApp.prototype as any, "_redirectTo").mockImplementation(async (...args: unknown[]) => {
|
|
@@ -539,6 +612,8 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
539
612
|
expect(errorUrl.pathname).toBe("/handler/error");
|
|
540
613
|
expect(errorUrl.searchParams.get("errorCode")).toBe("SIGN_UP_REJECTED");
|
|
541
614
|
expect(errorUrl.searchParams.get("message")).toBe("Your sign up was rejected by an administrator's sign-up rule.");
|
|
615
|
+
expect(errorUrl.searchParams.get("after_auth_return_to")).toBe("/dashboard");
|
|
616
|
+
expect(errorUrl.searchParams.get("after_callback_redirect_url")).toBe("https://customer.example.test/settings?tab=connected-accounts");
|
|
542
617
|
expect(new URL(currentHref).searchParams.has("errorCode")).toBe(false);
|
|
543
618
|
});
|
|
544
619
|
|
|
@@ -57,7 +57,7 @@ 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";
|
|
60
|
+
import { augmentUrlWithPersistedRedirectBackState, getRawAfterAuthReturnTo, saveRedirectBackStateFromUrl } from "./redirect-back-state";
|
|
61
61
|
import { recordRedirectAndThrowIfLoopDetected } from "./redirect-loop-breaker";
|
|
62
62
|
import { scopePasskeyAuthenticationToHostname, scopePasskeyRegistrationToHostname } from "../../passkey-rp-id";
|
|
63
63
|
import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthProvider, ProjectCurrentUser, SyncedPartialUser, TokenPartialUser, UserExtra, UserUpdateOptions, userUpdateOptionsToCrud, withUserDestructureGuard } from "../../users";
|
|
@@ -77,6 +77,7 @@ import { mountPushedConfigErrorOverlay } from "../../../../pushed-config-error-o
|
|
|
77
77
|
let isReactServer = false;
|
|
78
78
|
|
|
79
79
|
const prefetchedCrossDomainHandoffTtlMs = 55 * 60 * 1000;
|
|
80
|
+
const oauthAfterCallbackRedirectUrlQueryParam = "after_callback_redirect_url";
|
|
80
81
|
|
|
81
82
|
const nestedCrossDomainAuthQueryParams = {
|
|
82
83
|
refreshTokenId: "stack_nested_cross_domain_auth_refresh_token_id",
|
|
@@ -85,7 +86,7 @@ const nestedCrossDomainAuthQueryParams = {
|
|
|
85
86
|
state: "state",
|
|
86
87
|
codeChallenge: "code_challenge",
|
|
87
88
|
codeChallengeMethod: "code_challenge_method",
|
|
88
|
-
afterCallbackRedirectUrl:
|
|
89
|
+
afterCallbackRedirectUrl: oauthAfterCallbackRedirectUrlQueryParam,
|
|
89
90
|
} as const;
|
|
90
91
|
|
|
91
92
|
function getRedirectHelperInstruction(handlerName: string): string {
|
|
@@ -641,18 +642,32 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
641
642
|
this._redirectMethod = resolvedOptions.redirectMethod || (isBrowserLike() ? "window" : "none");
|
|
642
643
|
this._urlOptions = resolvedOptions.urls ?? {};
|
|
643
644
|
this._oauthScopesOnSignIn = resolvedOptions.oauthScopesOnSignIn ?? {};
|
|
645
|
+
this._analyticsOptions = resolvedOptions.analytics;
|
|
646
|
+
|
|
647
|
+
if (extraOptions?.uniqueIdentifier !== undefined) {
|
|
648
|
+
this._uniqueIdentifier = extraOptions.uniqueIdentifier;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// Custom dashboards can disable automatic initialization so URL parameters, browser storage, analytics, and
|
|
652
|
+
// development overlays cannot affect the containing page. Explicit SDK calls remain functional and retain their
|
|
653
|
+
// documented side effects.
|
|
654
|
+
if (resolvedOptions.automaticSideEffects === false) {
|
|
655
|
+
return;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
this._initializeAutomaticSideEffects(resolvedOptions);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
private _initializeAutomaticSideEffects(resolvedOptions: HexclaveClientAppImplConstructorOptionsResolved<HasTokenStore, ProjectId>) {
|
|
644
662
|
if (isBrowserLike() && (resolvedOptions.tokenStore === "cookie" || resolvedOptions.tokenStore === "nextjs-cookie")) {
|
|
645
663
|
runAsynchronously(this._trustedParentDomainCache.getOrWait([window.location.hostname], "write-only"));
|
|
646
664
|
this._ensureCrossSubdomainCookieExists();
|
|
647
665
|
}
|
|
648
666
|
|
|
649
|
-
if (
|
|
650
|
-
this._uniqueIdentifier = extraOptions.uniqueIdentifier;
|
|
667
|
+
if (this._uniqueIdentifier !== undefined) {
|
|
651
668
|
this._initUniqueIdentifier();
|
|
652
669
|
}
|
|
653
670
|
|
|
654
|
-
this._analyticsOptions = resolvedOptions.analytics;
|
|
655
|
-
|
|
656
671
|
const getAnalyticsSession = async (): Promise<InternalSession> => {
|
|
657
672
|
this._ensurePersistentTokenStore();
|
|
658
673
|
const partialUser = await this.getPartialUser({ from: 'token', or: 'anonymous-if-exists' });
|
|
@@ -848,9 +863,31 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
848
863
|
|
|
849
864
|
protected async _redirectToOAuthCallbackError(error: KnownError): Promise<void> {
|
|
850
865
|
const errorUrl = new URL(this._getUrls().error, window.location.href);
|
|
866
|
+
const callbackUrl = augmentUrlWithPersistedRedirectBackState({
|
|
867
|
+
currentUrl: new URL(window.location.href),
|
|
868
|
+
projectId: this.projectId,
|
|
869
|
+
});
|
|
851
870
|
errorUrl.searchParams.set("errorCode", error.errorCode);
|
|
852
871
|
errorUrl.searchParams.set("message", error.message);
|
|
853
872
|
errorUrl.searchParams.set("details", JSON.stringify(error.details ?? {}));
|
|
873
|
+
for (const paramName of [
|
|
874
|
+
"after_auth_return_to",
|
|
875
|
+
crossDomainAuthQueryParams.state,
|
|
876
|
+
crossDomainAuthQueryParams.codeChallenge,
|
|
877
|
+
crossDomainAuthQueryParams.afterCallbackRedirectUrl,
|
|
878
|
+
]) {
|
|
879
|
+
const value = callbackUrl.searchParams.get(paramName);
|
|
880
|
+
if (value != null) {
|
|
881
|
+
errorUrl.searchParams.set(paramName, value);
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
const afterCallbackRedirectUrl = callbackUrl.searchParams.get(oauthAfterCallbackRedirectUrlQueryParam);
|
|
885
|
+
if (afterCallbackRedirectUrl != null) {
|
|
886
|
+
// Account-link callbacks need to finish consuming the OAuth response before returning to
|
|
887
|
+
// their invoking page. Carry the already server-validated continuation through the
|
|
888
|
+
// configured error handler, which validates it again before navigation.
|
|
889
|
+
errorUrl.searchParams.set(oauthAfterCallbackRedirectUrlQueryParam, afterCallbackRedirectUrl);
|
|
890
|
+
}
|
|
854
891
|
await this._redirectIfTrusted(errorUrl.toString(), { replace: true });
|
|
855
892
|
}
|
|
856
893
|
|
|
@@ -2420,6 +2457,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2420
2457
|
tokenStore: null,
|
|
2421
2458
|
projectOwnerSession: session,
|
|
2422
2459
|
noAutomaticPrefetch: true,
|
|
2460
|
+
automaticSideEffects: false,
|
|
2423
2461
|
}));
|
|
2424
2462
|
}
|
|
2425
2463
|
return this._ownedAdminApps.get([session, forProjectId])!;
|
|
@@ -2747,16 +2785,18 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2747
2785
|
let currentUrl = isReactServer || typeof window === "undefined"
|
|
2748
2786
|
? null
|
|
2749
2787
|
: new URL(window.location.href);
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2788
|
+
const shouldRestorePersistedRedirectBackState = (
|
|
2789
|
+
(options?.noRedirectBack !== true && (handlerName === "afterSignIn" || handlerName === "afterSignUp"))
|
|
2790
|
+
|| handlerName === "forgotPassword"
|
|
2791
|
+
|| handlerName === "passwordReset"
|
|
2792
|
+
|| (handlerName === "signIn" && options?.noRedirectBack === true)
|
|
2793
|
+
);
|
|
2794
|
+
if (currentUrl != null && shouldRestorePersistedRedirectBackState) {
|
|
2755
2795
|
// If intermediate hops dropped the redirect-back query params, restore them from the
|
|
2756
|
-
// sessionStorage mirror
|
|
2757
|
-
//
|
|
2758
|
-
//
|
|
2759
|
-
//
|
|
2796
|
+
// sessionStorage mirror. Password-flow continuation pages only carry this inherited state;
|
|
2797
|
+
// redirect planning never turns forgot/reset pages themselves into return targets. The
|
|
2798
|
+
// restored URL still goes through the same _isTrusted / cross-domain-authorize validation
|
|
2799
|
+
// below as one read from query params.
|
|
2760
2800
|
currentUrl = augmentUrlWithPersistedRedirectBackState({ currentUrl, projectId: this.projectId });
|
|
2761
2801
|
}
|
|
2762
2802
|
const plan = await planRedirectToHandler({
|
|
@@ -2765,6 +2805,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
2765
2805
|
noRedirectBack: options?.noRedirectBack === true,
|
|
2766
2806
|
currentUrl,
|
|
2767
2807
|
localOAuthCallbackUrl: this._getLocalOAuthCallbackHandlerUrl(),
|
|
2808
|
+
rawHomeUrl: rawUrls.home,
|
|
2768
2809
|
getCrossDomainHandoffParams: async (href) => await this._getCrossDomainHandoffParamsForRedirect(href),
|
|
2769
2810
|
});
|
|
2770
2811
|
|
|
@@ -3661,13 +3702,19 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
3661
3702
|
}
|
|
3662
3703
|
|
|
3663
3704
|
const { analytics, ...restJson } = omit(json, ["uniqueIdentifier"]);
|
|
3664
|
-
|
|
3705
|
+
const app = new _HexclaveClientAppImplIncomplete<HasTokenStore, ProjectId>({
|
|
3665
3706
|
...restJson as any,
|
|
3666
3707
|
analytics: analyticsOptionsFromJson(analytics),
|
|
3667
3708
|
}, {
|
|
3668
3709
|
uniqueIdentifier: json.uniqueIdentifier,
|
|
3669
3710
|
checkString: providedCheckString,
|
|
3670
3711
|
});
|
|
3712
|
+
// Inert construction does not register itself, so register only after it has completed to preserve stable
|
|
3713
|
+
// identity across repeated client-side deserialization.
|
|
3714
|
+
if (json.automaticSideEffects === false) {
|
|
3715
|
+
app._initUniqueIdentifier();
|
|
3716
|
+
}
|
|
3717
|
+
return app;
|
|
3671
3718
|
}
|
|
3672
3719
|
};
|
|
3673
3720
|
}
|
|
@@ -3694,6 +3741,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
3694
3741
|
redirectMethod: this._redirectMethod,
|
|
3695
3742
|
extraRequestHeaders: this._options.extraRequestHeaders,
|
|
3696
3743
|
devTool: this._options.devTool,
|
|
3744
|
+
automaticSideEffects: this._options.automaticSideEffects,
|
|
3697
3745
|
analytics: analyticsOptionsToJson(this._analyticsOptions),
|
|
3698
3746
|
};
|
|
3699
3747
|
},
|
|
@@ -3730,6 +3778,15 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
3730
3778
|
redirectToHandler: async (handlerName: keyof HandlerUrls, options?: RedirectToOptions) => {
|
|
3731
3779
|
await this._redirectToHandler(handlerName, options);
|
|
3732
3780
|
},
|
|
3781
|
+
getRawAfterAuthReturnTo: () => {
|
|
3782
|
+
if (isReactServer || typeof window === "undefined") {
|
|
3783
|
+
return null;
|
|
3784
|
+
}
|
|
3785
|
+
return getRawAfterAuthReturnTo({
|
|
3786
|
+
currentUrl: new URL(window.location.href),
|
|
3787
|
+
projectId: this.projectId,
|
|
3788
|
+
});
|
|
3789
|
+
},
|
|
3733
3790
|
refreshOwnedProjects: async () => {
|
|
3734
3791
|
await this._refreshOwnedProjects(await this._getSession());
|
|
3735
3792
|
},
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
|
|
4
4
|
//===========================================
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
-
import { augmentUrlWithPersistedRedirectBackState, readRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
|
|
6
|
+
import { augmentUrlWithPersistedRedirectBackState, getRawAfterAuthReturnTo, readRedirectBackState, saveRedirectBackStateFromUrl } from "./redirect-back-state";
|
|
7
7
|
|
|
8
8
|
function createMockSessionStorage() {
|
|
9
9
|
const map = new Map<string, string>();
|
|
@@ -114,6 +114,34 @@ describe("redirect-back state mirror", () => {
|
|
|
114
114
|
expect(unchanged.toString()).toBe(explicitUrl.toString());
|
|
115
115
|
});
|
|
116
116
|
|
|
117
|
+
it("reads the explicit return target before persisted state", () => {
|
|
118
|
+
saveRedirectBackStateFromUrl({
|
|
119
|
+
url: new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/persisted"),
|
|
120
|
+
projectId,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(getRawAfterAuthReturnTo({
|
|
124
|
+
currentUrl: new URL("https://hosted.example.test/handler/mfa?after_auth_return_to=/explicit"),
|
|
125
|
+
projectId,
|
|
126
|
+
})).toBe("/explicit");
|
|
127
|
+
expect(getRawAfterAuthReturnTo({
|
|
128
|
+
currentUrl: new URL("https://hosted.example.test/handler/mfa"),
|
|
129
|
+
projectId,
|
|
130
|
+
})).toBe("/persisted");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("treats an explicitly empty return target as missing instead of reviving persisted state", () => {
|
|
134
|
+
saveRedirectBackStateFromUrl({
|
|
135
|
+
url: new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=/persisted"),
|
|
136
|
+
projectId,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
expect(getRawAfterAuthReturnTo({
|
|
140
|
+
currentUrl: new URL("https://hosted.example.test/handler/mfa?after_auth_return_to=%20%20"),
|
|
141
|
+
projectId,
|
|
142
|
+
})).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
117
145
|
it("leaves the URL unchanged when there is no mirrored state", () => {
|
|
118
146
|
const url = new URL("https://hosted.example.test/handler/sign-in");
|
|
119
147
|
const augmented = augmentUrlWithPersistedRedirectBackState({ currentUrl: url, projectId });
|
|
@@ -111,6 +111,23 @@ export function readRedirectBackState(options: { projectId: string }): Persisted
|
|
|
111
111
|
return parsed;
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Returns the raw return target carried by the current auth flow. The value is intentionally not
|
|
116
|
+
* described as trusted: callers must still pass it through the normal redirect validation before
|
|
117
|
+
* navigating to it.
|
|
118
|
+
*
|
|
119
|
+
* An explicit query parameter wins even when it is empty. This prevents a malformed URL from
|
|
120
|
+
* silently reviving an older flow from sessionStorage.
|
|
121
|
+
*/
|
|
122
|
+
export function getRawAfterAuthReturnTo(options: { currentUrl: URL, projectId: string }): string | null {
|
|
123
|
+
const explicitReturnTo = options.currentUrl.searchParams.get("after_auth_return_to");
|
|
124
|
+
if (explicitReturnTo != null) {
|
|
125
|
+
return explicitReturnTo.trim().length > 0 ? explicitReturnTo : null;
|
|
126
|
+
}
|
|
127
|
+
const persistedReturnTo = readRedirectBackState({ projectId: options.projectId })?.afterAuthReturnTo;
|
|
128
|
+
return persistedReturnTo != null && persistedReturnTo.trim().length > 0 ? persistedReturnTo : null;
|
|
129
|
+
}
|
|
130
|
+
|
|
114
131
|
/**
|
|
115
132
|
* Returns a copy of `currentUrl` with any redirect-back query params that were dropped along the
|
|
116
133
|
* way restored from the sessionStorage mirror. If `currentUrl` already carries
|
|
@@ -0,0 +1,119 @@
|
|
|
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 { describe, expect, it } from "vitest";
|
|
6
|
+
import { crossDomainAuthQueryParams, planRedirectToHandler } from "./redirect-page-urls";
|
|
7
|
+
|
|
8
|
+
const localOAuthCallbackUrl = "https://hosted.example.test/handler/oauth-callback";
|
|
9
|
+
|
|
10
|
+
async function plan(options: {
|
|
11
|
+
handlerName: "signIn" | "forgotPassword" | "passwordReset" | "afterSignIn" | "afterSignUp",
|
|
12
|
+
rawHandlerUrl: string,
|
|
13
|
+
currentUrl: URL,
|
|
14
|
+
noRedirectBack?: boolean,
|
|
15
|
+
}) {
|
|
16
|
+
return await planRedirectToHandler({
|
|
17
|
+
...options,
|
|
18
|
+
noRedirectBack: options.noRedirectBack === true,
|
|
19
|
+
localOAuthCallbackUrl,
|
|
20
|
+
getCrossDomainHandoffParams: async () => {
|
|
21
|
+
throw new Error("Continuation-only redirects must not mint a new cross-domain handoff");
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function expectRedirectUrl(planResult: Awaited<ReturnType<typeof plan>>, baseUrl: URL): URL {
|
|
27
|
+
if (planResult.type !== "redirect") {
|
|
28
|
+
throw new Error("Expected a direct redirect plan");
|
|
29
|
+
}
|
|
30
|
+
return new URL(planResult.url, baseUrl);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function expectContinuation(url: URL, expectedReturnTo: string): void {
|
|
34
|
+
expect(url.searchParams.get("after_auth_return_to")).toBe(expectedReturnTo);
|
|
35
|
+
expect(url.searchParams.get(crossDomainAuthQueryParams.state)).toBe("outer-state");
|
|
36
|
+
expect(url.searchParams.get(crossDomainAuthQueryParams.codeChallenge)).toBe("outer-challenge");
|
|
37
|
+
expect(url.searchParams.get(crossDomainAuthQueryParams.afterCallbackRedirectUrl)).toBe("https://customer.example.test/dashboard");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("password-flow redirect continuation", () => {
|
|
41
|
+
it("carries the complete customer return state through forgot-password and a reset-email new tab", async () => {
|
|
42
|
+
const customerReturnTo = "https://customer.example.test/handler/oauth-callback?hexclave_cross_domain_auth=1";
|
|
43
|
+
const signInUrl = new URL("https://hosted.example.test/handler/sign-in");
|
|
44
|
+
signInUrl.searchParams.set("after_auth_return_to", customerReturnTo);
|
|
45
|
+
signInUrl.searchParams.set(crossDomainAuthQueryParams.state, "outer-state");
|
|
46
|
+
signInUrl.searchParams.set(crossDomainAuthQueryParams.codeChallenge, "outer-challenge");
|
|
47
|
+
signInUrl.searchParams.set(crossDomainAuthQueryParams.afterCallbackRedirectUrl, "https://customer.example.test/dashboard");
|
|
48
|
+
|
|
49
|
+
const forgotPlan = await plan({
|
|
50
|
+
handlerName: "forgotPassword",
|
|
51
|
+
rawHandlerUrl: "/handler/forgot-password",
|
|
52
|
+
currentUrl: signInUrl,
|
|
53
|
+
});
|
|
54
|
+
const forgotUrl = expectRedirectUrl(forgotPlan, signInUrl);
|
|
55
|
+
expectContinuation(forgotUrl, customerReturnTo);
|
|
56
|
+
|
|
57
|
+
const callbackPlan = await plan({
|
|
58
|
+
handlerName: "passwordReset",
|
|
59
|
+
rawHandlerUrl: "/handler/password-reset",
|
|
60
|
+
currentUrl: forgotUrl,
|
|
61
|
+
});
|
|
62
|
+
const resetEmailCallbackUrl = expectRedirectUrl(callbackPlan, forgotUrl);
|
|
63
|
+
expectContinuation(resetEmailCallbackUrl, customerReturnTo);
|
|
64
|
+
|
|
65
|
+
// Model opening the callback in a new tab: only URL state is available.
|
|
66
|
+
resetEmailCallbackUrl.searchParams.set("code", "reset-code");
|
|
67
|
+
const signInPlan = await plan({
|
|
68
|
+
handlerName: "signIn",
|
|
69
|
+
rawHandlerUrl: "/handler/sign-in",
|
|
70
|
+
currentUrl: resetEmailCallbackUrl,
|
|
71
|
+
noRedirectBack: true,
|
|
72
|
+
});
|
|
73
|
+
const finalSignInUrl = expectRedirectUrl(signInPlan, resetEmailCallbackUrl);
|
|
74
|
+
expectContinuation(finalSignInUrl, customerReturnTo);
|
|
75
|
+
expect(finalSignInUrl.searchParams.has("code")).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it.each([
|
|
79
|
+
["forgotPassword", "/handler/forgot-password"],
|
|
80
|
+
["passwordReset", "/handler/password-reset"],
|
|
81
|
+
] as const)("never synthesizes the current %s page as a return target", async (handlerName, rawHandlerUrl) => {
|
|
82
|
+
const currentUrl = new URL(`https://hosted.example.test${rawHandlerUrl}?code=reset-code`);
|
|
83
|
+
const result = expectRedirectUrl(await plan({
|
|
84
|
+
handlerName,
|
|
85
|
+
rawHandlerUrl,
|
|
86
|
+
currentUrl,
|
|
87
|
+
}), currentUrl);
|
|
88
|
+
|
|
89
|
+
expect(result.searchParams.has("after_auth_return_to")).toBe(false);
|
|
90
|
+
expect(result.searchParams.has("code")).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("makes sign-in noRedirectBack suppress capture while preserving only inherited state", async () => {
|
|
94
|
+
const resetUrlWithoutContinuation = new URL("https://hosted.example.test/handler/password-reset?code=reset-code");
|
|
95
|
+
const result = expectRedirectUrl(await plan({
|
|
96
|
+
handlerName: "signIn",
|
|
97
|
+
rawHandlerUrl: "/handler/sign-in",
|
|
98
|
+
currentUrl: resetUrlWithoutContinuation,
|
|
99
|
+
noRedirectBack: true,
|
|
100
|
+
}), resetUrlWithoutContinuation);
|
|
101
|
+
|
|
102
|
+
expect(result.toString()).toBe("https://hosted.example.test/handler/sign-in");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it.each(["afterSignIn", "afterSignUp"] as const)(
|
|
106
|
+
"keeps existing %s noRedirectBack semantics",
|
|
107
|
+
async (handlerName) => {
|
|
108
|
+
const currentUrl = new URL("https://hosted.example.test/handler/sign-in?after_auth_return_to=%2Fcustomer");
|
|
109
|
+
const result = expectRedirectUrl(await plan({
|
|
110
|
+
handlerName,
|
|
111
|
+
rawHandlerUrl: "/default-after-auth",
|
|
112
|
+
currentUrl,
|
|
113
|
+
noRedirectBack: true,
|
|
114
|
+
}), currentUrl);
|
|
115
|
+
|
|
116
|
+
expect(result.toString()).toBe("https://hosted.example.test/default-after-auth");
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
});
|