Fix cross-subdomain cookie deletion and prefetch trusted parent domain (#1302)

Cross-subdomain refresh cookies were not being deleted correctly because
the domain option was not passed to deleteCookie/deleteCookieClient.
This caused stale cookies to accumulate and auth state to persist across
subdomains after sign-out.

Also eagerly warms the trusted parent domain cache on app construction
to avoid a race condition where navigation after sign-in could prevent
the cross-subdomain cookie from being written.

<!--

Make sure you've read the CONTRIBUTING.md guidelines:
https://github.com/stack-auth/stack-auth/blob/dev/CONTRIBUTING.md

-->


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Automatically recreates a missing cross-subdomain refresh cookie on
app startup in browser sessions when applicable.

* **Bug Fixes**
* Cookie deletions now correctly scope removals to the encoded parent
domain when applicable for both browser and server token-store flows.

* **Performance**
* Pre-warms a domain-resolution cache in browser token-store scenarios
to reduce authentication latency.

* **Tests**
* Added end-to-end tests validating custom refresh-cookie name
encoding/decoding, non-custom cookie handling, and eager cookie
recreation.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
BilalG1 2026-04-03 17:10:25 -07:00 committed by GitHub
parent a9ff92480d
commit e2fbe2ca09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 141 additions and 5 deletions

View File

@ -1,6 +1,8 @@
import { StackClientApp } from "@stackframe/js";
import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
import { TextEncoder } from "util";
import { vi } from "vitest";
import { STACK_BACKEND_BASE_URL } from "../helpers";
import { it } from "../helpers";
import { createApp } from "./js-helpers";
@ -302,6 +304,34 @@ it("should omit secure-only defaults when running on http origins", async ({ exp
expect(insecureAttrs?.get("domain")).toBeUndefined();
});
it("should roundtrip domain through custom refresh cookie name encode/decode", async ({ expect }) => {
const { clientApp } = await createApp();
const domains = [
"example.com",
"sub.example.com",
"deep.nested.example.com",
"EXAMPLE.COM",
"my-site.co.uk",
];
for (const domain of domains) {
const cookieName = (clientApp as any)._getCustomRefreshCookieName(domain);
const decoded = (clientApp as any)._getDomainFromCustomRefreshCookieName(cookieName);
expect(decoded).toBe(domain.toLowerCase());
}
});
it("should return null for non-custom refresh cookie names", async ({ expect }) => {
const { clientApp } = await createApp();
const defaultName = getDefaultRefreshCookieName(clientApp.projectId, true);
expect((clientApp as any)._getDomainFromCustomRefreshCookieName(defaultName)).toBeNull();
expect((clientApp as any)._getDomainFromCustomRefreshCookieName("unrelated-cookie")).toBeNull();
expect((clientApp as any)._getDomainFromCustomRefreshCookieName("")).toBeNull();
expect((clientApp as any)._getDomainFromCustomRefreshCookieName(`stack-refresh-${clientApp.projectId}--custom-%%%`)).toBeNull();
});
it("should read the newest refresh token payload from cookie storage", async ({ expect }) => {
const { clientApp } = await createApp();
@ -327,3 +357,72 @@ it("should read the newest refresh token payload from cookie storage", async ({
expect(tokens.refreshToken).toBe("fresh-token");
expect(tokens.accessToken).toBe("fresh-access-token");
});
it("should eagerly create cross-subdomain cookie on construction when session exists but custom cookie is missing", async ({ expect }) => {
const { cookieStore } = setupBrowserCookieEnv({ protocol: "https:" });
const { clientApp, apiKey } = await createApp(
{
config: {
domains: [
{ domain: "https://example.com", handlerPath: "/handler" },
{ domain: "https://**.example.com", handlerPath: "/handler" },
],
},
},
{
client: {
tokenStore: "cookie",
noAutomaticPrefetch: true,
},
},
);
// Sign in to get a valid session
const email = `${crypto.randomUUID()}@eager-cookie.test`;
const password = "password";
await clientApp.signUpWithCredential({ email, password, verificationCallbackUrl: "http://localhost:3000", noRedirect: true });
await clientApp.signInWithCredential({ email, password, noRedirect: true });
const defaultCookieName = getDefaultRefreshCookieName(clientApp.projectId, true);
const customCookieName = getCustomRefreshCookieName(clientApp.projectId, "example.com");
// Wait for the cross-subdomain cookie to be written
const customReady = await waitUntil(() => cookieStore.has(customCookieName), 10_000);
expect(customReady).toBe(true);
// Grab the refresh token before we manipulate cookies
const customCookieValue = cookieStore.get(customCookieName)!;
const parsed = JSON.parse(decodeURIComponent(customCookieValue));
// Simulate state where user was signed in before wildcard domain was added:
// default cookie exists with the session, but no cross-subdomain cookie
cookieStore.delete(customCookieName);
const defaultValue = encodeURIComponent(JSON.stringify({
refresh_token: parsed.refresh_token,
updated_at_millis: parsed.updated_at_millis,
}));
cookieStore.set(defaultCookieName, defaultValue);
expect(cookieStore.has(customCookieName)).toBe(false);
expect(cookieStore.has(defaultCookieName)).toBe(true);
// Construct a new client app (simulates page reload)
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
const reloadedApp = new StackClientApp({
baseUrl: STACK_BACKEND_BASE_URL,
projectId: clientApp.projectId,
publishableClientKey: apiKey.publishableClientKey,
tokenStore: "cookie",
redirectMethod: "none",
noAutomaticPrefetch: true,
extraRequestHeaders: { "x-stack-disable-artificial-development-delay": "yes" },
});
// The cross-subdomain cookie should be eagerly created on construction
const customRecreated = await waitUntil(() => cookieStore.has(customCookieName), 10_000);
expect(customRecreated).toBe(true);
// Clean up
(reloadedApp as any).dispose?.();
});

View File

@ -18,7 +18,7 @@ import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields";
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
import { decodeBase32, encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
import { scrambleDuringCompileTime } from "@stackframe/stack-shared/dist/utils/compile-time";
import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env";
import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
@ -535,6 +535,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
this._urlOptions = resolvedOptions.urls ?? {};
this._oauthScopesOnSignIn = resolvedOptions.oauthScopesOnSignIn ?? {};
this._prefetchCrossDomainHandoffParamsIfNeeded();
if (isBrowserLike() && (resolvedOptions.tokenStore === "cookie" || resolvedOptions.tokenStore === "nextjs-cookie")) {
runAsynchronously(this._trustedParentDomainCache.getOrWait([window.location.hostname], "write-only"));
this._ensureCrossSubdomainCookieExists();
}
if (extraOptions && extraOptions.uniqueIdentifier) {
this._uniqueIdentifier = extraOptions.uniqueIdentifier;
@ -620,6 +624,15 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
const encoded = encodeBase32(new TextEncoder().encode(domain.toLowerCase()));
return `${this._refreshTokenCookieName}--custom-${encoded}`;
}
private _getDomainFromCustomRefreshCookieName(name: string): string | null {
const prefix = `${this._refreshTokenCookieName}--custom-`;
if (!name.startsWith(prefix)) return null;
try {
return new TextDecoder().decode(decodeBase32(name.slice(prefix.length)));
} catch {
return null;
}
}
private _formatRefreshCookieValue(refreshToken: string, updatedAt: number): string {
return JSON.stringify({
refresh_token: refreshToken,
@ -763,6 +776,26 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
cookieNamesToDelete: [...cookieNames],
};
}
private _ensureCrossSubdomainCookieExists() {
runAsynchronously(async () => {
const hostname = window.location.hostname;
const domain = await this._trustedParentDomainCache.getOrWait([hostname], "read-write");
if (domain.status === "error" || !domain.data) {
return;
}
const cookies = this._getAllBrowserCookies();
const customCookieName = this._getCustomRefreshCookieName(domain.data);
if (cookies[customCookieName]) {
return;
}
const { refreshToken, updatedAt } = this._extractRefreshTokenFromCookieMap(cookies);
if (refreshToken && updatedAt) {
const value = this._formatRefreshCookieValue(refreshToken, updatedAt);
setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365, domain: domain.data });
}
});
}
private _queueCustomRefreshCookieUpdate(refreshToken: string | null, updatedAt: number | null, context: "browser" | "server") {
runAsynchronously(async () => {
this._mostRecentQueuedCookieRefreshIndex++;
@ -855,7 +888,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
);
setOrDeleteCookieClient(defaultName, refreshCookieValue, { maxAge: 60 * 60 * 24 * 365, secure });
setOrDeleteCookieClient(this._accessTokenCookieName, accessTokenPayload, { maxAge: 60 * 60 * 24 });
cookieNamesToDelete.forEach((name) => deleteCookieClient(name, {}));
cookieNamesToDelete.forEach((name) => {
const domain = this._getDomainFromCustomRefreshCookieName(name);
deleteCookieClient(name, domain ? { domain } : {});
});
this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "browser");
hasSucceededInWriting = true;
} catch (e) {
@ -912,9 +948,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
]);
if (cookieNamesToDelete.length > 0) {
await Promise.all(
cookieNamesToDelete.map((name) =>
deleteCookie(name, { noOpIfServerComponent: true }),
),
cookieNamesToDelete.map((name) => {
const domain = this._getDomainFromCustomRefreshCookieName(name);
return deleteCookie(name, { noOpIfServerComponent: true, ...(domain ? { domain } : {}) });
}),
);
}
this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "server");