mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
Merge branch 'dev' into remote-ssh-local-dashboard-support
This commit is contained in:
commit
04113b5352
6
.github/workflows/npm-publish.yaml
vendored
6
.github/workflows/npm-publish.yaml
vendored
@ -36,12 +36,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Clean
|
||||
run: pnpm run clean
|
||||
|
||||
- name: Install dependencies (after clean)
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build packages
|
||||
run: pnpm build:packages
|
||||
|
||||
|
||||
@ -230,7 +230,10 @@ async function createDnsimpleZone(subdomain: string): Promise<DnsimpleZone> {
|
||||
async function deleteDnsimpleZoneByName(zoneName: string): Promise<{ status: "deleted" | "not_found" }> {
|
||||
const dnsimpleBaseUrl = getDnsimpleBaseUrl();
|
||||
const dnsimpleAccountId = getDnsimpleAccountId();
|
||||
const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/zones/${encodeURIComponent(zoneName)}`, {
|
||||
// A zone is created by creating a domain (see createDnsimpleZone), and DNSimple has no
|
||||
// DELETE endpoint for zones — the symmetric teardown is to delete the owning domain,
|
||||
// which removes its hosted DNS zone too.
|
||||
const response = await fetch(`${dnsimpleBaseUrl}/${encodeURIComponent(dnsimpleAccountId)}/domains/${encodeURIComponent(zoneName)}`, {
|
||||
method: "DELETE",
|
||||
headers: getDnsimpleHeaders(),
|
||||
});
|
||||
@ -239,7 +242,7 @@ async function deleteDnsimpleZoneByName(zoneName: string): Promise<{ status: "de
|
||||
}
|
||||
if (!response.ok) {
|
||||
const responseBody = await response.text();
|
||||
throw new HexclaveAssertionError(`DNSimple returned non-OK status when deleting managed email zone`, {
|
||||
throw new HexclaveAssertionError(`DNSimple returned non-OK status when deleting managed email domain`, {
|
||||
zoneName,
|
||||
status: response.status,
|
||||
responseBody,
|
||||
@ -643,10 +646,19 @@ export async function applyManagedEmailProvider(options: {
|
||||
if (!domain || domain.tenancyId !== options.tenancy.id || !domain.isActive) {
|
||||
throw new StatusError(404, "Managed domain not found for this project/branch");
|
||||
}
|
||||
if (domain.status === "applied") {
|
||||
// Only short-circuit when the rendered config already points at this exact domain.
|
||||
// If the row is marked "applied" but the config has since drifted (e.g. the user
|
||||
// switched to a different provider and back), we must fall through and re-provision
|
||||
// so the config is rewritten with the full, valid managed server settings — otherwise
|
||||
// the config is left missing required fields like password/senderName.
|
||||
if (domain.status === "applied" && isManagedEmailDomainInUseForTenancy({
|
||||
tenancy: options.tenancy,
|
||||
subdomain: domain.subdomain,
|
||||
senderLocalPart: domain.senderLocalPart,
|
||||
})) {
|
||||
return { status: "applied" };
|
||||
}
|
||||
if (domain.status !== "verified") {
|
||||
if (domain.status !== "verified" && domain.status !== "applied") {
|
||||
throw new StatusError(409, "Managed domain is not verified yet");
|
||||
}
|
||||
|
||||
|
||||
@ -310,6 +310,7 @@ function ManagedDomainSetupDialog(props: {
|
||||
onOpenChange: (open: boolean) => void,
|
||||
initialState: SetupState | null,
|
||||
onCompleted: () => void,
|
||||
onApply: (domainId: string) => void,
|
||||
}) {
|
||||
const stackAdminApp = useAdminApp();
|
||||
const { toast } = useToast();
|
||||
@ -416,21 +417,14 @@ function ManagedDomainSetupDialog(props: {
|
||||
}
|
||||
}, [setupState, stackAdminApp, toast, props]);
|
||||
|
||||
const handleApply = useCallback(async () => {
|
||||
const handleApply = useCallback(() => {
|
||||
if (!setupState) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await stackAdminApp.applyManagedEmailProvider({ domainId: setupState.domainId });
|
||||
toast({ title: "Domain applied", description: `Sending emails from ${setupState.senderLocalPart}@${setupState.subdomain}.`, variant: "success" });
|
||||
props.onCompleted();
|
||||
props.onOpenChange(false);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Could not apply domain");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}, [setupState, stackAdminApp, toast, props]);
|
||||
// Don't apply immediately — stage the selection so the parent surfaces the
|
||||
// standard "unsaved changes" save bar, consistent with the other providers.
|
||||
// The actual applyManagedEmailProvider call happens when the user hits Save.
|
||||
props.onApply(setupState.domainId);
|
||||
props.onOpenChange(false);
|
||||
}, [setupState, props]);
|
||||
|
||||
const steps = [
|
||||
{ n: 1 as const, title: "Your domain" },
|
||||
@ -698,8 +692,7 @@ function ManagedDomainSetupDialog(props: {
|
||||
{stage === 3 && (
|
||||
<DesignButton
|
||||
size="sm"
|
||||
loading={submitting}
|
||||
onClick={() => runAsynchronouslyWithAlert(handleApply)}
|
||||
onClick={() => handleApply()}
|
||||
>
|
||||
Use this domain
|
||||
</DesignButton>
|
||||
@ -731,6 +724,9 @@ export function DomainSettings() {
|
||||
const [loadingDomains, setLoadingDomains] = useState(false);
|
||||
const [dialog, setDialog] = useState<{ initialState: SetupState | null } | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState<ManagedDomain | null>(null);
|
||||
// The managed domain the user has staged to apply but not yet saved. `null` means
|
||||
// "no pending change" — the active domain (from config) is used as the baseline.
|
||||
const [draftManagedDomainId, setDraftManagedDomainId] = useState<string | null>(null);
|
||||
|
||||
const refreshDomains = useCallback(async () => {
|
||||
setLoadingDomains(true);
|
||||
@ -748,6 +744,15 @@ export function DomainSettings() {
|
||||
}
|
||||
}, [serverType, refreshDomains]);
|
||||
|
||||
// The managed domain currently applied per the saved config (the baseline).
|
||||
const activeManagedDomainId = emailConfig.provider === "managed" && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart
|
||||
? domains.find((d) =>
|
||||
d.subdomain === emailConfig.managedSubdomain
|
||||
&& d.senderLocalPart === emailConfig.managedSenderLocalPart
|
||||
)?.domainId ?? null
|
||||
: null;
|
||||
const isManagedDirty = serverType === "managed" && draftManagedDomainId != null && draftManagedDomainId !== activeManagedDomainId;
|
||||
|
||||
const isShared = serverType === "shared";
|
||||
|
||||
const visibleSenderFields = serverType === "resend" || serverType === "standard";
|
||||
@ -768,6 +773,7 @@ export function DomainSettings() {
|
||||
|
||||
const isDirty = useMemo(() => {
|
||||
if (serverType !== savedServerType) return true;
|
||||
if (isManagedDirty) return true;
|
||||
const keys = new Set<string>();
|
||||
if (visibleSenderFields) {
|
||||
keys.add("senderEmail");
|
||||
@ -778,7 +784,7 @@ export function DomainSettings() {
|
||||
if ((formValues[k] || "") !== (savedValues[k] || "")) return true;
|
||||
}
|
||||
return false;
|
||||
}, [serverType, savedServerType, formValues, savedValues, visibleSenderFields, configFields]);
|
||||
}, [serverType, savedServerType, isManagedDirty, formValues, savedValues, visibleSenderFields, configFields]);
|
||||
|
||||
const updateField = useCallback((key: string, value: string) => {
|
||||
setFormValues(prev => ({ ...prev, [key]: value }));
|
||||
@ -793,6 +799,7 @@ export function DomainSettings() {
|
||||
const handleDiscard = useCallback(() => {
|
||||
setServerType(savedServerType);
|
||||
setFormValues(savedValues);
|
||||
setDraftManagedDomainId(null);
|
||||
setSaveError(null);
|
||||
}, [savedServerType, savedValues]);
|
||||
|
||||
@ -809,6 +816,22 @@ export function DomainSettings() {
|
||||
pushable: false,
|
||||
});
|
||||
toast({ title: "Email server updated", variant: "success" });
|
||||
} else if (serverType === "managed") {
|
||||
const domainId = draftManagedDomainId;
|
||||
if (!domainId) throwErr("Select a domain to use");
|
||||
const selected = domains.find((d) => d.domainId === domainId);
|
||||
if (!selected) throwErr("Selected domain is no longer available");
|
||||
// applyManagedEmailProvider provisions the scoped sending key and writes the complete,
|
||||
// valid managed config server-side — it owns the managed config entirely. We must NOT
|
||||
// write a partial config from here: a partial managed override renders to a config
|
||||
// missing required fields (password/senderName), which breaks every project read.
|
||||
// The empty updateConfig is a no-op write whose only purpose is to refresh the reactive
|
||||
// config cache (via _refreshProjectConfig) so the UI reflects the newly-active domain.
|
||||
await stackAdminApp.applyManagedEmailProvider({ domainId });
|
||||
await updateConfig({ adminApp: stackAdminApp, configUpdate: {}, pushable: false });
|
||||
setDraftManagedDomainId(null);
|
||||
await refreshDomains();
|
||||
toast({ title: "Domain applied", description: `Sending emails from ${selected.senderLocalPart}@${selected.subdomain}.`, variant: "success" });
|
||||
} else {
|
||||
const requireField = (key: string, label: string): string => {
|
||||
const val = formValues[key];
|
||||
@ -875,7 +898,7 @@ export function DomainSettings() {
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [serverType, formValues, stackAdminApp, updateConfig, toast]);
|
||||
}, [serverType, formValues, stackAdminApp, updateConfig, toast, draftManagedDomainId, domains, refreshDomains]);
|
||||
|
||||
if (isEmulator) {
|
||||
return (
|
||||
@ -898,12 +921,15 @@ export function DomainSettings() {
|
||||
...configFields.filter(f => !(formValues[f.key] || "").trim()).map(f => f.label),
|
||||
] : [];
|
||||
|
||||
const canSave = isDirty && !emailFormatError && missingRequiredFields.length === 0;
|
||||
const activeManagedDomainId = emailConfig.provider === "managed" && emailConfig.managedSubdomain && emailConfig.managedSenderLocalPart
|
||||
? domains.find((d) =>
|
||||
d.subdomain === emailConfig.managedSubdomain
|
||||
&& d.senderLocalPart === emailConfig.managedSenderLocalPart
|
||||
)?.domainId
|
||||
// Managed requires a staged domain selection before it can be saved.
|
||||
const canSave = isDirty && !emailFormatError && missingRequiredFields.length === 0
|
||||
// For managed, also wait until the domains list has finished loading: a just-staged
|
||||
// domain (e.g. one added via the setup dialog, which kicks off refreshDomains) may not
|
||||
// be in `domains` yet, and saving early would spuriously fail with "Selected domain is
|
||||
// no longer available". loadingDomains flips true synchronously when a refresh starts.
|
||||
&& (serverType !== "managed" || (draftManagedDomainId != null && !loadingDomains));
|
||||
const draftManagedDomain = draftManagedDomainId != null
|
||||
? domains.find((d) => d.domainId === draftManagedDomainId) ?? null
|
||||
: null;
|
||||
|
||||
return (
|
||||
@ -971,14 +997,27 @@ export function DomainSettings() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
{isDirty && serverType !== "managed" && (
|
||||
{isDirty && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/[0.06] p-3 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 text-sm text-foreground">
|
||||
<WarningDiamond className="h-4 w-4 text-amber-600 dark:text-amber-400 shrink-0" weight="fill" />
|
||||
<span>
|
||||
Unsaved changes — previewing{" "}
|
||||
<span className="font-semibold">{PROVIDERS.find((p) => p.value === serverType)?.label}</span>.
|
||||
Changes don't take effect until you save.
|
||||
{serverType === "managed" ? (
|
||||
draftManagedDomain ? (
|
||||
<>
|
||||
Unsaved changes — save to start sending from{" "}
|
||||
<span className="font-semibold">{draftManagedDomain.senderLocalPart}@{draftManagedDomain.subdomain}</span>.
|
||||
</>
|
||||
) : (
|
||||
<>Switching to a managed domain — select a verified domain below, then save.</>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
Unsaved changes — previewing{" "}
|
||||
<span className="font-semibold">{PROVIDERS.find((p) => p.value === serverType)?.label}</span>.
|
||||
Changes don't take effect until you save.
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
@ -1030,11 +1069,12 @@ export function DomainSettings() {
|
||||
) : (
|
||||
<div className="rounded-lg border border-border/60 divide-y divide-border/50 overflow-hidden">
|
||||
{domains.map((d) => {
|
||||
const isInUse = d.domainId === activeManagedDomainId;
|
||||
const isReadyButUnused = !isInUse && (d.status === "verified" || d.status === "applied");
|
||||
const isActiveSaved = d.domainId === activeManagedDomainId;
|
||||
const isDraftSelected = draftManagedDomainId != null && d.domainId === draftManagedDomainId && draftManagedDomainId !== activeManagedDomainId;
|
||||
const isReadyButUnused = !isActiveSaved && !isDraftSelected && (d.status === "verified" || d.status === "applied");
|
||||
const isPending = d.status === "pending_dns" || d.status === "pending_verification" || d.status === "failed";
|
||||
const displayStatus: ManagedDomainStatus = isInUse ? "applied" : isReadyButUnused ? "verified" : d.status;
|
||||
const displayLabel = isInUse ? "Active" : MANAGED_DOMAIN_STATUS_LABELS[displayStatus];
|
||||
const displayStatus: ManagedDomainStatus = isActiveSaved ? "applied" : isReadyButUnused ? "verified" : d.status;
|
||||
const displayLabel = isActiveSaved ? "Active" : MANAGED_DOMAIN_STATUS_LABELS[displayStatus];
|
||||
return (
|
||||
<div key={d.domainId} className="flex items-center justify-between px-4 py-3 gap-3 min-w-0">
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
@ -1044,30 +1084,41 @@ export function DomainSettings() {
|
||||
{d.senderLocalPart}@{d.subdomain}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5">
|
||||
{isInUse ? "In use for this project" : "Not in use"}
|
||||
{isActiveSaved ? "In use for this project" : isDraftSelected ? "Selected — save to apply" : "Not in use"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className={cn(
|
||||
"text-[11px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap",
|
||||
MANAGED_DOMAIN_STATUS_COLORS[displayStatus],
|
||||
)}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
{isDraftSelected ? (
|
||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap text-amber-700 dark:text-amber-400 bg-amber-500/10">
|
||||
Selected
|
||||
</span>
|
||||
) : (
|
||||
<span className={cn(
|
||||
"text-[11px] font-medium px-2 py-0.5 rounded-full whitespace-nowrap",
|
||||
MANAGED_DOMAIN_STATUS_COLORS[displayStatus],
|
||||
)}>
|
||||
{displayLabel}
|
||||
</span>
|
||||
)}
|
||||
{isReadyButUnused && (
|
||||
<DesignButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => runAsynchronouslyWithAlert(async () => {
|
||||
await stackAdminApp.applyManagedEmailProvider({ domainId: d.domainId });
|
||||
toast({ title: "Domain applied", description: `Sending from ${d.senderLocalPart}@${d.subdomain}.`, variant: "success" });
|
||||
await refreshDomains();
|
||||
})}
|
||||
onClick={() => setDraftManagedDomainId(d.domainId)}
|
||||
>
|
||||
Use this domain
|
||||
</DesignButton>
|
||||
)}
|
||||
{isDraftSelected && (
|
||||
<DesignButton
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => setDraftManagedDomainId(null)}
|
||||
>
|
||||
Deselect
|
||||
</DesignButton>
|
||||
)}
|
||||
{isPending && (
|
||||
<DesignButton
|
||||
size="sm"
|
||||
@ -1083,7 +1134,7 @@ export function DomainSettings() {
|
||||
View DNS
|
||||
</DesignButton>
|
||||
)}
|
||||
{!isInUse && (
|
||||
{!isActiveSaved && (
|
||||
<button
|
||||
type="button"
|
||||
title="Remove domain"
|
||||
@ -1174,6 +1225,11 @@ export function DomainSettings() {
|
||||
onOpenChange={(o) => { if (!o) setDialog(null); }}
|
||||
initialState={dialog?.initialState ?? null}
|
||||
onCompleted={() => { runAsynchronouslyWithAlert(refreshDomains); }}
|
||||
onApply={(domainId) => {
|
||||
setServerType("managed");
|
||||
setDraftManagedDomainId(domainId);
|
||||
runAsynchronouslyWithAlert(refreshDomains);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ActionDialog
|
||||
@ -1185,9 +1241,11 @@ export function DomainSettings() {
|
||||
label: "Remove",
|
||||
onClick: async () => {
|
||||
if (!confirmDelete) return;
|
||||
const removed = confirmDelete;
|
||||
runAsynchronouslyWithAlert(async () => {
|
||||
await stackAdminApp.deleteManagedEmailDomain({ resendDomainId: confirmDelete.domainId });
|
||||
toast({ title: "Domain removed", description: `${confirmDelete.senderLocalPart}@${confirmDelete.subdomain} was removed.`, variant: "success" });
|
||||
await stackAdminApp.deleteManagedEmailDomain({ resendDomainId: removed.domainId });
|
||||
toast({ title: "Domain removed", description: `${removed.senderLocalPart}@${removed.subdomain} was removed.`, variant: "success" });
|
||||
if (draftManagedDomainId === removed.domainId) setDraftManagedDomainId(null);
|
||||
await refreshDomains();
|
||||
});
|
||||
},
|
||||
|
||||
@ -529,6 +529,7 @@ it("starts nested cross-domain auth from the target domain", async ({ expect })
|
||||
state: "nested-state",
|
||||
codeChallenge: "nested-code-challenge",
|
||||
});
|
||||
vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true);
|
||||
|
||||
globalThis.document = createMockDocument();
|
||||
globalThis.window = {
|
||||
@ -559,6 +560,74 @@ it("starts nested cross-domain auth from the target domain", async ({ expect })
|
||||
});
|
||||
});
|
||||
|
||||
it("carries hosted sign-in return state on the nested OAuth redirect URI", async ({ expect }) => {
|
||||
await withHostedDomainSuffix(async () => {
|
||||
const projectId = "14141414-1414-4414-8414-141414141414";
|
||||
const clientApp = createClientApp(projectId);
|
||||
const sourceRefreshTokenId = "source-session";
|
||||
const handoffState = "state-from-hosted-sign-in";
|
||||
const handoffCodeChallenge = "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~";
|
||||
const returnToAppUrl = `${localRedirectUrl}/handler/sign-in`;
|
||||
const redirectBackUrl = new URL(`${localRedirectUrl}/handler/sign-in`);
|
||||
redirectBackUrl.searchParams.set("hexclave_cross_domain_auth", "1");
|
||||
redirectBackUrl.searchParams.set("hexclave_cross_domain_state", handoffState);
|
||||
redirectBackUrl.searchParams.set("hexclave_cross_domain_code_challenge", handoffCodeChallenge);
|
||||
redirectBackUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", returnToAppUrl);
|
||||
const currentUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`);
|
||||
currentUrl.searchParams.set("after_auth_return_to", redirectBackUrl.toString());
|
||||
currentUrl.searchParams.set("hexclave_cross_domain_state", handoffState);
|
||||
currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", handoffCodeChallenge);
|
||||
currentUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", returnToAppUrl);
|
||||
currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", sourceRefreshTokenId);
|
||||
currentUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", `${localRedirectUrl}/handler/sign-in`);
|
||||
const expectedAfterCallbackRedirectUrl = new URL(currentUrl);
|
||||
expectedAfterCallbackRedirectUrl.searchParams.delete("stack_nested_cross_domain_auth_refresh_token_id");
|
||||
expectedAfterCallbackRedirectUrl.searchParams.delete("stack_nested_cross_domain_auth_callback_url");
|
||||
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
let redirectedUrl = "";
|
||||
|
||||
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
|
||||
vi.spyOn(clientApp as any, "_getCrossDomainHandoffParamsForRedirect").mockResolvedValue({
|
||||
state: "nested-state",
|
||||
codeChallenge: "nested-code-challenge",
|
||||
});
|
||||
vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true);
|
||||
|
||||
globalThis.document = createMockDocument();
|
||||
globalThis.window = {
|
||||
location: {
|
||||
href: currentUrl.toString(),
|
||||
replace: (url: string) => {
|
||||
redirectedUrl = url;
|
||||
throw new Error("INTENTIONAL_TEST_ABORT");
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
try {
|
||||
await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
|
||||
} finally {
|
||||
globalThis.window = previousWindow;
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
|
||||
const redirectUrl = new URL(redirectedUrl);
|
||||
expect(redirectUrl.pathname).toBe(new URL(`${localRedirectUrl}/handler/sign-in`).pathname);
|
||||
expect(redirectUrl.searchParams.get("after_callback_redirect_url")).toBe(expectedAfterCallbackRedirectUrl.toString());
|
||||
const redirectUri = new URL(redirectUrl.searchParams.get("redirect_uri") ?? "");
|
||||
expect(redirectUri.origin).toBe(`https://${projectId}.example-stack-hosted.test`);
|
||||
expect(redirectUri.pathname).toBe("/handler/sign-in");
|
||||
expect(redirectUrl.searchParams.get("state")).toBe("nested-state");
|
||||
expect(redirectUrl.searchParams.get("code_challenge")).toBe("nested-code-challenge");
|
||||
expect(redirectUri.searchParams.get("after_auth_return_to")).toBe(redirectBackUrl.toString());
|
||||
expect(redirectUri.searchParams.get("hexclave_cross_domain_state")).toBe(handoffState);
|
||||
expect(redirectUri.searchParams.get("hexclave_cross_domain_code_challenge")).toBe(handoffCodeChallenge);
|
||||
expect(redirectUri.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe(returnToAppUrl);
|
||||
});
|
||||
});
|
||||
|
||||
it("continues nested cross-domain auth on the source domain", async ({ expect }) => {
|
||||
await withHostedDomainSuffix(async () => {
|
||||
const projectId = "88888888-8888-4888-8888-888888888888";
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"clean": "rimraf .next && rimraf node_modules",
|
||||
"dev": "NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT=${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}42 pnpm -w run cli -- dev --config-file=./hexclave.config.ts -- pnpm --dir examples/demo run dev:inner",
|
||||
"dev": "NEXT_PUBLIC_HEXCLAVE_LOCAL_DASHBOARD_PORT=${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}42 node scripts/dev-with-retry.mjs",
|
||||
"dev:inner": "next dev --turbopack --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}03",
|
||||
"build": "next build",
|
||||
"start": "next start --port ${NEXT_PUBLIC_HEXCLAVE_PORT_PREFIX:-81}03",
|
||||
|
||||
119
examples/demo/scripts/dev-with-retry.mjs
Normal file
119
examples/demo/scripts/dev-with-retry.mjs
Normal file
@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Resilient wrapper for the demo dev command.
|
||||
//
|
||||
// The demo dev flow runs `pnpm -w run cli`, which internally builds the CLI
|
||||
// package (and its dependency, dashboard build:rde-standalone). If that build
|
||||
// fails, this process would normally exit, and because the root dev script uses
|
||||
// `concurrently -k`, the entire dev server would die.
|
||||
//
|
||||
// This wrapper catches non-zero exits and watches for file changes in the
|
||||
// dashboard and packages directories before retrying, so a transient build
|
||||
// failure doesn't tear down the whole dev server.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { watch } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { setTimeout as sleep } from "node:timers/promises";
|
||||
|
||||
const scriptDir = import.meta.dirname;
|
||||
const demoRoot = resolve(scriptDir, "..");
|
||||
const repoRoot = resolve(demoRoot, "../..");
|
||||
|
||||
const LOG_PREFIX = "[Hexclave dev-retry] ";
|
||||
const RETRY_DEBOUNCE_MS = 2_000;
|
||||
|
||||
function log(message) {
|
||||
console.error(`${LOG_PREFIX}${message}`);
|
||||
}
|
||||
|
||||
function runCliDev() {
|
||||
return new Promise((resolvePromise, reject) => {
|
||||
const child = spawn("pnpm", [
|
||||
"-w", "run", "cli", "--",
|
||||
"dev",
|
||||
"--config-file=./hexclave.config.ts",
|
||||
"--",
|
||||
"pnpm", "--dir", "examples/demo", "run", "dev:inner",
|
||||
], {
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
|
||||
let signalled = false;
|
||||
|
||||
const forwardSigint = () => { signalled = true; child.kill("SIGINT"); };
|
||||
const forwardSigterm = () => { signalled = true; child.kill("SIGTERM"); };
|
||||
process.on("SIGINT", forwardSigint);
|
||||
process.on("SIGTERM", forwardSigterm);
|
||||
|
||||
child.on("close", (code) => {
|
||||
process.off("SIGINT", forwardSigint);
|
||||
process.off("SIGTERM", forwardSigterm);
|
||||
resolvePromise({ code: code ?? 1, signalled });
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
process.off("SIGINT", forwardSigint);
|
||||
process.off("SIGTERM", forwardSigterm);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForFileChanges() {
|
||||
return new Promise((resolvePromise) => {
|
||||
const watchDirs = [
|
||||
join(repoRoot, "apps", "dashboard"),
|
||||
join(repoRoot, "packages"),
|
||||
];
|
||||
const watchers = [];
|
||||
let resolved = false;
|
||||
|
||||
const done = () => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
for (const w of watchers) {
|
||||
try { w.close(); } catch { /* ignore */ }
|
||||
}
|
||||
resolvePromise();
|
||||
};
|
||||
|
||||
for (const dir of watchDirs) {
|
||||
try {
|
||||
const w = watch(dir, { recursive: true }, done);
|
||||
w.on("error", () => { /* ignore watch errors */ });
|
||||
watchers.push(w);
|
||||
} catch {
|
||||
// directory might not exist yet
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: if no watchers could be set up, resolve after a timeout so we
|
||||
// don't block forever.
|
||||
if (watchers.length === 0) {
|
||||
log("Could not set up file watchers. Will retry after a delay.");
|
||||
setTimeout(done, 10_000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
while (true) {
|
||||
const { code, signalled } = await runCliDev();
|
||||
|
||||
if (signalled || code === 0) {
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
log(`Dev command exited with code ${code}. Watching for file changes before retrying...`);
|
||||
await waitForFileChanges();
|
||||
log(`Change detected. Retrying in ${RETRY_DEBOUNCE_MS / 1000}s...`);
|
||||
await sleep(RETRY_DEBOUNCE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
@ -5,7 +5,7 @@ import { FilterUndefined, filterUndefined } from "@hexclave/shared/dist/utils/ob
|
||||
import { runAsynchronouslyWithAlert } from "@hexclave/shared/dist/utils/promises";
|
||||
import { getRelativePart } from "@hexclave/shared/dist/utils/urls";
|
||||
import { notFound, redirect, RedirectType, usePathname, useSearchParams } from 'next/navigation'; // THIS_LINE_PLATFORM next
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useEffect, useMemo, useSyncExternalStore } from 'react';
|
||||
/* IF_PLATFORM react
|
||||
import { useRef } from 'react';
|
||||
// END_PLATFORM */
|
||||
@ -225,6 +225,7 @@ function renderComponent(props: {
|
||||
export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps> & { location?: string }) {
|
||||
// Use hooks to get app
|
||||
const stackApp = useStackApp();
|
||||
const clientOrigin = useClientOriginAfterHydration();
|
||||
|
||||
// IF_PLATFORM next
|
||||
const pathname = usePathname();
|
||||
@ -270,7 +271,7 @@ export function StackHandlerClient(props: BaseHandlerProps & Partial<RouteProps>
|
||||
return !isLocalHandlerUrlTarget({
|
||||
targetUrl: url,
|
||||
handlerPath,
|
||||
currentOrigin: typeof window === "undefined" ? undefined : window.location.origin,
|
||||
currentOrigin: clientOrigin,
|
||||
});
|
||||
};
|
||||
|
||||
@ -362,3 +363,13 @@ function filterUndefinedINU<T extends {}>(value: T | undefined): FilterUndefined
|
||||
function toAbsoluteOrRelativeRedirectTarget(url: URL): string {
|
||||
return url.origin === "http://example.com" ? getRelativePart(url) : url.toString();
|
||||
}
|
||||
|
||||
function useClientOriginAfterHydration(): string | undefined {
|
||||
// The first hydrated render must match SSR. After hydration, React re-checks
|
||||
// the snapshot and lets us distinguish same-path cross-origin handler URLs.
|
||||
return useSyncExternalStore(
|
||||
() => () => {},
|
||||
() => window.location.origin,
|
||||
() => undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { StackClientApp } from "../interfaces/client-app";
|
||||
|
||||
function createMockDocument(): Document {
|
||||
const cookieJar = new Map<string, string>();
|
||||
return {
|
||||
get cookie() {
|
||||
return [...cookieJar.entries()].map(([key, value]) => `${key}=${value}`).join("; ");
|
||||
},
|
||||
set cookie(str: string) {
|
||||
const [nameValue] = str.split(";");
|
||||
const eqIndex = nameValue.indexOf("=");
|
||||
if (eqIndex < 0) return;
|
||||
cookieJar.set(nameValue.slice(0, eqIndex).trim(), nameValue.slice(eqIndex + 1).trim());
|
||||
},
|
||||
createElement: () => ({}),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("StackClientApp cross-domain auth", () => {
|
||||
it("uses the fresh post-auth refresh token when minting a cross-domain handoff", async () => {
|
||||
const clientApp = new StackClientApp({
|
||||
@ -58,4 +74,64 @@ describe("StackClientApp cross-domain auth", () => {
|
||||
|
||||
expect(capturedRefreshTokens).toEqual(["fresh-refresh-token"]);
|
||||
});
|
||||
|
||||
it("uses a fresh nested OAuth state while preserving the outer cross-domain return state", async () => {
|
||||
const projectId = "00000000-0000-4000-8000-000000000002";
|
||||
const clientApp = new StackClientApp({
|
||||
baseUrl: "http://localhost:12345",
|
||||
projectId,
|
||||
publishableClientKey: "stack-pk-test",
|
||||
tokenStore: "memory",
|
||||
redirectMethod: "window",
|
||||
urls: {
|
||||
default: { type: "hosted" },
|
||||
},
|
||||
noAutomaticPrefetch: true,
|
||||
});
|
||||
const outerState = "outer-cross-domain-state";
|
||||
const outerCodeChallenge = "abcdefghijklmnopqrstuvwxyzABCDEFG_0123456789-._~";
|
||||
const currentUrl = new URL(`https://${projectId}.example-stack-hosted.test/handler/sign-in`);
|
||||
currentUrl.searchParams.set("after_auth_return_to", `https://demo.stack-auth.com/?hexclave_cross_domain_auth=1&hexclave_cross_domain_state=${outerState}`);
|
||||
currentUrl.searchParams.set("hexclave_cross_domain_state", outerState);
|
||||
currentUrl.searchParams.set("hexclave_cross_domain_code_challenge", outerCodeChallenge);
|
||||
currentUrl.searchParams.set("hexclave_cross_domain_after_callback_redirect_url", "https://demo.stack-auth.com/");
|
||||
currentUrl.searchParams.set("stack_nested_cross_domain_auth_refresh_token_id", "source-session");
|
||||
currentUrl.searchParams.set("stack_nested_cross_domain_auth_callback_url", "https://demo.stack-auth.com/");
|
||||
|
||||
const previousWindow = globalThis.window;
|
||||
const previousDocument = globalThis.document;
|
||||
let redirectedUrl = "";
|
||||
vi.spyOn(clientApp as any, "_getCurrentRefreshTokenIdIfSignedIn").mockResolvedValue(null);
|
||||
vi.spyOn(clientApp as any, "_getNestedCrossDomainAuthParamsForRedirect").mockResolvedValue({
|
||||
state: "fresh-nested-state",
|
||||
codeChallenge: "fresh-nested-code-challenge",
|
||||
});
|
||||
vi.spyOn(clientApp as any, "_isTrusted").mockResolvedValue(true);
|
||||
|
||||
globalThis.document = createMockDocument();
|
||||
globalThis.window = {
|
||||
location: {
|
||||
href: currentUrl.toString(),
|
||||
replace: (url: string) => {
|
||||
redirectedUrl = url;
|
||||
throw new Error("INTENTIONAL_TEST_ABORT");
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
|
||||
try {
|
||||
await expect((clientApp as any)._maybeHandleNestedCrossDomainAuth()).rejects.toThrowError("INTENTIONAL_TEST_ABORT");
|
||||
} finally {
|
||||
globalThis.window = previousWindow;
|
||||
globalThis.document = previousDocument;
|
||||
}
|
||||
|
||||
const redirectUrl = new URL(redirectedUrl);
|
||||
expect(redirectUrl.searchParams.get("state")).toBe("fresh-nested-state");
|
||||
expect(redirectUrl.searchParams.get("code_challenge")).toBe("fresh-nested-code-challenge");
|
||||
const redirectUri = new URL(redirectUrl.searchParams.get("redirect_uri") ?? "");
|
||||
expect(redirectUri.searchParams.get("hexclave_cross_domain_state")).toBe(outerState);
|
||||
expect(redirectUri.searchParams.get("hexclave_cross_domain_code_challenge")).toBe(outerCodeChallenge);
|
||||
expect(redirectUri.searchParams.get("hexclave_cross_domain_after_callback_redirect_url")).toBe("https://demo.stack-auth.com/");
|
||||
});
|
||||
});
|
||||
|
||||
@ -947,10 +947,25 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
const afterCallbackRedirectUrl = new URL(currentUrl);
|
||||
afterCallbackRedirectUrl.searchParams.delete(nestedCrossDomainAuthQueryParams.refreshTokenId);
|
||||
afterCallbackRedirectUrl.searchParams.delete(nestedCrossDomainAuthQueryParams.callbackUrl);
|
||||
const { state: newState, codeChallenge: newCodeChallenge } = await this._getCrossDomainHandoffParamsForRedirect(currentUrl);
|
||||
const { state: newState, codeChallenge: newCodeChallenge } = await this._getNestedCrossDomainAuthParamsForRedirect();
|
||||
const nestedRedirectUri = new URL(this._getOAuthCallbackRedirectUri(), currentUrl);
|
||||
nestedRedirectUri.searchParams.delete(nestedCrossDomainAuthQueryParams.refreshTokenId);
|
||||
nestedRedirectUri.searchParams.delete(nestedCrossDomainAuthQueryParams.callbackUrl);
|
||||
for (const param of [
|
||||
"after_auth_return_to",
|
||||
crossDomainAuthQueryParams.marker,
|
||||
crossDomainAuthQueryParams.state,
|
||||
crossDomainAuthQueryParams.codeChallenge,
|
||||
crossDomainAuthQueryParams.afterCallbackRedirectUrl,
|
||||
]) {
|
||||
const value = currentUrl.searchParams.get(param);
|
||||
if (value != null && !nestedRedirectUri.searchParams.has(param)) {
|
||||
nestedRedirectUri.searchParams.set(param, value);
|
||||
}
|
||||
}
|
||||
|
||||
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.refreshTokenId, refreshTokenId);
|
||||
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.redirectUri, new URL(this._getOAuthCallbackRedirectUri(), currentUrl).toString());
|
||||
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.redirectUri, nestedRedirectUri.toString());
|
||||
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.state, newState);
|
||||
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.codeChallenge, newCodeChallenge);
|
||||
callbackUrl.searchParams.set(nestedCrossDomainAuthQueryParams.codeChallengeMethod, "S256");
|
||||
@ -959,6 +974,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
|
||||
return true;
|
||||
}
|
||||
|
||||
protected async _getNestedCrossDomainAuthParamsForRedirect(): Promise<CrossDomainHandoffParams> {
|
||||
return await saveVerifierAndState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cloudflare workers does not allow use of randomness on the global scope (on which the Stack app is probably
|
||||
* initialized). For that reason, we generate the unique identifier lazily when it is first needed instead of in the
|
||||
|
||||
Loading…
Reference in New Issue
Block a user