@hexclave/next 1.0.5 → 1.0.6
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/components-page/account-settings/payments/payments-panel.js +3 -3
- package/dist/components-page/account-settings/payments/payments-panel.js.map +1 -1
- package/dist/components-page/hexclave-handler-client.d.ts +13 -1
- package/dist/components-page/hexclave-handler-client.d.ts.map +1 -1
- package/dist/components-page/hexclave-handler-client.js +44 -9
- package/dist/components-page/hexclave-handler-client.js.map +1 -1
- package/dist/components-page/hexclave-handler-client.test.d.ts +1 -0
- package/dist/components-page/hexclave-handler-client.test.js +51 -0
- package/dist/components-page/hexclave-handler-client.test.js.map +1 -0
- package/dist/dev-tool/dev-tool-core.js +2 -2
- package/dist/dev-tool/dev-tool-core.js.map +1 -1
- package/dist/esm/components-page/account-settings/payments/payments-panel.js +2 -2
- package/dist/esm/components-page/account-settings/payments/payments-panel.js.map +1 -1
- package/dist/esm/components-page/hexclave-handler-client.d.ts +12 -1
- package/dist/esm/components-page/hexclave-handler-client.d.ts.map +1 -1
- package/dist/esm/components-page/hexclave-handler-client.js +46 -12
- package/dist/esm/components-page/hexclave-handler-client.js.map +1 -1
- package/dist/esm/components-page/hexclave-handler-client.test.d.ts +1 -0
- package/dist/esm/components-page/hexclave-handler-client.test.js +51 -0
- package/dist/esm/components-page/hexclave-handler-client.test.js.map +1 -0
- package/dist/esm/dev-tool/dev-tool-core.js +2 -2
- package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
- package/dist/esm/generated/env.d.ts +26 -0
- package/dist/esm/{lib → generated}/env.d.ts.map +1 -1
- package/dist/esm/generated/env.js +67 -0
- package/dist/esm/generated/env.js.map +1 -0
- package/dist/esm/generated/quetzal-translations.d.ts +2 -2
- package/dist/esm/global.d.ts +8 -1
- package/dist/esm/global.d.ts.map +1 -0
- 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 +263 -3
- 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 -1
- 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 +53 -26
- 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 +8 -8
- package/dist/esm/lib/hexclave-app/apps/implementations/common.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/common.js +28 -14
- package/dist/esm/lib/hexclave-app/apps/implementations/common.js.map +1 -1
- package/dist/esm/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
- package/dist/esm/lib/hexclave-app/url-targets.d.ts.map +1 -1
- package/dist/esm/lib/hexclave-app/url-targets.js +25 -11
- package/dist/esm/lib/hexclave-app/url-targets.js.map +1 -1
- package/dist/esm/lib/hexclave-app/url-targets.test.js +12 -0
- package/dist/esm/lib/hexclave-app/url-targets.test.js.map +1 -1
- package/dist/generated/env.d.ts +26 -0
- package/dist/{lib → generated}/env.d.ts.map +1 -1
- package/dist/generated/env.js +69 -0
- package/dist/generated/env.js.map +1 -0
- package/dist/generated/quetzal-translations.d.ts +2 -2
- package/dist/global.d.ts +8 -1
- package/dist/global.d.ts.map +1 -0
- package/dist/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.js +263 -3
- 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 +3 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js +52 -25
- package/dist/lib/hexclave-app/apps/implementations/client-app-impl.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/common.d.ts +8 -8
- package/dist/lib/hexclave-app/apps/implementations/common.d.ts.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/common.js +28 -14
- package/dist/lib/hexclave-app/apps/implementations/common.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.js +1 -1
- package/dist/lib/hexclave-app/url-targets.d.ts.map +1 -1
- package/dist/lib/hexclave-app/url-targets.js +25 -11
- package/dist/lib/hexclave-app/url-targets.js.map +1 -1
- package/dist/lib/hexclave-app/url-targets.test.js +12 -0
- package/dist/lib/hexclave-app/url-targets.test.js.map +1 -1
- package/package.json +9 -7
- package/src/components-page/account-settings/payments/payments-panel.tsx +2 -2
- package/src/components-page/hexclave-handler-client.test.tsx +64 -0
- package/src/components-page/hexclave-handler-client.tsx +50 -11
- package/src/dev-tool/dev-tool-core.ts +2 -2
- package/src/generated/.gitignore +1 -1
- package/src/global.d.ts +8 -1
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +316 -3
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +69 -25
- package/src/lib/hexclave-app/apps/implementations/common.ts +34 -14
- package/src/lib/hexclave-app/url-targets.test.ts +17 -0
- package/src/lib/hexclave-app/url-targets.ts +25 -7
- package/dist/esm/lib/env.d.ts +0 -42
- package/dist/esm/lib/env.js +0 -93
- package/dist/esm/lib/env.js.map +0 -1
- package/dist/lib/env.d.ts +0 -42
- package/dist/lib/env.js +0 -95
- package/dist/lib/env.js.map +0 -1
- package/src/lib/env.ts +0 -93
|
@@ -0,0 +1,64 @@
|
|
|
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 { KnownErrors } from "@hexclave/shared";
|
|
6
|
+
import { describe, expect, it, vi } from "vitest";
|
|
7
|
+
import { hexclaveAppInternalsSymbol } from "../lib/hexclave-app";
|
|
8
|
+
import type { StackClientApp } from "../lib/hexclave-app/apps/interfaces/client-app";
|
|
9
|
+
import { getRedirectToPageResult } from "./hexclave-handler-client";
|
|
10
|
+
|
|
11
|
+
vi.mock("next/navigation", () => ({
|
|
12
|
+
RedirectType: {
|
|
13
|
+
replace: "replace",
|
|
14
|
+
},
|
|
15
|
+
notFound: () => {
|
|
16
|
+
throw new Error("notFound");
|
|
17
|
+
},
|
|
18
|
+
redirect: (url: string) => {
|
|
19
|
+
throw new Error(`redirect:${url}`);
|
|
20
|
+
},
|
|
21
|
+
usePathname: () => window.location.pathname,
|
|
22
|
+
useSearchParams: () => new URLSearchParams(window.location.search),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
function createAppTestDouble(options: {
|
|
26
|
+
redirectToHandler: (name: string, options: { replace: true }) => Promise<void>,
|
|
27
|
+
}) {
|
|
28
|
+
const projectId = "00000000-0000-4000-8000-000000000000";
|
|
29
|
+
const app = {
|
|
30
|
+
projectId,
|
|
31
|
+
urls: {
|
|
32
|
+
handler: "http://localhost/handler",
|
|
33
|
+
signIn: `https://${projectId}.example-stack-hosted.test/handler/sign-in`,
|
|
34
|
+
home: "http://localhost",
|
|
35
|
+
},
|
|
36
|
+
redirectToHome: vi.fn(async () => {}),
|
|
37
|
+
[hexclaveAppInternalsSymbol]: {
|
|
38
|
+
getConstructorOptions: () => ({ urls: {} }),
|
|
39
|
+
redirectToHandler: options.redirectToHandler,
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
// This test double intentionally implements only the StackClientApp surface
|
|
44
|
+
// that HexclaveHandlerClient touches in this redirect path.
|
|
45
|
+
return app as unknown as StackClientApp<true>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("HexclaveHandlerClient", () => {
|
|
49
|
+
it("returns known cross-domain redirect errors instead of treating them as unhandled async failures", async () => {
|
|
50
|
+
const redirectToHandler = vi.fn(async () => {
|
|
51
|
+
throw new KnownErrors.RedirectUrlNotWhitelisted();
|
|
52
|
+
});
|
|
53
|
+
const app = createAppTestDouble({ redirectToHandler });
|
|
54
|
+
|
|
55
|
+
const result = await getRedirectToPageResult(app, "signIn");
|
|
56
|
+
|
|
57
|
+
expect(redirectToHandler).toHaveBeenCalledWith("signIn", { replace: true });
|
|
58
|
+
expect(result.status).toBe("known-error");
|
|
59
|
+
if (result.status === "known-error") {
|
|
60
|
+
expect(result.error.errorCode).toBe("REDIRECT_URL_NOT_WHITELISTED");
|
|
61
|
+
expect(result.error.message).toContain("Redirect URL not whitelisted");
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
// THIS FILE IS AUTO-GENERATED FROM TEMPLATE. DO NOT EDIT IT DIRECTLY UNLESS YOU ALSO EDIT THE CORRESPONDING FILE IN packages/template
|
|
6
6
|
//===========================================
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { KnownError } from "@hexclave/shared";
|
|
9
|
+
import { captureError, HexclaveAssertionError } from "@hexclave/shared/dist/utils/errors";
|
|
9
10
|
import { FilterUndefined, filterUndefined } from "@hexclave/shared/dist/utils/objects";
|
|
10
|
-
import {
|
|
11
|
+
import { use } from "@hexclave/shared/dist/utils/react";
|
|
11
12
|
import { getRelativePart } from "@hexclave/shared/dist/utils/urls";
|
|
12
13
|
import { notFound, redirect, RedirectType, usePathname, useSearchParams } from 'next/navigation'; // THIS_LINE_PLATFORM next
|
|
13
|
-
import {
|
|
14
|
+
import { Suspense, useMemo, useSyncExternalStore } from 'react';
|
|
14
15
|
import { SignIn, SignUp, StackServerApp } from "..";
|
|
15
16
|
import { useStackApp } from "../lib/hooks";
|
|
16
17
|
import { HandlerUrls, StackClientApp, hexclaveAppInternalsSymbol } from "../lib/hexclave-app";
|
|
@@ -28,7 +29,9 @@ import { PasswordReset } from "./password-reset";
|
|
|
28
29
|
import { SignOut } from "./sign-out";
|
|
29
30
|
import { TeamInvitation } from "./team-invitation";
|
|
30
31
|
|
|
32
|
+
import { KnownErrorMessageCard } from "../components/message-cards/known-error-message-card";
|
|
31
33
|
import { MessageCard } from "../components/message-cards/message-card";
|
|
34
|
+
import { PredefinedMessageCard } from "../components/message-cards/predefined-message-card";
|
|
32
35
|
|
|
33
36
|
type Components = {
|
|
34
37
|
SignIn: typeof SignIn,
|
|
@@ -52,6 +55,11 @@ type RouteProps = {
|
|
|
52
55
|
searchParams: Promise<Record<string, string>> | Record<string, string>,
|
|
53
56
|
};
|
|
54
57
|
|
|
58
|
+
type RedirectToPageResult =
|
|
59
|
+
| { status: "success" }
|
|
60
|
+
| { status: "known-error", error: KnownError }
|
|
61
|
+
| { status: "unknown-error" };
|
|
62
|
+
|
|
55
63
|
const availablePaths = {
|
|
56
64
|
signIn: 'sign-in',
|
|
57
65
|
signUp: 'sign-up',
|
|
@@ -225,6 +233,42 @@ function renderComponent(props: {
|
|
|
225
233
|
}
|
|
226
234
|
}
|
|
227
235
|
|
|
236
|
+
export async function getRedirectToPageResult(
|
|
237
|
+
app: StackClientApp,
|
|
238
|
+
redirectToPage: keyof HandlerUrls,
|
|
239
|
+
): Promise<RedirectToPageResult> {
|
|
240
|
+
try {
|
|
241
|
+
await app[hexclaveAppInternalsSymbol].redirectToHandler(redirectToPage, { replace: true });
|
|
242
|
+
return { status: "success" };
|
|
243
|
+
} catch (e) {
|
|
244
|
+
if (KnownError.isKnownError(e)) {
|
|
245
|
+
return { status: "known-error", error: e };
|
|
246
|
+
}
|
|
247
|
+
captureError("<HexclaveHandlerClient redirectToPage />", e);
|
|
248
|
+
return { status: "unknown-error" };
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function RedirectToPage(props: {
|
|
253
|
+
app: StackClientApp,
|
|
254
|
+
fullPage?: boolean,
|
|
255
|
+
redirectToPage: keyof HandlerUrls,
|
|
256
|
+
}) {
|
|
257
|
+
const redirectResultPromise = useMemo(
|
|
258
|
+
() => getRedirectToPageResult(props.app, props.redirectToPage),
|
|
259
|
+
[props.app, props.redirectToPage],
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
const redirectResult = use(redirectResultPromise);
|
|
263
|
+
if (redirectResult.status === "known-error") {
|
|
264
|
+
return <KnownErrorMessageCard error={redirectResult.error} fullPage={props.fullPage} />;
|
|
265
|
+
}
|
|
266
|
+
if (redirectResult.status === "unknown-error") {
|
|
267
|
+
return <PredefinedMessageCard type="unknownError" fullPage={props.fullPage} />;
|
|
268
|
+
}
|
|
269
|
+
return <MessageCard title="Redirecting..." fullPage={props.fullPage} />;
|
|
270
|
+
}
|
|
271
|
+
|
|
228
272
|
export function HexclaveHandlerClient(props: BaseHandlerProps & Partial<RouteProps> & { location?: string }) {
|
|
229
273
|
// Use hooks to get app
|
|
230
274
|
const hexclaveApp = useStackApp();
|
|
@@ -284,16 +328,11 @@ export function HexclaveHandlerClient(props: BaseHandlerProps & Partial<RoutePro
|
|
|
284
328
|
|
|
285
329
|
const redirectToPage = (result != null && typeof result === 'object' && 'redirectToPage' in result) ? result.redirectToPage : undefined;
|
|
286
330
|
|
|
287
|
-
useEffect(() => {
|
|
288
|
-
if (redirectToPage == null) return;
|
|
289
|
-
runAsynchronouslyWithAlert(
|
|
290
|
-
hexclaveApp[hexclaveAppInternalsSymbol].redirectToHandler(redirectToPage, { replace: true })
|
|
291
|
-
);
|
|
292
|
-
}, [redirectToPage, hexclaveApp]);
|
|
293
|
-
|
|
294
331
|
if (redirectToPage != null) {
|
|
295
332
|
return (
|
|
296
|
-
<MessageCard title="Redirecting..." fullPage={props.fullPage} />
|
|
333
|
+
<Suspense fallback={<MessageCard title="Redirecting..." fullPage={props.fullPage} />}>
|
|
334
|
+
<RedirectToPage app={hexclaveApp} redirectToPage={redirectToPage} fullPage={props.fullPage} />
|
|
335
|
+
</Suspense>
|
|
297
336
|
);
|
|
298
337
|
}
|
|
299
338
|
|
|
@@ -7,7 +7,7 @@ import type { RequestLogEntry } from "@hexclave/shared/dist/interface/client-int
|
|
|
7
7
|
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
|
|
8
8
|
import { isLocalhost } from "@hexclave/shared/dist/utils/urls";
|
|
9
9
|
import type { StackClientApp } from "../lib/hexclave-app";
|
|
10
|
-
import { envVars } from "../
|
|
10
|
+
import { envVars } from "../generated/env";
|
|
11
11
|
import { getBaseUrl } from "../lib/hexclave-app/apps/implementations/common";
|
|
12
12
|
import type { HandlerUrlOptions, HandlerUrls, HandlerUrlTarget } from "../lib/hexclave-app/common";
|
|
13
13
|
import { hexclaveAppInternalsSymbol } from "../lib/hexclave-app/common";
|
|
@@ -211,7 +211,7 @@ function resolveApiBaseUrl(app: StackClientApp<true>): string {
|
|
|
211
211
|
}
|
|
212
212
|
|
|
213
213
|
function shouldShowDashboardTab(app: StackClientApp<true>): boolean {
|
|
214
|
-
return envVars.
|
|
214
|
+
return envVars.HEXCLAVE_IS_LOCAL_EMULATOR === "true" && isLocalhost(resolveApiBaseUrl(app));
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
function getTabsForApp(app: StackClientApp<true>): { id: TabId; label: string; icon: string }[] {
|
package/src/generated/.gitignore
CHANGED
package/src/global.d.ts
CHANGED
|
@@ -2,4 +2,11 @@
|
|
|
2
2
|
//===========================================
|
|
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
|
-
import type {} from "react/canary";
|
|
5
|
+
import type { } from "react/canary";
|
|
6
|
+
|
|
7
|
+
declare global {
|
|
8
|
+
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
|
9
|
+
interface ImportMeta {
|
|
10
|
+
readonly env?: Record<string, string | undefined>,
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -3,8 +3,39 @@
|
|
|
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 { describe, expect, it, vi } from "vitest";
|
|
6
|
+
import { AccessToken } from "@hexclave/shared/dist/sessions";
|
|
7
|
+
import { Store } from "@hexclave/shared/dist/utils/stores";
|
|
6
8
|
import { StackClientApp } from "../interfaces/client-app";
|
|
7
9
|
|
|
10
|
+
function createAccessTokenString(refreshTokenId: string): string {
|
|
11
|
+
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
12
|
+
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
13
|
+
return [
|
|
14
|
+
encode({ alg: "none", typ: "JWT" }),
|
|
15
|
+
encode({
|
|
16
|
+
sub: "user-id",
|
|
17
|
+
exp: nowSeconds + 60,
|
|
18
|
+
iat: nowSeconds,
|
|
19
|
+
iss: "https://api.example.test",
|
|
20
|
+
aud: "project-id",
|
|
21
|
+
project_id: "project-id",
|
|
22
|
+
branch_id: "main",
|
|
23
|
+
refresh_token_id: refreshTokenId,
|
|
24
|
+
role: "authenticated",
|
|
25
|
+
name: null,
|
|
26
|
+
email: null,
|
|
27
|
+
email_verified: false,
|
|
28
|
+
selected_team_id: null,
|
|
29
|
+
signed_up_at: nowSeconds,
|
|
30
|
+
is_anonymous: false,
|
|
31
|
+
is_restricted: false,
|
|
32
|
+
restricted_reason: null,
|
|
33
|
+
requires_totp_mfa: false,
|
|
34
|
+
}),
|
|
35
|
+
"",
|
|
36
|
+
].join(".");
|
|
37
|
+
}
|
|
38
|
+
|
|
8
39
|
function createMockDocument(): Document {
|
|
9
40
|
const cookieJar = new Map<string, string>();
|
|
10
41
|
return {
|
|
@@ -23,12 +54,13 @@ function createMockDocument(): Document {
|
|
|
23
54
|
|
|
24
55
|
describe("StackClientApp cross-domain auth", () => {
|
|
25
56
|
it("uses the fresh post-auth refresh token when minting a cross-domain handoff", async () => {
|
|
57
|
+
const freshAccessToken = createAccessTokenString("fresh-refresh-token-id");
|
|
26
58
|
const clientApp = new StackClientApp({
|
|
27
59
|
baseUrl: "http://localhost:12345",
|
|
28
60
|
projectId: "00000000-0000-4000-8000-000000000000",
|
|
29
61
|
publishableClientKey: "stack-pk-test",
|
|
30
62
|
tokenStore: {
|
|
31
|
-
accessToken: "stale-
|
|
63
|
+
accessToken: createAccessTokenString("stale-refresh-token-id"),
|
|
32
64
|
refreshToken: "stale-refresh-token",
|
|
33
65
|
},
|
|
34
66
|
redirectMethod: "none",
|
|
@@ -37,24 +69,43 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
37
69
|
|
|
38
70
|
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
39
71
|
const originalSendClientRequest = Reflect.get(clientInterface, "sendClientRequest");
|
|
72
|
+
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
|
40
73
|
const capturedRefreshTokens: string[] = [];
|
|
74
|
+
const capturedAccessTokenRefreshTokenIds: string[] = [];
|
|
75
|
+
const refreshedRawRefreshTokens: string[] = [];
|
|
41
76
|
|
|
42
77
|
Reflect.set(clientInterface, "sendClientRequest", async (_path: unknown, _requestOptions: unknown, session: unknown) => {
|
|
43
78
|
const getRefreshToken = Reflect.get(session ?? {}, "getRefreshToken");
|
|
79
|
+
const getOrFetchLikelyValidTokens = Reflect.get(session ?? {}, "getOrFetchLikelyValidTokens");
|
|
44
80
|
if (typeof getRefreshToken !== "function") {
|
|
45
81
|
throw new Error("Expected cross-domain auth to pass a session to the client interface.");
|
|
46
82
|
}
|
|
83
|
+
if (typeof getOrFetchLikelyValidTokens !== "function") {
|
|
84
|
+
throw new Error("Expected cross-domain auth to pass a session with token accessors.");
|
|
85
|
+
}
|
|
47
86
|
const refreshToken = getRefreshToken.call(session);
|
|
48
87
|
const refreshTokenString = Reflect.get(refreshToken ?? {}, "token");
|
|
49
88
|
if (typeof refreshTokenString !== "string") {
|
|
50
89
|
throw new Error("Expected cross-domain auth to pass a refresh-token-backed session.");
|
|
51
90
|
}
|
|
52
91
|
capturedRefreshTokens.push(refreshTokenString);
|
|
92
|
+
const tokens = await getOrFetchLikelyValidTokens.call(session, 0, null);
|
|
93
|
+
capturedAccessTokenRefreshTokenIds.push(tokens.accessToken.payload.refresh_token_id);
|
|
53
94
|
return {
|
|
54
95
|
ok: true,
|
|
55
96
|
json: async () => ({ redirect_url: "https://example.com/handler/oauth-callback?code=handoff-code&state=handoff-state" }),
|
|
56
97
|
};
|
|
57
98
|
});
|
|
99
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", async (refreshToken: unknown) => {
|
|
100
|
+
const refreshTokenString = Reflect.get(refreshToken ?? {}, "token");
|
|
101
|
+
if (typeof refreshTokenString !== "string") {
|
|
102
|
+
throw new Error("Expected refresh token while fetching a new access token.");
|
|
103
|
+
}
|
|
104
|
+
refreshedRawRefreshTokens.push(refreshTokenString);
|
|
105
|
+
return AccessToken.createIfValid(freshAccessToken) ?? (() => {
|
|
106
|
+
throw new Error("Expected test access token to be valid");
|
|
107
|
+
})();
|
|
108
|
+
});
|
|
58
109
|
|
|
59
110
|
try {
|
|
60
111
|
const createCrossDomainAuthRedirectUrl = Reflect.get(clientApp, "_createCrossDomainAuthRedirectUrl");
|
|
@@ -68,15 +119,18 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
68
119
|
codeChallenge: "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~",
|
|
69
120
|
afterCallbackRedirectUrl: "https://example.com/account-settings",
|
|
70
121
|
overrideTokenStoreInit: {
|
|
71
|
-
accessToken: "fresh-
|
|
122
|
+
accessToken: createAccessTokenString("fresh-stale-refresh-token-id"),
|
|
72
123
|
refreshToken: "fresh-refresh-token",
|
|
73
124
|
},
|
|
74
125
|
})).resolves.toBe("https://example.com/handler/oauth-callback?code=handoff-code&state=handoff-state");
|
|
75
126
|
} finally {
|
|
76
127
|
Reflect.set(clientInterface, "sendClientRequest", originalSendClientRequest);
|
|
128
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
|
|
77
129
|
}
|
|
78
130
|
|
|
131
|
+
expect(refreshedRawRefreshTokens).toEqual(["fresh-refresh-token"]);
|
|
79
132
|
expect(capturedRefreshTokens).toEqual(["fresh-refresh-token"]);
|
|
133
|
+
expect(capturedAccessTokenRefreshTokenIds).toEqual(["fresh-refresh-token-id"]);
|
|
80
134
|
});
|
|
81
135
|
|
|
82
136
|
it("uses a fresh nested OAuth state while preserving the outer cross-domain return state", async () => {
|
|
@@ -105,7 +159,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
105
159
|
const previousWindow = globalThis.window;
|
|
106
160
|
const previousDocument = globalThis.document;
|
|
107
161
|
let redirectedUrl = "";
|
|
108
|
-
vi.spyOn(clientApp as any, "
|
|
162
|
+
vi.spyOn(clientApp as any, "_fetchCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
|
|
109
163
|
vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({
|
|
110
164
|
state: "fresh-nested-state",
|
|
111
165
|
codeChallenge: "fresh-nested-code-challenge",
|
|
@@ -138,4 +192,263 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
138
192
|
expect(redirectUri.searchParams.get("hexclave_cross_domain_code_challenge")).toBe(outerCodeChallenge);
|
|
139
193
|
expect(redirectUri.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe("https://demo.stack-auth.com/");
|
|
140
194
|
});
|
|
195
|
+
|
|
196
|
+
it("clears a stale target-domain session before deferring to the source-domain session", async () => {
|
|
197
|
+
const projectId = "00000000-0000-4000-8000-000000000006";
|
|
198
|
+
const hostedAccessToken = createAccessTokenString("hosted-old-refresh-token-id");
|
|
199
|
+
const clientApp = new StackClientApp({
|
|
200
|
+
baseUrl: "http://localhost:12345",
|
|
201
|
+
projectId,
|
|
202
|
+
publishableClientKey: "stack-pk-test",
|
|
203
|
+
tokenStore: "memory",
|
|
204
|
+
redirectMethod: "window",
|
|
205
|
+
urls: {
|
|
206
|
+
default: { type: "hosted" },
|
|
207
|
+
},
|
|
208
|
+
noAutomaticPrefetch: true,
|
|
209
|
+
});
|
|
210
|
+
const tokenStore = Reflect.get(clientApp, "_memoryTokenStore");
|
|
211
|
+
if (!(tokenStore instanceof Store)) {
|
|
212
|
+
throw new Error("Expected StackClientApp to use a memory token store in this test.");
|
|
213
|
+
}
|
|
214
|
+
tokenStore.set({
|
|
215
|
+
refreshToken: "hosted-old-refresh-token",
|
|
216
|
+
accessToken: hostedAccessToken,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const currentUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`);
|
|
220
|
+
currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-anonymous-refresh-token-id");
|
|
221
|
+
currentUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", "https://demo.stack-auth.com/handler/oauth-callback");
|
|
222
|
+
currentUrl.searchParams.set("hexclave_cross_domain_state", "outer-state");
|
|
223
|
+
currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", "outer-code-challenge");
|
|
224
|
+
currentUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://demo.stack-auth.com/app");
|
|
225
|
+
|
|
226
|
+
const previousWindow = globalThis.window;
|
|
227
|
+
const previousDocument = globalThis.document;
|
|
228
|
+
let redirectedUrl = "";
|
|
229
|
+
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
230
|
+
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
|
231
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", async () => {
|
|
232
|
+
return AccessToken.createIfValid(hostedAccessToken) ?? (() => {
|
|
233
|
+
throw new Error("Expected test access token to be valid");
|
|
234
|
+
})();
|
|
235
|
+
});
|
|
236
|
+
vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true);
|
|
237
|
+
|
|
238
|
+
globalThis.document = createMockDocument();
|
|
239
|
+
globalThis.window = {
|
|
240
|
+
location: {
|
|
241
|
+
href: currentUrl.toString(),
|
|
242
|
+
replace: (url: string) => {
|
|
243
|
+
redirectedUrl = url;
|
|
244
|
+
throw new Error("INTENTIONAL_TEST_ABORT");
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
} as any;
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
|
|
251
|
+
} finally {
|
|
252
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
|
|
253
|
+
globalThis.window = previousWindow;
|
|
254
|
+
globalThis.document = previousDocument;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
expect(tokenStore.get()).toEqual({
|
|
258
|
+
refreshToken: null,
|
|
259
|
+
accessToken: null,
|
|
260
|
+
});
|
|
261
|
+
expect(new URL(redirectedUrl).origin).toBe("https://demo.stack-auth.com");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("uses the latest browser refresh cookie before computing nested cross-domain session IDs", async () => {
|
|
265
|
+
const projectId = "00000000-0000-4000-8000-000000000007";
|
|
266
|
+
const previousWindow = globalThis.window;
|
|
267
|
+
const previousDocument = globalThis.document;
|
|
268
|
+
|
|
269
|
+
globalThis.document = createMockDocument();
|
|
270
|
+
globalThis.window = {
|
|
271
|
+
location: {
|
|
272
|
+
href: "https://demo.stack-auth.com/",
|
|
273
|
+
protocol: "https:",
|
|
274
|
+
hostname: "demo.stack-auth.com",
|
|
275
|
+
},
|
|
276
|
+
} as any;
|
|
277
|
+
|
|
278
|
+
const clientApp = new StackClientApp({
|
|
279
|
+
baseUrl: "http://localhost:12345",
|
|
280
|
+
projectId,
|
|
281
|
+
publishableClientKey: "stack-pk-test",
|
|
282
|
+
tokenStore: "cookie",
|
|
283
|
+
redirectMethod: "none",
|
|
284
|
+
noAutomaticPrefetch: true,
|
|
285
|
+
});
|
|
286
|
+
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
287
|
+
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
|
288
|
+
const refreshedRawRefreshTokens: string[] = [];
|
|
289
|
+
|
|
290
|
+
try {
|
|
291
|
+
const getBrowserCookieTokenStore = Reflect.get(clientApp, "_getBrowserCookieTokenStore");
|
|
292
|
+
if (typeof getBrowserCookieTokenStore !== "function") {
|
|
293
|
+
throw new Error("Expected StackClientApp to expose _getBrowserCookieTokenStore in tests.");
|
|
294
|
+
}
|
|
295
|
+
const tokenStore = getBrowserCookieTokenStore.call(clientApp);
|
|
296
|
+
tokenStore.set({
|
|
297
|
+
refreshToken: "old-refresh-token",
|
|
298
|
+
accessToken: createAccessTokenString("old-refresh-token-id"),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
document.cookie = `__Host-hexclave-refresh-${projectId}--default=${JSON.stringify({
|
|
302
|
+
refresh_token: "new-refresh-token",
|
|
303
|
+
updated_at_millis: 1,
|
|
304
|
+
})}`;
|
|
305
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", async (refreshToken: unknown) => {
|
|
306
|
+
const refreshTokenString = Reflect.get(refreshToken ?? {}, "token");
|
|
307
|
+
if (typeof refreshTokenString !== "string") {
|
|
308
|
+
throw new Error("Expected refresh token while fetching a new access token.");
|
|
309
|
+
}
|
|
310
|
+
refreshedRawRefreshTokens.push(refreshTokenString);
|
|
311
|
+
return AccessToken.createIfValid(createAccessTokenString("new-refresh-token-id")) ?? (() => {
|
|
312
|
+
throw new Error("Expected test access token to be valid");
|
|
313
|
+
})();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
const fetchCurrentRefreshTokenIdIfSignedIn = Reflect.get(clientApp, "_fetchCurrentRefreshTokenIdIfSignedIn");
|
|
317
|
+
if (typeof fetchCurrentRefreshTokenIdIfSignedIn !== "function") {
|
|
318
|
+
throw new Error("Expected StackClientApp to expose _fetchCurrentRefreshTokenIdIfSignedIn in tests.");
|
|
319
|
+
}
|
|
320
|
+
await expect(fetchCurrentRefreshTokenIdIfSignedIn.call(clientApp, {
|
|
321
|
+
awaitPendingAuthResolutions: false,
|
|
322
|
+
})).resolves.toBe("new-refresh-token-id");
|
|
323
|
+
} finally {
|
|
324
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
|
|
325
|
+
globalThis.window = previousWindow;
|
|
326
|
+
globalThis.document = previousDocument;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
expect(refreshedRawRefreshTokens).toEqual(["new-refresh-token"]);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it("uses direct sign-out instead of hosted sign-out redirects when code execution is available", async () => {
|
|
333
|
+
const clientApp = new StackClientApp({
|
|
334
|
+
baseUrl: "http://localhost:12345",
|
|
335
|
+
projectId: "00000000-0000-4000-8000-000000000003",
|
|
336
|
+
publishableClientKey: "stack-pk-test",
|
|
337
|
+
tokenStore: "memory",
|
|
338
|
+
redirectMethod: "window",
|
|
339
|
+
urls: {
|
|
340
|
+
handler: "/handler",
|
|
341
|
+
signOut: { type: "hosted" },
|
|
342
|
+
},
|
|
343
|
+
noAutomaticPrefetch: true,
|
|
344
|
+
});
|
|
345
|
+
const signOutSpy = vi.spyOn(clientApp, "signOut").mockRejectedValue(new Error("INTENTIONAL_TEST_ABORT"));
|
|
346
|
+
|
|
347
|
+
try {
|
|
348
|
+
await expect(clientApp.redirectToSignOut()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
|
|
349
|
+
expect(signOutSpy).toHaveBeenCalledWith();
|
|
350
|
+
} finally {
|
|
351
|
+
signOutSpy.mockRestore();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("keeps default hosted signOut() on the source domain when afterSignOut is not configured", async () => {
|
|
356
|
+
const clientApp = new StackClientApp({
|
|
357
|
+
baseUrl: "http://localhost:12345",
|
|
358
|
+
projectId: "00000000-0000-4000-8000-000000000004",
|
|
359
|
+
publishableClientKey: "stack-pk-test",
|
|
360
|
+
tokenStore: "memory",
|
|
361
|
+
redirectMethod: "window",
|
|
362
|
+
urls: {
|
|
363
|
+
default: { type: "hosted" },
|
|
364
|
+
},
|
|
365
|
+
noAutomaticPrefetch: true,
|
|
366
|
+
});
|
|
367
|
+
const currentHref = "https://demo.stack-auth.com/settings?tab=profile";
|
|
368
|
+
|
|
369
|
+
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
370
|
+
const originalSignOut = Reflect.get(clientInterface, "signOut");
|
|
371
|
+
Reflect.set(clientInterface, "signOut", async () => {});
|
|
372
|
+
const previousWindow = globalThis.window;
|
|
373
|
+
const previousDocument = globalThis.document;
|
|
374
|
+
let redirectedUrl = "";
|
|
375
|
+
|
|
376
|
+
globalThis.document = createMockDocument();
|
|
377
|
+
globalThis.window = {
|
|
378
|
+
location: {
|
|
379
|
+
href: currentHref,
|
|
380
|
+
replace: (url: string) => {
|
|
381
|
+
redirectedUrl = url;
|
|
382
|
+
throw new Error("INTENTIONAL_TEST_ABORT");
|
|
383
|
+
},
|
|
384
|
+
},
|
|
385
|
+
} as any;
|
|
386
|
+
|
|
387
|
+
try {
|
|
388
|
+
const signOut = Reflect.get(clientApp, "_signOut");
|
|
389
|
+
if (typeof signOut !== "function") {
|
|
390
|
+
throw new Error("Expected StackClientApp to expose _signOut in tests.");
|
|
391
|
+
}
|
|
392
|
+
await expect(signOut.call(clientApp, Reflect.get(clientInterface, "createSession").call(clientInterface, {
|
|
393
|
+
refreshToken: null,
|
|
394
|
+
}))).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
|
|
395
|
+
} finally {
|
|
396
|
+
Reflect.set(clientInterface, "signOut", originalSignOut);
|
|
397
|
+
globalThis.window = previousWindow;
|
|
398
|
+
globalThis.document = previousDocument;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
expect(redirectedUrl).toBe("/settings?tab=profile");
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
it("ignores stale session callbacks after a newer refresh token owns the token store", async () => {
|
|
405
|
+
const clientApp = new StackClientApp({
|
|
406
|
+
baseUrl: "http://localhost:12345",
|
|
407
|
+
projectId: "00000000-0000-4000-8000-000000000005",
|
|
408
|
+
publishableClientKey: "stack-pk-test",
|
|
409
|
+
tokenStore: "memory",
|
|
410
|
+
redirectMethod: "none",
|
|
411
|
+
noAutomaticPrefetch: true,
|
|
412
|
+
});
|
|
413
|
+
const oldAccessToken = createAccessTokenString("old-refresh-token-id");
|
|
414
|
+
const refreshedOldAccessToken = createAccessTokenString("refreshed-old-refresh-token-id");
|
|
415
|
+
const newAccessToken = createAccessTokenString("new-refresh-token-id");
|
|
416
|
+
const tokenStore = new Store({
|
|
417
|
+
refreshToken: "old-refresh-token",
|
|
418
|
+
accessToken: oldAccessToken,
|
|
419
|
+
});
|
|
420
|
+
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
421
|
+
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
|
422
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", async () => {
|
|
423
|
+
return AccessToken.createIfValid(refreshedOldAccessToken) ?? (() => {
|
|
424
|
+
throw new Error("Expected test access token to be valid");
|
|
425
|
+
})();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
try {
|
|
429
|
+
const getSessionFromTokenStore = Reflect.get(clientApp, "_getSessionFromTokenStore");
|
|
430
|
+
if (typeof getSessionFromTokenStore !== "function") {
|
|
431
|
+
throw new Error("Expected StackClientApp to expose _getSessionFromTokenStore in tests.");
|
|
432
|
+
}
|
|
433
|
+
const oldSession = getSessionFromTokenStore.call(clientApp, tokenStore);
|
|
434
|
+
tokenStore.set({
|
|
435
|
+
refreshToken: "new-refresh-token",
|
|
436
|
+
accessToken: newAccessToken,
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await oldSession.fetchNewTokens();
|
|
440
|
+
expect(tokenStore.get()).toEqual({
|
|
441
|
+
refreshToken: "new-refresh-token",
|
|
442
|
+
accessToken: newAccessToken,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
oldSession.markInvalid();
|
|
446
|
+
expect(tokenStore.get()).toEqual({
|
|
447
|
+
refreshToken: "new-refresh-token",
|
|
448
|
+
accessToken: newAccessToken,
|
|
449
|
+
});
|
|
450
|
+
} finally {
|
|
451
|
+
Reflect.set(clientInterface, "fetchNewAccessToken", originalFetchNewAccessToken);
|
|
452
|
+
}
|
|
453
|
+
});
|
|
141
454
|
});
|