@hexclave/next 1.0.75 → 1.0.76
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-sTQD2d-3.d.ts → admin-app-impl-CsH-gVGz.d.ts} +7 -7
- package/dist/{admin-app-impl-sTQD2d-3.d.ts.map → admin-app-impl-CsH-gVGz.d.ts.map} +1 -1
- package/dist/components/api-key-dialogs.d.ts +1 -1
- package/dist/components/elements/sidebar-layout.js +1 -1
- package/dist/dev-tool/dev-tool-core.d.ts.map +1 -1
- package/dist/dev-tool/dev-tool-core.js +2 -2
- package/dist/dev-tool/dev-tool-core.js.map +1 -1
- package/dist/dev-tool/index.d.ts.map +1 -1
- package/dist/dev-tool/index.js +2 -8
- package/dist/dev-tool/index.js.map +1 -1
- package/dist/esm/components/api-key-dialogs.d.ts +1 -1
- package/dist/esm/components/elements/sidebar-layout.js +1 -1
- package/dist/esm/dev-tool/dev-tool-core.d.ts.map +1 -1
- package/dist/esm/dev-tool/dev-tool-core.js +1 -1
- package/dist/esm/dev-tool/dev-tool-core.js.map +1 -1
- package/dist/esm/dev-tool/index.d.ts.map +1 -1
- package/dist/esm/dev-tool/index.js +2 -8
- package/dist/esm/dev-tool/index.js.map +1 -1
- package/dist/esm/generated/quetzal-translations.d.ts +2 -2
- package/dist/esm/in-page-ui/dev-environment.d.ts +5 -0
- package/dist/esm/in-page-ui/dev-environment.d.ts.map +1 -0
- package/dist/esm/in-page-ui/dev-environment.js +18 -0
- package/dist/esm/in-page-ui/dev-environment.js.map +1 -0
- package/dist/esm/in-page-ui/issue-card.d.ts +52 -0
- package/dist/esm/in-page-ui/issue-card.d.ts.map +1 -0
- package/dist/esm/in-page-ui/issue-card.js +392 -0
- package/dist/esm/in-page-ui/issue-card.js.map +1 -0
- package/dist/esm/in-page-ui/issue-card.test.d.ts +1 -0
- package/dist/esm/in-page-ui/issue-card.test.js +60 -0
- package/dist/esm/in-page-ui/issue-card.test.js.map +1 -0
- package/dist/esm/in-page-ui/logo.d.ts +5 -0
- package/dist/esm/in-page-ui/logo.d.ts.map +1 -0
- package/dist/esm/in-page-ui/logo.js +6 -0
- package/dist/esm/in-page-ui/logo.js.map +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/admin-app-impl.d.ts +4 -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 +140 -15
- 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 +2 -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 +95 -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-page-urls.test.js +1 -0
- package/dist/esm/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js.map +1 -1
- package/dist/esm/pushed-config-error-overlay/index.d.ts.map +1 -1
- package/dist/esm/pushed-config-error-overlay/index.js +42 -343
- package/dist/esm/pushed-config-error-overlay/index.js.map +1 -1
- package/dist/esm/setup-error-overlay/index.d.ts +16 -0
- package/dist/esm/setup-error-overlay/index.d.ts.map +1 -0
- package/dist/esm/setup-error-overlay/index.js +106 -0
- package/dist/esm/setup-error-overlay/index.js.map +1 -0
- package/dist/esm/setup-error-overlay/index.test.d.ts +1 -0
- package/dist/esm/setup-error-overlay/index.test.js +125 -0
- package/dist/esm/setup-error-overlay/index.test.js.map +1 -0
- package/dist/generated/quetzal-translations.d.ts +1 -1
- package/dist/{hexclave-provider-BKxi3Vy-.d.ts → hexclave-provider-BA97jMlx.d.ts} +2 -2
- package/dist/{hexclave-provider-BKxi3Vy-.d.ts.map → hexclave-provider-BA97jMlx.d.ts.map} +1 -1
- package/dist/in-page-ui/dev-environment.d.ts +5 -0
- package/dist/in-page-ui/dev-environment.d.ts.map +1 -0
- package/dist/in-page-ui/dev-environment.js +19 -0
- package/dist/in-page-ui/dev-environment.js.map +1 -0
- package/dist/in-page-ui/issue-card.d.ts +52 -0
- package/dist/in-page-ui/issue-card.d.ts.map +1 -0
- package/dist/in-page-ui/issue-card.js +397 -0
- package/dist/in-page-ui/issue-card.js.map +1 -0
- package/dist/in-page-ui/issue-card.test.d.ts +1 -0
- package/dist/in-page-ui/issue-card.test.js +59 -0
- package/dist/in-page-ui/issue-card.test.js.map +1 -0
- package/dist/in-page-ui/logo.d.ts +5 -0
- package/dist/in-page-ui/logo.d.ts.map +1 -0
- package/dist/in-page-ui/logo.js +7 -0
- package/dist/in-page-ui/logo.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 +140 -15
- 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 +94 -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-page-urls.test.js +1 -0
- package/dist/lib/hexclave-app/apps/implementations/redirect-page-urls.test.js.map +1 -1
- package/dist/lib/hexclave-app/apps/implementations/server-app-impl.d.ts +1 -1
- package/dist/providers/hexclave-provider.d.ts +1 -1
- package/dist/providers/translation-provider.d.ts +1 -1
- package/dist/pushed-config-error-overlay/index.d.ts.map +1 -1
- package/dist/pushed-config-error-overlay/index.js +41 -342
- package/dist/pushed-config-error-overlay/index.js.map +1 -1
- package/dist/{quetzal-translations-XeH3zQ9l.d.ts → quetzal-translations-CK45_4Dm.d.ts} +3 -3
- package/dist/{quetzal-translations-XeH3zQ9l.d.ts.map → quetzal-translations-CK45_4Dm.d.ts.map} +1 -1
- package/dist/setup-error-overlay/index.d.ts +16 -0
- package/dist/setup-error-overlay/index.d.ts.map +1 -0
- package/dist/setup-error-overlay/index.js +108 -0
- package/dist/setup-error-overlay/index.js.map +1 -0
- package/dist/setup-error-overlay/index.test.d.ts +1 -0
- package/dist/setup-error-overlay/index.test.js +124 -0
- package/dist/setup-error-overlay/index.test.js.map +1 -0
- package/dist/{translation-provider-WOaZXqAz.d.ts → translation-provider-DQ3BJepJ.d.ts} +2 -2
- package/dist/{translation-provider-WOaZXqAz.d.ts.map → translation-provider-DQ3BJepJ.d.ts.map} +1 -1
- package/package.json +4 -4
- package/src/dev-tool/dev-tool-core.ts +1 -4
- package/src/dev-tool/index.ts +4 -15
- package/src/in-page-ui/dev-environment.ts +33 -0
- package/src/in-page-ui/issue-card.test.ts +67 -0
- package/src/in-page-ui/issue-card.ts +500 -0
- package/src/in-page-ui/logo.ts +8 -0
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.cross-domain.test.ts +158 -0
- package/src/lib/hexclave-app/apps/implementations/client-app-impl.ts +125 -17
- package/src/lib/hexclave-app/apps/implementations/redirect-page-urls.test.ts +1 -0
- package/src/pushed-config-error-overlay/index.ts +45 -386
- package/src/setup-error-overlay/index.test.ts +133 -0
- package/src/setup-error-overlay/index.ts +150 -0
|
@@ -4,11 +4,16 @@
|
|
|
4
4
|
//===========================================
|
|
5
5
|
import { describe, expect, it, vi } from "vitest";
|
|
6
6
|
import { AccessToken } from "@hexclave/shared/dist/sessions";
|
|
7
|
+
import { HexclaveSetupError } from "@hexclave/shared/dist/utils/errors";
|
|
7
8
|
import { Store } from "@hexclave/shared/dist/utils/stores";
|
|
8
9
|
import { hexclaveAppInternalsSymbol } from "../../common";
|
|
9
10
|
import { StackClientApp } from "../interfaces/client-app";
|
|
10
11
|
import { planRedirectToHandler } from "./redirect-page-urls";
|
|
11
12
|
|
|
13
|
+
// Every app in this file is constructed with `devTool: false`. The tests install a mock window and document, which makes
|
|
14
|
+
// the SDK believe it is in a browser, and mounting the dev tool kicks off a background `getProject()` against a backend
|
|
15
|
+
// that does not exist here. That fetch never settles, and because it runs inside the global store lock, it blocks every
|
|
16
|
+
// later `withWriteLock` caller (`_signOut`, most visibly) for the rest of the test file.
|
|
12
17
|
function createAccessTokenString(refreshTokenId: string): string {
|
|
13
18
|
const encode = (value: unknown) => Buffer.from(JSON.stringify(value)).toString("base64url");
|
|
14
19
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
@@ -54,6 +59,28 @@ function createMockDocument(): Document {
|
|
|
54
59
|
} as any;
|
|
55
60
|
}
|
|
56
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Installs a stand-in for `window` with only the parts the code under test touches. `globalThis.window` is typed as a
|
|
64
|
+
* full `Window`, which a hand-written stub can never be, so the assignment goes through `Reflect.set` — the stub's own
|
|
65
|
+
* shape stays checked, unlike with a cast on the object literal.
|
|
66
|
+
*/
|
|
67
|
+
function setMockWindow(mockWindow: {
|
|
68
|
+
location: { href: string, replace?: (url: string) => void },
|
|
69
|
+
}): void {
|
|
70
|
+
Reflect.set(globalThis, "window", mockWindow);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Calls one of the app's `protected` methods, which are part of the flows under test but not of the public SDK surface.
|
|
75
|
+
*/
|
|
76
|
+
function callProtectedMethod(app: StackClientApp<true>, methodName: string, ...args: unknown[]): Promise<unknown> {
|
|
77
|
+
const method = Reflect.get(app, methodName);
|
|
78
|
+
if (typeof method !== "function") {
|
|
79
|
+
throw new Error(`Expected StackClientApp to have a ${methodName} method in tests.`);
|
|
80
|
+
}
|
|
81
|
+
return method.apply(app, args);
|
|
82
|
+
}
|
|
83
|
+
|
|
57
84
|
describe("StackClientApp cross-domain auth", () => {
|
|
58
85
|
it("keeps noRedirectBack same-domain redirects free of redirect-back state", async () => {
|
|
59
86
|
const redirectPlan = await planRedirectToHandler({
|
|
@@ -145,6 +172,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
145
172
|
signIn: "/handler/sign-in",
|
|
146
173
|
},
|
|
147
174
|
noAutomaticPrefetch: true,
|
|
175
|
+
devTool: false,
|
|
148
176
|
});
|
|
149
177
|
|
|
150
178
|
const redirectUrl = await clientApp[hexclaveAppInternalsSymbol].getRedirectToHandlerUrl("signIn");
|
|
@@ -173,6 +201,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
173
201
|
},
|
|
174
202
|
redirectMethod: "none",
|
|
175
203
|
noAutomaticPrefetch: true,
|
|
204
|
+
devTool: false,
|
|
176
205
|
});
|
|
177
206
|
|
|
178
207
|
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
@@ -253,6 +282,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
253
282
|
default: { type: "hosted" },
|
|
254
283
|
},
|
|
255
284
|
noAutomaticPrefetch: true,
|
|
285
|
+
devTool: false,
|
|
256
286
|
});
|
|
257
287
|
const outerState = "outer-cross-domain-state";
|
|
258
288
|
const outerCodeChallenge = "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~";
|
|
@@ -314,6 +344,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
314
344
|
default: { type: "hosted" },
|
|
315
345
|
},
|
|
316
346
|
noAutomaticPrefetch: true,
|
|
347
|
+
devTool: false,
|
|
317
348
|
});
|
|
318
349
|
const tokenStore = Reflect.get(clientApp, "_memoryTokenStore");
|
|
319
350
|
if (!(tokenStore instanceof Store)) {
|
|
@@ -392,6 +423,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
392
423
|
tokenStore: "cookie",
|
|
393
424
|
redirectMethod: "none",
|
|
394
425
|
noAutomaticPrefetch: true,
|
|
426
|
+
devTool: false,
|
|
395
427
|
});
|
|
396
428
|
const clientInterface = Reflect.get(clientApp, "_interface");
|
|
397
429
|
const originalFetchNewAccessToken = Reflect.get(clientInterface, "fetchNewAccessToken");
|
|
@@ -466,6 +498,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
466
498
|
tokenStore: "memory",
|
|
467
499
|
redirectMethod: "window",
|
|
468
500
|
noAutomaticPrefetch: true,
|
|
501
|
+
devTool: false,
|
|
469
502
|
});
|
|
470
503
|
|
|
471
504
|
globalThis.document = createMockDocument();
|
|
@@ -499,6 +532,122 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
499
532
|
}
|
|
500
533
|
});
|
|
501
534
|
|
|
535
|
+
it("reports malformed nested cross-domain auth URLs as setup errors", async () => {
|
|
536
|
+
const projectId = "00000000-0000-4000-8000-000000000012";
|
|
537
|
+
const previousWindow = globalThis.window;
|
|
538
|
+
const previousDocument = globalThis.document;
|
|
539
|
+
globalThis.document = createMockDocument();
|
|
540
|
+
|
|
541
|
+
// A URL the flow cannot even parse is as fatal as an untrusted one, so it has to reach the developer the same way
|
|
542
|
+
// instead of escaping as a bare TypeError from `new URL()`, which the overlay only shows during development.
|
|
543
|
+
// The two branches of the handler want opposite session states: on the provider side (a.com) a query-derived failure
|
|
544
|
+
// only becomes a setup error when the handoff belongs to the current session, while the requesting side (b.com) only
|
|
545
|
+
// bounces when the current session is *not* the one that was asked for.
|
|
546
|
+
const malformedCases = [
|
|
547
|
+
{
|
|
548
|
+
description: "redirect URI",
|
|
549
|
+
currentRefreshTokenId: "source-refresh-token-id",
|
|
550
|
+
params: { redirect_uri: "not-a-url", state: "nested-state", code_challenge: "nested-code-challenge" },
|
|
551
|
+
},
|
|
552
|
+
{
|
|
553
|
+
description: "after-callback redirect URL",
|
|
554
|
+
currentRefreshTokenId: "source-refresh-token-id",
|
|
555
|
+
params: {
|
|
556
|
+
redirect_uri: "https://source.example.test/handler/account-settings",
|
|
557
|
+
state: "nested-state",
|
|
558
|
+
code_challenge: "nested-code-challenge",
|
|
559
|
+
after_callback_redirect_url: "http://[",
|
|
560
|
+
},
|
|
561
|
+
},
|
|
562
|
+
{
|
|
563
|
+
description: "callback URL",
|
|
564
|
+
currentRefreshTokenId: null,
|
|
565
|
+
params: { stack_nested_cross_domain_auth_callback_url: "http://[" },
|
|
566
|
+
},
|
|
567
|
+
];
|
|
568
|
+
|
|
569
|
+
try {
|
|
570
|
+
for (const malformedCase of malformedCases) {
|
|
571
|
+
const currentUrl = new URL("https://target.example.test/nested-provider");
|
|
572
|
+
currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-refresh-token-id");
|
|
573
|
+
for (const [key, value] of Object.entries(malformedCase.params)) {
|
|
574
|
+
currentUrl.searchParams.set(key, value);
|
|
575
|
+
}
|
|
576
|
+
// Each app is constructed with the mock window uninstalled: the constructor's automatic side effects expect a
|
|
577
|
+
// real window, and a construction-time nested-auth URL would also schedule a second, unawaited run of the
|
|
578
|
+
// handler that keeps failing in the background and leaks into whichever test runs next.
|
|
579
|
+
globalThis.window = previousWindow;
|
|
580
|
+
const clientApp = new StackClientApp({
|
|
581
|
+
baseUrl: "http://localhost:12345",
|
|
582
|
+
projectId,
|
|
583
|
+
publishableClientKey: "stack-pk-test",
|
|
584
|
+
tokenStore: "memory",
|
|
585
|
+
redirectMethod: "window",
|
|
586
|
+
noAutomaticPrefetch: true,
|
|
587
|
+
devTool: false,
|
|
588
|
+
});
|
|
589
|
+
setMockWindow({ location: { href: currentUrl.toString() } });
|
|
590
|
+
Reflect.set(clientApp, "_fetchCurrentRefreshTokenIdIfSignedIn", async () => malformedCase.currentRefreshTokenId);
|
|
591
|
+
Reflect.set(clientApp, "_isTrusted", async () => true);
|
|
592
|
+
|
|
593
|
+
const nestedAuthPromise = callProtectedMethod(clientApp, "_maybeHandleNestedCrossDomainAuth");
|
|
594
|
+
await expect(nestedAuthPromise).rejects.toThrowError(new RegExp(`${malformedCase.description} .* is not a valid absolute URL`));
|
|
595
|
+
await expect(nestedAuthPromise).rejects.toSatisfy((error: unknown) => HexclaveSetupError.isSetupError(error));
|
|
596
|
+
}
|
|
597
|
+
} finally {
|
|
598
|
+
globalThis.window = previousWindow;
|
|
599
|
+
globalThis.document = previousDocument;
|
|
600
|
+
}
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
it("keeps nested cross-domain auth failures off the page for a handoff that does not own the session", async () => {
|
|
604
|
+
const projectId = "00000000-0000-4000-8000-000000000013";
|
|
605
|
+
const previousWindow = globalThis.window;
|
|
606
|
+
const previousDocument = globalThis.document;
|
|
607
|
+
globalThis.document = createMockDocument();
|
|
608
|
+
|
|
609
|
+
// Setup errors are shown in production, so a link anybody can craft must not be able to put a card on a healthy
|
|
610
|
+
// app's page: with a refresh-token ID that is not the current session's, every failure below stays an ordinary error
|
|
611
|
+
// that only captureError sees, even though the same URL from the right session would render a card.
|
|
612
|
+
const craftedRedirectUris = [
|
|
613
|
+
"https://evil.example.test/handler/account-settings", // untrusted
|
|
614
|
+
"not-a-url", // malformed
|
|
615
|
+
];
|
|
616
|
+
|
|
617
|
+
try {
|
|
618
|
+
for (const craftedRedirectUri of craftedRedirectUris) {
|
|
619
|
+
const currentUrl = new URL("https://target.example.test/nested-provider");
|
|
620
|
+
currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "attacker-chosen-refresh-token-id");
|
|
621
|
+
currentUrl.searchParams.set("redirect_uri", craftedRedirectUri);
|
|
622
|
+
currentUrl.searchParams.set("state", "nested-state");
|
|
623
|
+
currentUrl.searchParams.set("code_challenge", "nested-code-challenge");
|
|
624
|
+
|
|
625
|
+
globalThis.window = previousWindow;
|
|
626
|
+
const clientApp = new StackClientApp({
|
|
627
|
+
baseUrl: "http://localhost:12345",
|
|
628
|
+
projectId,
|
|
629
|
+
publishableClientKey: "stack-pk-test",
|
|
630
|
+
tokenStore: "memory",
|
|
631
|
+
redirectMethod: "window",
|
|
632
|
+
noAutomaticPrefetch: true,
|
|
633
|
+
devTool: false,
|
|
634
|
+
});
|
|
635
|
+
setMockWindow({ location: { href: currentUrl.toString() } });
|
|
636
|
+
Reflect.set(clientApp, "_fetchCurrentRefreshTokenIdIfSignedIn", async () => "this-apps-refresh-token-id");
|
|
637
|
+
Reflect.set(clientApp, "_isTrusted", async () => false);
|
|
638
|
+
|
|
639
|
+
const nestedAuthPromise = callProtectedMethod(clientApp, "_maybeHandleNestedCrossDomainAuth");
|
|
640
|
+
await expect(nestedAuthPromise).rejects.toThrowError(/does not match the requested refresh token ID/);
|
|
641
|
+
await expect(nestedAuthPromise).rejects.toSatisfy(
|
|
642
|
+
(error: unknown) => !HexclaveSetupError.isSetupError(error),
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
} finally {
|
|
646
|
+
globalThis.window = previousWindow;
|
|
647
|
+
globalThis.document = previousDocument;
|
|
648
|
+
}
|
|
649
|
+
});
|
|
650
|
+
|
|
502
651
|
it("passes the construction-time URL to the nested cross-domain auth handler", async () => {
|
|
503
652
|
const projectId = "00000000-0000-4000-8000-000000000009";
|
|
504
653
|
const previousWindow = globalThis.window;
|
|
@@ -529,6 +678,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
529
678
|
tokenStore: "memory",
|
|
530
679
|
redirectMethod: "window",
|
|
531
680
|
noAutomaticPrefetch: true,
|
|
681
|
+
devTool: false,
|
|
532
682
|
});
|
|
533
683
|
|
|
534
684
|
// Simulate consumeOAuthCallbackQueryParams stripping code+state before microtasks run.
|
|
@@ -597,6 +747,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
597
747
|
default: { type: "hosted" },
|
|
598
748
|
},
|
|
599
749
|
noAutomaticPrefetch: true,
|
|
750
|
+
devTool: false,
|
|
600
751
|
});
|
|
601
752
|
|
|
602
753
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
@@ -629,6 +780,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
629
780
|
signOut: { type: "hosted" },
|
|
630
781
|
},
|
|
631
782
|
noAutomaticPrefetch: true,
|
|
783
|
+
devTool: false,
|
|
632
784
|
});
|
|
633
785
|
const signOutSpy = vi.spyOn(clientApp, "signOut").mockRejectedValue(new Error("INTENTIONAL_TEST_ABORT"));
|
|
634
786
|
|
|
@@ -651,6 +803,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
651
803
|
default: { type: "hosted" },
|
|
652
804
|
},
|
|
653
805
|
noAutomaticPrefetch: true,
|
|
806
|
+
devTool: false,
|
|
654
807
|
});
|
|
655
808
|
|
|
656
809
|
expect(() => clientApp.urls.signIn).toThrowError(/app\.urls\.signIn cannot be used when this app is configured to use hosted components.*Use app\.redirectToSignIn\(\) instead/s);
|
|
@@ -669,6 +822,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
669
822
|
handler: "/custom-handler",
|
|
670
823
|
},
|
|
671
824
|
noAutomaticPrefetch: true,
|
|
825
|
+
devTool: false,
|
|
672
826
|
});
|
|
673
827
|
|
|
674
828
|
expect(clientApp.urls.signIn).toBe("/custom-handler/sign-in");
|
|
@@ -685,6 +839,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
685
839
|
default: { type: "hosted" },
|
|
686
840
|
},
|
|
687
841
|
noAutomaticPrefetch: true,
|
|
842
|
+
devTool: false,
|
|
688
843
|
});
|
|
689
844
|
const currentHref = "https://demo.stack-auth.com/settings?tab=profile";
|
|
690
845
|
|
|
@@ -754,6 +909,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
754
909
|
tokenStore: "memory",
|
|
755
910
|
redirectMethod: "window",
|
|
756
911
|
noAutomaticPrefetch: true,
|
|
912
|
+
devTool: false,
|
|
757
913
|
});
|
|
758
914
|
|
|
759
915
|
// Intermediate hops (MFA, magic link, OAuth round-trips, ...) dropped the query params.
|
|
@@ -822,6 +978,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
822
978
|
tokenStore: "memory",
|
|
823
979
|
redirectMethod: "window",
|
|
824
980
|
noAutomaticPrefetch: true,
|
|
981
|
+
devTool: false,
|
|
825
982
|
});
|
|
826
983
|
const crossDomainAuthorizeRedirect = "https://demo.example.test/handler/oauth-callback?code=minted-code&state=mirror-handoff-state";
|
|
827
984
|
const createCrossDomainAuthRedirectUrlSpy = vi
|
|
@@ -858,6 +1015,7 @@ describe("StackClientApp cross-domain auth", () => {
|
|
|
858
1015
|
tokenStore: "memory",
|
|
859
1016
|
redirectMethod: "none",
|
|
860
1017
|
noAutomaticPrefetch: true,
|
|
1018
|
+
devTool: false,
|
|
861
1019
|
});
|
|
862
1020
|
const oldAccessToken = createAccessTokenString("old-refresh-token-id");
|
|
863
1021
|
const refreshedOldAccessToken = createAccessTokenString("refreshed-old-refresh-token-id");
|
|
@@ -25,7 +25,7 @@ import { InternalSession } from "@hexclave/shared/dist/sessions";
|
|
|
25
25
|
import { decodeBase32, decodeBase64, encodeBase32, encodeBase64 } from "@hexclave/shared/dist/utils/bytes";
|
|
26
26
|
import { scrambleDuringCompileTime } from "@hexclave/shared/dist/utils/compile-time";
|
|
27
27
|
import { isBrowserLike } from "@hexclave/shared/dist/utils/env";
|
|
28
|
-
import { HexclaveAssertionError, captureError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
|
28
|
+
import { HexclaveAssertionError, HexclaveSetupError, captureError, throwErr } from "@hexclave/shared/dist/utils/errors";
|
|
29
29
|
import { parseJson } from "@hexclave/shared/dist/utils/json";
|
|
30
30
|
import { DependenciesMap } from "@hexclave/shared/dist/utils/maps";
|
|
31
31
|
import { ProviderType } from "@hexclave/shared/dist/utils/oauth";
|
|
@@ -76,6 +76,7 @@ import { useAsyncCache } from "./common";
|
|
|
76
76
|
import { mountClickmapOverlay } from "../../../../clickmap";
|
|
77
77
|
import { mountDevTool } from "../../../../dev-tool";
|
|
78
78
|
import { mountPushedConfigErrorOverlay } from "../../../../pushed-config-error-overlay";
|
|
79
|
+
import { showSetupErrorOverlay } from "../../../../setup-error-overlay";
|
|
79
80
|
|
|
80
81
|
let isReactServer = false;
|
|
81
82
|
import * as sc from "@hexclave/sc";
|
|
@@ -98,6 +99,67 @@ const nestedCrossDomainAuthQueryParams = {
|
|
|
98
99
|
afterCallbackRedirectUrl: oauthAfterCallbackRedirectUrlQueryParam,
|
|
99
100
|
} as const;
|
|
100
101
|
|
|
102
|
+
/**
|
|
103
|
+
* An auth flow can only ever send the user to a URL that the project trusts, so an untrusted one means the flow is dead
|
|
104
|
+
* until someone changes the project's configuration. Describe the problem in terms of that configuration rather than in
|
|
105
|
+
* terms of the flow's internals, which nobody outside this file knows about.
|
|
106
|
+
*/
|
|
107
|
+
function createUntrustedUrlError(options: {
|
|
108
|
+
/** Kept verbatim from the throw site, so the message stays greppable for whoever already has it in their logs. */
|
|
109
|
+
message: string,
|
|
110
|
+
url: string,
|
|
111
|
+
projectId: string,
|
|
112
|
+
}): HexclaveSetupError {
|
|
113
|
+
const origin = createUrlIfValid(options.url)?.origin ?? options.url;
|
|
114
|
+
return new HexclaveSetupError({
|
|
115
|
+
title: "A domain in your authentication flow is not one of your project's trusted domains",
|
|
116
|
+
message: options.message,
|
|
117
|
+
howToFix: [
|
|
118
|
+
`If ${origin} is yours, add it to your project's trusted domains in the Hexclave dashboard, under Domains.`,
|
|
119
|
+
"Protocol, domain and port must all match a trusted domain exactly; a wildcard like https://*.example.com matches only one subdomain level.",
|
|
120
|
+
`If ${origin} is not yours, do not add it \u2014 somebody may be trying to redirect your users' credentials to it.`,
|
|
121
|
+
],
|
|
122
|
+
extraData: {
|
|
123
|
+
url: options.url,
|
|
124
|
+
projectId: options.projectId,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function createUntrustedNestedCrossDomainUrlError(options: {
|
|
130
|
+
urlDescription: string,
|
|
131
|
+
url: string,
|
|
132
|
+
projectId: string,
|
|
133
|
+
}): HexclaveSetupError {
|
|
134
|
+
return createUntrustedUrlError({
|
|
135
|
+
message: `Nested cross-domain auth ${options.urlDescription} ${options.url} is not trusted.`,
|
|
136
|
+
url: options.url,
|
|
137
|
+
projectId: options.projectId,
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A URL that the auth flow cannot even parse is just as fatal as an untrusted one, and just as much a configuration
|
|
143
|
+
* problem: something in the developer's setup put a relative or malformed value where an absolute URL belongs.
|
|
144
|
+
*/
|
|
145
|
+
function createMalformedNestedCrossDomainUrlError(options: {
|
|
146
|
+
urlDescription: string,
|
|
147
|
+
url: string,
|
|
148
|
+
}): HexclaveSetupError {
|
|
149
|
+
return new HexclaveSetupError({
|
|
150
|
+
title: "A URL in your authentication flow is not a valid absolute URL",
|
|
151
|
+
message: `Nested cross-domain auth ${options.urlDescription} ${options.url} is not a valid absolute URL.`,
|
|
152
|
+
howToFix: [
|
|
153
|
+
"Handler URLs and after-auth redirect URLs must be absolute, including their protocol, like https://example.com/handler/oauth-callback.",
|
|
154
|
+
"Check the handler URLs configured for your project and the redirect URLs you pass to Hexclave's sign-in and sign-up methods.",
|
|
155
|
+
"If none of those look wrong, check whether a proxy or rewrite in front of your app is rewriting the authentication flow's query parameters.",
|
|
156
|
+
],
|
|
157
|
+
extraData: {
|
|
158
|
+
url: options.url,
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
101
163
|
function getRedirectHelperInstruction(handlerName: string): string {
|
|
102
164
|
if (handlerName === "handler") {
|
|
103
165
|
return "Use a page-specific redirect helper such as app.redirectToSignIn() instead.";
|
|
@@ -788,6 +850,9 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
788
850
|
// Startup auth transitions gate session finality, but malformed nested-auth URLs should
|
|
789
851
|
// not make every app-level session consumer fail while the tracker is cleaning up.
|
|
790
852
|
captureError("pending-auth-resolution-failed", error);
|
|
853
|
+
// Swallowing the error here means the flow just stops, with no sign of it on the page; setup errors
|
|
854
|
+
// (untrusted domains, most commonly) would otherwise be invisible to whoever has to fix them.
|
|
855
|
+
showSetupErrorOverlay(error); // THIS_LINE_PLATFORM js-like
|
|
791
856
|
}
|
|
792
857
|
})();
|
|
793
858
|
this._pendingAuthResolutionPromises.push(promise);
|
|
@@ -1014,24 +1079,52 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1014
1079
|
if ((currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.codeChallengeMethod) ?? "S256") !== "S256") {
|
|
1015
1080
|
throw new HexclaveAssertionError("Nested cross-domain auth only supports S256 PKCE");
|
|
1016
1081
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1082
|
+
const validateHandoffOwnership = async (): Promise<void> => {
|
|
1083
|
+
const currentRefreshTokenId = await this._fetchCurrentRefreshTokenIdIfSignedIn({ awaitPendingAuthResolutions: false });
|
|
1084
|
+
if (currentRefreshTokenId !== refreshTokenId) {
|
|
1085
|
+
throw new Error("Nested cross-domain auth source session does not match the requested refresh token ID.");
|
|
1086
|
+
}
|
|
1087
|
+
};
|
|
1088
|
+
// Everything derived from these query params is attacker-controllable, and setup errors are rendered as a card on
|
|
1089
|
+
// the page, so a failure may only become a displayable setup error once the handoff is known to belong to this
|
|
1090
|
+
// session. The check runs on the failure paths rather than up front so the happy path keeps making exactly the
|
|
1091
|
+
// requests it made before, and so an unowned handoff never costs the session lookup twice.
|
|
1092
|
+
const setupErrorIfHandoffIsOwned = async (setupError: HexclaveSetupError): Promise<HexclaveSetupError> => {
|
|
1093
|
+
await validateHandoffOwnership();
|
|
1094
|
+
return setupError;
|
|
1095
|
+
};
|
|
1096
|
+
const redirectUriUrl = isRelative(redirectUri) ? null : createUrlIfValid(redirectUri);
|
|
1097
|
+
if (redirectUriUrl == null) {
|
|
1098
|
+
throw await setupErrorIfHandoffIsOwned(createMalformedNestedCrossDomainUrlError({
|
|
1099
|
+
urlDescription: "redirect URI",
|
|
1100
|
+
url: redirectUri,
|
|
1101
|
+
}));
|
|
1019
1102
|
}
|
|
1020
|
-
const redirectUriUrl = new URL(redirectUri);
|
|
1021
1103
|
if (!await this._isTrusted(redirectUriUrl.toString())) {
|
|
1022
|
-
throw
|
|
1104
|
+
throw await setupErrorIfHandoffIsOwned(createUntrustedNestedCrossDomainUrlError({
|
|
1105
|
+
urlDescription: "redirect URI",
|
|
1106
|
+
url: redirectUri,
|
|
1107
|
+
projectId: this.projectId,
|
|
1108
|
+
}));
|
|
1023
1109
|
}
|
|
1024
1110
|
const afterCallbackRedirectUrlString = currentUrl.searchParams.get(nestedCrossDomainAuthQueryParams.afterCallbackRedirectUrl);
|
|
1025
1111
|
const afterCallbackRedirectUrl = afterCallbackRedirectUrlString == null
|
|
1026
1112
|
? redirectUriUrl
|
|
1027
|
-
:
|
|
1028
|
-
if (
|
|
1029
|
-
throw
|
|
1113
|
+
: createUrlIfValid(afterCallbackRedirectUrlString, redirectUriUrl);
|
|
1114
|
+
if (afterCallbackRedirectUrl == null) {
|
|
1115
|
+
throw await setupErrorIfHandoffIsOwned(createMalformedNestedCrossDomainUrlError({
|
|
1116
|
+
urlDescription: "after-callback redirect URL",
|
|
1117
|
+
url: afterCallbackRedirectUrlString ?? "",
|
|
1118
|
+
}));
|
|
1030
1119
|
}
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1120
|
+
if (!await this._isTrusted(afterCallbackRedirectUrl.toString())) {
|
|
1121
|
+
throw await setupErrorIfHandoffIsOwned(createUntrustedNestedCrossDomainUrlError({
|
|
1122
|
+
urlDescription: "after-callback redirect URL",
|
|
1123
|
+
url: afterCallbackRedirectUrlString ?? afterCallbackRedirectUrl.toString(),
|
|
1124
|
+
projectId: this.projectId,
|
|
1125
|
+
}));
|
|
1034
1126
|
}
|
|
1127
|
+
await validateHandoffOwnership();
|
|
1035
1128
|
await this._redirectTo({
|
|
1036
1129
|
url: await this._createCrossDomainAuthRedirectUrl({
|
|
1037
1130
|
redirectUri: redirectUriUrl.toString(),
|
|
@@ -1057,13 +1150,20 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
1057
1150
|
if (callbackUrlString == null) {
|
|
1058
1151
|
throw new HexclaveAssertionError("Nested cross-domain auth URL is missing callback URL");
|
|
1059
1152
|
}
|
|
1060
|
-
|
|
1061
|
-
|
|
1153
|
+
const callbackUrl = isRelative(callbackUrlString) ? null : createUrlIfValid(callbackUrlString);
|
|
1154
|
+
if (callbackUrl == null) {
|
|
1155
|
+
throw createMalformedNestedCrossDomainUrlError({
|
|
1156
|
+
urlDescription: "callback URL",
|
|
1157
|
+
url: callbackUrlString,
|
|
1158
|
+
});
|
|
1062
1159
|
}
|
|
1063
|
-
const callbackUrl = new URL(callbackUrlString);
|
|
1064
1160
|
const isTrusted = await this._isTrusted(callbackUrl.toString());
|
|
1065
1161
|
if (!isTrusted) {
|
|
1066
|
-
throw
|
|
1162
|
+
throw createUntrustedNestedCrossDomainUrlError({
|
|
1163
|
+
urlDescription: "callback URL",
|
|
1164
|
+
url: callbackUrlString,
|
|
1165
|
+
projectId: this.projectId,
|
|
1166
|
+
});
|
|
1067
1167
|
}
|
|
1068
1168
|
|
|
1069
1169
|
const afterCallbackRedirectUrl = new URL(currentUrl);
|
|
@@ -3016,7 +3116,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
3016
3116
|
}
|
|
3017
3117
|
protected async _redirectIfTrusted(url: string, options?: RedirectToOptions) {
|
|
3018
3118
|
if (!await this._isTrusted(url)) {
|
|
3019
|
-
throw
|
|
3119
|
+
throw createUntrustedUrlError({
|
|
3120
|
+
message: `Redirect URL ${url} is not trusted; should be relative.`,
|
|
3121
|
+
url,
|
|
3122
|
+
projectId: this.projectId,
|
|
3123
|
+
});
|
|
3020
3124
|
}
|
|
3021
3125
|
return await this._redirectTo({ url, ...options });
|
|
3022
3126
|
}
|
|
@@ -3097,7 +3201,11 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
|
|
|
3097
3201
|
})
|
|
3098
3202
|
: plan.url;
|
|
3099
3203
|
if (!await this._isTrusted(redirectUrl)) {
|
|
3100
|
-
throw
|
|
3204
|
+
throw createUntrustedUrlError({
|
|
3205
|
+
message: `Redirect URL ${redirectUrl} is not trusted; should be relative.`,
|
|
3206
|
+
url: redirectUrl,
|
|
3207
|
+
projectId: this.projectId,
|
|
3208
|
+
});
|
|
3101
3209
|
}
|
|
3102
3210
|
return redirectUrl;
|
|
3103
3211
|
}
|
|
@@ -17,6 +17,7 @@ async function plan(options: {
|
|
|
17
17
|
...options,
|
|
18
18
|
noRedirectBack: options.noRedirectBack === true,
|
|
19
19
|
localOAuthCallbackUrl,
|
|
20
|
+
rawHomeUrl: "/",
|
|
20
21
|
getCrossDomainHandoffParams: async () => {
|
|
21
22
|
throw new Error("Continuation-only redirects must not mint a new cross-domain handoff");
|
|
22
23
|
},
|