mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-04 21:04:37 +08:00
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:
parent
a9ff92480d
commit
e2fbe2ca09
@ -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?.();
|
||||
});
|
||||
|
||||
@ -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");
|
||||
|
||||
Loading…
Reference in New Issue
Block a user