Skip to content

Commit e2fbe2c

Browse files
authored
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 -->
1 parent a9ff924 commit e2fbe2c

2 files changed

Lines changed: 141 additions & 5 deletions

File tree

apps/e2e/tests/js/cookies.test.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import { StackClientApp } from "@stackframe/js";
12
import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
23
import { TextEncoder } from "util";
34
import { vi } from "vitest";
5+
import { STACK_BACKEND_BASE_URL } from "../helpers";
46
import { it } from "../helpers";
57
import { createApp } from "./js-helpers";
68

@@ -302,6 +304,34 @@ it("should omit secure-only defaults when running on http origins", async ({ exp
302304
expect(insecureAttrs?.get("domain")).toBeUndefined();
303305
});
304306

307+
it("should roundtrip domain through custom refresh cookie name encode/decode", async ({ expect }) => {
308+
const { clientApp } = await createApp();
309+
310+
const domains = [
311+
"example.com",
312+
"sub.example.com",
313+
"deep.nested.example.com",
314+
"EXAMPLE.COM",
315+
"my-site.co.uk",
316+
];
317+
318+
for (const domain of domains) {
319+
const cookieName = (clientApp as any)._getCustomRefreshCookieName(domain);
320+
const decoded = (clientApp as any)._getDomainFromCustomRefreshCookieName(cookieName);
321+
expect(decoded).toBe(domain.toLowerCase());
322+
}
323+
});
324+
325+
it("should return null for non-custom refresh cookie names", async ({ expect }) => {
326+
const { clientApp } = await createApp();
327+
328+
const defaultName = getDefaultRefreshCookieName(clientApp.projectId, true);
329+
expect((clientApp as any)._getDomainFromCustomRefreshCookieName(defaultName)).toBeNull();
330+
expect((clientApp as any)._getDomainFromCustomRefreshCookieName("unrelated-cookie")).toBeNull();
331+
expect((clientApp as any)._getDomainFromCustomRefreshCookieName("")).toBeNull();
332+
expect((clientApp as any)._getDomainFromCustomRefreshCookieName(`stack-refresh-${clientApp.projectId}--custom-%%%`)).toBeNull();
333+
});
334+
305335
it("should read the newest refresh token payload from cookie storage", async ({ expect }) => {
306336
const { clientApp } = await createApp();
307337

@@ -327,3 +357,72 @@ it("should read the newest refresh token payload from cookie storage", async ({
327357
expect(tokens.refreshToken).toBe("fresh-token");
328358
expect(tokens.accessToken).toBe("fresh-access-token");
329359
});
360+
361+
it("should eagerly create cross-subdomain cookie on construction when session exists but custom cookie is missing", async ({ expect }) => {
362+
const { cookieStore } = setupBrowserCookieEnv({ protocol: "https:" });
363+
364+
const { clientApp, apiKey } = await createApp(
365+
{
366+
config: {
367+
domains: [
368+
{ domain: "https://example.com", handlerPath: "/handler" },
369+
{ domain: "https://**.example.com", handlerPath: "/handler" },
370+
],
371+
},
372+
},
373+
{
374+
client: {
375+
tokenStore: "cookie",
376+
noAutomaticPrefetch: true,
377+
},
378+
},
379+
);
380+
381+
// Sign in to get a valid session
382+
const email = `${crypto.randomUUID()}@eager-cookie.test`;
383+
const password = "password";
384+
await clientApp.signUpWithCredential({ email, password, verificationCallbackUrl: "http://localhost:3000", noRedirect: true });
385+
await clientApp.signInWithCredential({ email, password, noRedirect: true });
386+
387+
const defaultCookieName = getDefaultRefreshCookieName(clientApp.projectId, true);
388+
const customCookieName = getCustomRefreshCookieName(clientApp.projectId, "example.com");
389+
390+
// Wait for the cross-subdomain cookie to be written
391+
const customReady = await waitUntil(() => cookieStore.has(customCookieName), 10_000);
392+
expect(customReady).toBe(true);
393+
394+
// Grab the refresh token before we manipulate cookies
395+
const customCookieValue = cookieStore.get(customCookieName)!;
396+
const parsed = JSON.parse(decodeURIComponent(customCookieValue));
397+
398+
// Simulate state where user was signed in before wildcard domain was added:
399+
// default cookie exists with the session, but no cross-subdomain cookie
400+
cookieStore.delete(customCookieName);
401+
const defaultValue = encodeURIComponent(JSON.stringify({
402+
refresh_token: parsed.refresh_token,
403+
updated_at_millis: parsed.updated_at_millis,
404+
}));
405+
cookieStore.set(defaultCookieName, defaultValue);
406+
407+
expect(cookieStore.has(customCookieName)).toBe(false);
408+
expect(cookieStore.has(defaultCookieName)).toBe(true);
409+
410+
// Construct a new client app (simulates page reload)
411+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
412+
const reloadedApp = new StackClientApp({
413+
baseUrl: STACK_BACKEND_BASE_URL,
414+
projectId: clientApp.projectId,
415+
publishableClientKey: apiKey.publishableClientKey,
416+
tokenStore: "cookie",
417+
redirectMethod: "none",
418+
noAutomaticPrefetch: true,
419+
extraRequestHeaders: { "x-stack-disable-artificial-development-delay": "yes" },
420+
});
421+
422+
// The cross-subdomain cookie should be eagerly created on construction
423+
const customRecreated = await waitUntil(() => cookieStore.has(customCookieName), 10_000);
424+
expect(customRecreated).toBe(true);
425+
426+
// Clean up
427+
(reloadedApp as any).dispose?.();
428+
});

packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams";
1818
import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users";
1919
import type { RestrictedReason } from "@stackframe/stack-shared/dist/schema-fields";
2020
import { InternalSession } from "@stackframe/stack-shared/dist/sessions";
21-
import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
21+
import { decodeBase32, encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes";
2222
import { scrambleDuringCompileTime } from "@stackframe/stack-shared/dist/utils/compile-time";
2323
import { isBrowserLike } from "@stackframe/stack-shared/dist/utils/env";
2424
import { StackAssertionError, captureError, throwErr } from "@stackframe/stack-shared/dist/utils/errors";
@@ -535,6 +535,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
535535
this._urlOptions = resolvedOptions.urls ?? {};
536536
this._oauthScopesOnSignIn = resolvedOptions.oauthScopesOnSignIn ?? {};
537537
this._prefetchCrossDomainHandoffParamsIfNeeded();
538+
if (isBrowserLike() && (resolvedOptions.tokenStore === "cookie" || resolvedOptions.tokenStore === "nextjs-cookie")) {
539+
runAsynchronously(this._trustedParentDomainCache.getOrWait([window.location.hostname], "write-only"));
540+
this._ensureCrossSubdomainCookieExists();
541+
}
538542

539543
if (extraOptions && extraOptions.uniqueIdentifier) {
540544
this._uniqueIdentifier = extraOptions.uniqueIdentifier;
@@ -620,6 +624,15 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
620624
const encoded = encodeBase32(new TextEncoder().encode(domain.toLowerCase()));
621625
return `${this._refreshTokenCookieName}--custom-${encoded}`;
622626
}
627+
private _getDomainFromCustomRefreshCookieName(name: string): string | null {
628+
const prefix = `${this._refreshTokenCookieName}--custom-`;
629+
if (!name.startsWith(prefix)) return null;
630+
try {
631+
return new TextDecoder().decode(decodeBase32(name.slice(prefix.length)));
632+
} catch {
633+
return null;
634+
}
635+
}
623636
private _formatRefreshCookieValue(refreshToken: string, updatedAt: number): string {
624637
return JSON.stringify({
625638
refresh_token: refreshToken,
@@ -763,6 +776,26 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
763776
cookieNamesToDelete: [...cookieNames],
764777
};
765778
}
779+
780+
private _ensureCrossSubdomainCookieExists() {
781+
runAsynchronously(async () => {
782+
const hostname = window.location.hostname;
783+
const domain = await this._trustedParentDomainCache.getOrWait([hostname], "read-write");
784+
if (domain.status === "error" || !domain.data) {
785+
return;
786+
}
787+
const cookies = this._getAllBrowserCookies();
788+
const customCookieName = this._getCustomRefreshCookieName(domain.data);
789+
if (cookies[customCookieName]) {
790+
return;
791+
}
792+
const { refreshToken, updatedAt } = this._extractRefreshTokenFromCookieMap(cookies);
793+
if (refreshToken && updatedAt) {
794+
const value = this._formatRefreshCookieValue(refreshToken, updatedAt);
795+
setOrDeleteCookieClient(customCookieName, value, { maxAge: 60 * 60 * 24 * 365, domain: domain.data });
796+
}
797+
});
798+
}
766799
private _queueCustomRefreshCookieUpdate(refreshToken: string | null, updatedAt: number | null, context: "browser" | "server") {
767800
runAsynchronously(async () => {
768801
this._mostRecentQueuedCookieRefreshIndex++;
@@ -855,7 +888,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
855888
);
856889
setOrDeleteCookieClient(defaultName, refreshCookieValue, { maxAge: 60 * 60 * 24 * 365, secure });
857890
setOrDeleteCookieClient(this._accessTokenCookieName, accessTokenPayload, { maxAge: 60 * 60 * 24 });
858-
cookieNamesToDelete.forEach((name) => deleteCookieClient(name, {}));
891+
cookieNamesToDelete.forEach((name) => {
892+
const domain = this._getDomainFromCustomRefreshCookieName(name);
893+
deleteCookieClient(name, domain ? { domain } : {});
894+
});
859895
this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "browser");
860896
hasSucceededInWriting = true;
861897
} catch (e) {
@@ -912,9 +948,10 @@ export class _StackClientAppImplIncomplete<HasTokenStore extends boolean, Projec
912948
]);
913949
if (cookieNamesToDelete.length > 0) {
914950
await Promise.all(
915-
cookieNamesToDelete.map((name) =>
916-
deleteCookie(name, { noOpIfServerComponent: true }),
917-
),
951+
cookieNamesToDelete.map((name) => {
952+
const domain = this._getDomainFromCustomRefreshCookieName(name);
953+
return deleteCookie(name, { noOpIfServerComponent: true, ...(domain ? { domain } : {}) });
954+
}),
918955
);
919956
}
920957
this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "server");

0 commit comments

Comments
 (0)