Fix redirect URL on devtool indicator

This commit is contained in:
Konstantin Wohlwend 2026-06-26 14:12:47 -07:00
parent 912eea4f7f
commit 73b8a0c27f
4 changed files with 74 additions and 12 deletions

View File

@ -2,7 +2,7 @@
import type { RequestLogEntry } from "@hexclave/shared/dist/interface/client-interface";
import { DEV_TOOL_ROOT_ID } from "@hexclave/shared/dist/utils/dev-tool";
import { runAsynchronously } from "@hexclave/shared/dist/utils/promises";
import { runAsynchronously, runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
import { isLocalhost } from "@hexclave/shared/dist/utils/urls";
import type { StackClientApp } from "../lib/hexclave-app";
import { envVars } from "../generated/env";
@ -1933,11 +1933,6 @@ function createComponentsTab(app: StackClientApp<true>): HTMLElement {
});
}
function getCompactUrl(url: string): string {
const resolved = new URL(url, window.location.origin);
return `${resolved.pathname}${resolved.search}${resolved.hash}`;
}
const sidebar = h('div', { className: 'sdt-pg-sidebar' });
const mainArea = h('div', { className: 'sdt-pg-main' });
@ -1988,7 +1983,6 @@ function createComponentsTab(app: StackClientApp<true>): HTMLElement {
const header = h('div', { className: 'sdt-pg-header' });
const headerTop = h('div', { className: 'sdt-pg-header-top' });
headerTop.appendChild(h('h3', { className: 'sdt-pg-title' }, `${page.label} Page`));
headerTop.appendChild(h('a', { href: page.url, target: '_blank', rel: 'noopener noreferrer', className: 'sdt-pg-title-url' }, getCompactUrl(page.url)));
if (page.versionStatus === 'outdated') {
headerTop.appendChild(h('span', { className: 'sdt-pg-badge sdt-pg-badge-outdated' }, 'Outdated'));
}
@ -2000,8 +1994,19 @@ function createComponentsTab(app: StackClientApp<true>): HTMLElement {
const openBtn = h('button', { className: 'sdt-pg-copy-btn sdt-pg-open-btn' });
setHtml(openBtn, 'Open <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M7 17L17 7"/><path d="M7 7h10v10"/></svg>');
openBtn.addEventListener('click', () => {
const resolved = new URL(page.url, window.location.origin);
window.open(resolved.toString(), '_blank', 'noopener,noreferrer');
const openedWindow = window.open('about:blank', '_blank');
if (openedWindow != null) {
openedWindow.opener = null;
}
runAsynchronouslyWithAlert(async () => {
const redirectUrl = await app[hexclaveAppInternalsSymbol].getRedirectToHandlerUrl(page.key);
const resolved = new URL(redirectUrl, window.location.origin);
if (openedWindow != null) {
openedWindow.location.replace(resolved.toString());
} else {
window.open(resolved.toString(), '_blank', 'noopener,noreferrer');
}
});
});
codeRow.appendChild(openBtn);
header.appendChild(codeRow);

View File

@ -1,6 +1,7 @@
import { describe, expect, it, vi } from "vitest";
import { AccessToken } from "@hexclave/shared/dist/sessions";
import { Store } from "@hexclave/shared/dist/utils/stores";
import { hexclaveAppInternalsSymbol } from "../../common";
import { StackClientApp } from "../interfaces/client-app";
function createAccessTokenString(refreshTokenId: string): string {
@ -49,6 +50,42 @@ function createMockDocument(): Document {
}
describe("StackClientApp cross-domain auth", () => {
it("exposes redirect-back-aware handler URLs for devtool previews", async () => {
const previousWindow = Reflect.get(globalThis, "window");
const hadPreviousWindow = Reflect.has(globalThis, "window");
Reflect.set(globalThis, "window", {
location: {
href: "http://localhost/music?track=1#song",
},
});
try {
const clientApp = new StackClientApp({
baseUrl: "http://localhost:12345",
projectId: "00000000-0000-4000-8000-000000000000",
publishableClientKey: "stack-pk-test",
tokenStore: "memory",
redirectMethod: "none",
urls: {
signIn: "/handler/sign-in",
},
noAutomaticPrefetch: true,
});
const redirectUrl = await clientApp[hexclaveAppInternalsSymbol].getRedirectToHandlerUrl("signIn");
const resolved = new URL(redirectUrl, "http://localhost");
expect(resolved.pathname).toBe("/handler/sign-in");
expect(resolved.searchParams.get("after_auth_return_to")).toBe("/music?track=1#song");
} finally {
if (hadPreviousWindow) {
Reflect.set(globalThis, "window", previousWindow);
} else {
Reflect.deleteProperty(globalThis, "window");
}
}
});
it("uses the fresh post-auth refresh token when minting a cross-domain handoff", async () => {
const freshAccessToken = createAccessTokenString("fresh-refresh-token-id");
const clientApp = new StackClientApp({

View File

@ -3069,6 +3069,20 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
overrideTokenStoreInit?: TokenStoreInit,
},
) {
await this._redirectTo({
url: await this._getRedirectToHandlerUrl(handlerName, options, internalOptions),
...options,
});
}
protected async _getRedirectToHandlerUrl(
handlerName: keyof HandlerUrls,
options?: RedirectToOptions,
internalOptions?: {
awaitPendingAuthResolutions?: boolean,
overrideTokenStoreInit?: TokenStoreInit,
},
): Promise<string> {
const rawUrls = getUrls(this._urlOptions, { projectId: this.projectId });
const rawHandlerUrl = rawUrls[handlerName];
if (!rawHandlerUrl) {
@ -3096,8 +3110,7 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
awaitPendingAuthResolutions: internalOptions?.awaitPendingAuthResolutions,
overrideTokenStoreInit: internalOptions?.overrideTokenStoreInit,
});
await this._redirectTo({ url: crossDomainRedirectUrl, ...options });
return;
return crossDomainRedirectUrl;
}
const redirectUrl = currentUrl != null && handlerName !== "signOut" && handlerName !== "afterSignOut" && handlerName !== "oauthCallback"
@ -3108,7 +3121,10 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
overrideTokenStoreInit: internalOptions?.overrideTokenStoreInit,
})
: plan.url;
await this._redirectIfTrusted(redirectUrl, options);
if (!await this._isTrusted(redirectUrl)) {
throw new Error(`Redirect URL ${redirectUrl} is not trusted; should be relative.`);
}
return redirectUrl;
}
protected _redirectToHandlerDuringRender(handlerName: keyof HandlerUrls, options?: RedirectToOptions): boolean {
@ -4190,6 +4206,9 @@ export class _HexclaveClientAppImplIncomplete<HasTokenStore extends boolean, Pro
redirectToUrl: async (url: string | URL, options?: { replace?: boolean }) => {
await this._redirectTo({ url, ...options });
},
getRedirectToHandlerUrl: async (handlerName: keyof HandlerUrls, options?: RedirectToOptions) => {
return await this._getRedirectToHandlerUrl(handlerName, options);
},
redirectToHandler: async (handlerName: keyof HandlerUrls, options?: RedirectToOptions) => {
await this._redirectToHandler(handlerName, options);
},

View File

@ -134,6 +134,7 @@ export type StackClientApp<HasTokenStore extends boolean = boolean, ProjectId ex
getUrls(): Readonly<ResolvedHandlerUrls>,
getRedirectMethod(): RedirectMethod,
redirectToUrl(url: string | URL, options?: { replace?: boolean }): Promise<void>,
getRedirectToHandlerUrl(handlerName: keyof HandlerUrls, options?: RedirectToOptions): Promise<string>,
redirectToHandler(handlerName: keyof HandlerUrls, options?: RedirectToOptions): Promise<void>,
signInWithTokens(tokens: { accessToken: string, refreshToken: string }): Promise<void>,
awaitPendingAuthResolutions(): Promise<void>,