From e6aabb830cb944c423e5864f4a42662ec757b3db Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 22 Oct 2025 13:17:00 -0700 Subject: [PATCH 01/14] test --- apps/e2e/tests/js/app.test.ts | 90 +++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/apps/e2e/tests/js/app.test.ts b/apps/e2e/tests/js/app.test.ts index edb0da3c80..c1e19a39a7 100644 --- a/apps/e2e/tests/js/app.test.ts +++ b/apps/e2e/tests/js/app.test.ts @@ -1,7 +1,12 @@ +import { vi, afterEach } from "vitest"; import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { it } from "../helpers"; import { createApp, scaffoldProject } from "./js-helpers"; +afterEach(() => { + vi.unstubAllGlobals(); +}); + it("should scaffold the project", async ({ expect }) => { const { project } = await scaffoldProject(); expect(project.displayName).toBe("New Project"); @@ -91,3 +96,88 @@ it("should throw a helpful error when destructuring user", async ({ expect }) => const accessServerUser = () => (serverUser as any).user; expect(accessServerUser).toThrowError("Stack Auth: useUser() already returns the user object. Use `const user = useUser()` (or `const user = await app.getUser()`) instead of destructuring it like `const { user } = ...`."); }); + +it("should share auth cookies across subdomains when enabled", async ({ expect }) => { + const cookieWrites: string[] = []; + + const fakeSessionStorage = { + getItem: () => null, + setItem: () => { }, + removeItem: () => { }, + clear: () => { }, + }; + + const fakeLocation = { + host: "app.example.com", + hostname: "app.example.com", + href: "https://app.example.com/", + origin: "https://app.example.com", + }; + + const fakeWindow = { + location: fakeLocation, + sessionStorage: fakeSessionStorage, + } as any; + + const fakeDocument: any = { + createElement: () => ({}), + }; + Object.defineProperty(fakeDocument, "cookie", { + configurable: true, + get: () => cookieWrites.join("; "), + set: (value: string) => { + cookieWrites.push(value); + }, + }); + + vi.stubGlobal("window", fakeWindow); + vi.stubGlobal("document", fakeDocument); + vi.stubGlobal("sessionStorage", fakeSessionStorage); + + const { clientApp } = await createApp(undefined, { + client: { + tokenStore: "cookie", + shareCookiesAcrossSubdomains: true, + noAutomaticPrefetch: true, + }, + }); + const email = `${crypto.randomUUID()}@share-cookie.test`; + const password = "password"; + const signUpResult = await clientApp.signUpWithCredential({ + email, + password, + verificationCallbackUrl: "http://localhost:3000", + noRedirect: true, + }); + expect(signUpResult.status).toBe("ok"); + + const signInResult = await clientApp.signInWithCredential({ + email, + password, + noRedirect: true, + }); + expect(signInResult.status).toBe("ok"); + + const parseCookieAttributes = (name: string) => { + const raw = [...cookieWrites].reverse().find((entry) => entry.trim().toLowerCase().startsWith(`${name.toLowerCase()}=`)); + if (!raw) { + return null; + } + const [, ...attributeParts] = raw.split(";").map((part) => part.trim()).filter(Boolean); + const attrs: Record = {}; + for (const attribute of attributeParts) { + const [attrName, ...attrValueParts] = attribute.split("="); + attrs[attrName.toLowerCase()] = attrValueParts.join("=") || ""; + } + return attrs; + }; + + const refreshAttrs = parseCookieAttributes(`stack-refresh-${clientApp.projectId}`); + expect(refreshAttrs?.domain).toBe("example.com"); + + const accessAttrs = parseCookieAttributes("stack-access"); + expect(accessAttrs?.domain).toBe("example.com"); + + const legacyDelete = cookieWrites.find((entry) => entry.toLowerCase().startsWith("stack-refresh=") && entry.toLowerCase().includes("domain=example.com") && entry.toLowerCase().includes("expires=")); + expect(legacyDelete).toBeTruthy(); +}); From 0e23112825e18cb0e4ca0afa6d082bf5de9a9606 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 22 Oct 2025 13:45:11 -0700 Subject: [PATCH 02/14] cookie subdomain sharing option --- packages/stack-shared/src/utils/ips.tsx | 2 +- packages/stack-shared/src/utils/urls.tsx | 26 ++++++++++-- packages/template/src/lib/cookie.ts | 15 +++++-- .../apps/implementations/client-app-impl.ts | 42 ++++++++++++++++--- .../stack-app/apps/interfaces/client-app.ts | 1 + 5 files changed, 73 insertions(+), 13 deletions(-) diff --git a/packages/stack-shared/src/utils/ips.tsx b/packages/stack-shared/src/utils/ips.tsx index bc1de37fa4..db756a37f0 100644 --- a/packages/stack-shared/src/utils/ips.tsx +++ b/packages/stack-shared/src/utils/ips.tsx @@ -3,7 +3,7 @@ import ipRegex from "ip-regex"; export type Ipv4Address = `${number}.${number}.${number}.${number}`; export type Ipv6Address = string; -export function isIpAddress(ip: string): ip is Ipv4Address | Ipv6Address { +export function isIpAddress(ip: string) { return ipRegex({ exact: true }).test(ip); } import.meta.vitest?.test("isIpAddress", ({ expect }) => { diff --git a/packages/stack-shared/src/utils/urls.tsx b/packages/stack-shared/src/utils/urls.tsx index 34b829f315..3046ffa691 100644 --- a/packages/stack-shared/src/utils/urls.tsx +++ b/packages/stack-shared/src/utils/urls.tsx @@ -1,4 +1,5 @@ import { generateSecureRandomString } from "./crypto"; +import { isIpAddress } from "./ips"; import { templateIdentity } from "./strings"; export function createUrlIfValid(...args: ConstructorParameters) { @@ -225,6 +226,26 @@ import.meta.vitest?.test("isLocalhost", ({ expect }) => { expect(isLocalhost("")).toBe(false); }); +export function extractBaseDomainFromHost(host: string): string { + const hostWithoutPort = host.split(':')[0]; + if (hostWithoutPort === 'localhost' || isIpAddress(hostWithoutPort)) { + return hostWithoutPort; + } + const parts = hostWithoutPort.split('.'); + if (parts.length < 2) { + return hostWithoutPort; + } + return parts.slice(-2).join('.'); +} +import.meta.vitest?.test("extractBaseDomainFromHost", ({ expect }) => { + expect(extractBaseDomainFromHost("app.example.com")).toBe("example.com"); + expect(extractBaseDomainFromHost("sub.app.example.com")).toBe("example.com"); + expect(extractBaseDomainFromHost("example.com")).toBe("example.com"); + expect(extractBaseDomainFromHost("localhost:3000")).toBe("localhost"); + expect(extractBaseDomainFromHost("127.0.0.1")).toBe("127.0.0.1"); + expect(extractBaseDomainFromHost("127.0.0.1:3000")).toBe("127.0.0.1"); +}); + export function isRelative(url: string) { const randomDomain = `${generateSecureRandomString()}.stack-auth.example.com`; const u = createUrlIfValid(url, `https://${randomDomain}`); @@ -275,7 +296,7 @@ import.meta.vitest?.test("getRelativePart", ({ expect }) => { * * Any values passed are encoded. */ -export function url(strings: TemplateStringsArray | readonly string[], ...values: (string|number|boolean)[]): URL { +export function url(strings: TemplateStringsArray | readonly string[], ...values: (string | number | boolean)[]): URL { return new URL(urlString(strings, ...values)); } import.meta.vitest?.test("url", ({ expect }) => { @@ -311,7 +332,7 @@ import.meta.vitest?.test("url", ({ expect }) => { * * Any values passed are encoded. */ -export function urlString(strings: TemplateStringsArray | readonly string[], ...values: (string|number|boolean)[]): string { +export function urlString(strings: TemplateStringsArray | readonly string[], ...values: (string | number | boolean)[]): string { return templateIdentity(strings, ...values.map(encodeURIComponent)); } import.meta.vitest?.test("urlString", ({ expect }) => { @@ -378,4 +399,3 @@ import.meta.vitest?.test("isSubPath", ({ expect }) => { expect(isChildPath("/path/", "/path-abc")).toBe(false); expect(isChildPath("/path/", "/path-abc/")).toBe(false); }); - diff --git a/packages/template/src/lib/cookie.ts b/packages/template/src/lib/cookie.ts index 4010c69d7c..15dda28794 100644 --- a/packages/template/src/lib/cookie.ts +++ b/packages/template/src/lib/cookie.ts @@ -4,8 +4,8 @@ import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors' import Cookies from "js-cookie"; import { calculatePKCECodeChallenge, generateRandomCodeVerifier, generateRandomState } from "oauth4webapi"; -type SetCookieOptions = { maxAge?: number, noOpIfServerComponent?: boolean }; -type DeleteCookieOptions = { noOpIfServerComponent?: boolean }; +type SetCookieOptions = { maxAge?: number, noOpIfServerComponent?: boolean, domain?: string }; +type DeleteCookieOptions = { noOpIfServerComponent?: boolean, domain?: string }; function ensureClient() { if (!isBrowserLike()) { @@ -117,6 +117,7 @@ function createNextCookieHelper( rscCookiesAwaited.set(name, value, { secure: isSecureCookie, maxAge: options.maxAge, + domain: options.domain, }); } catch (e) { handleCookieError(e, options); @@ -131,7 +132,11 @@ function createNextCookieHelper( }, delete(name: string, options: DeleteCookieOptions = {}) { try { - rscCookiesAwaited.delete(name); + if (options.domain !== undefined) { + rscCookiesAwaited.delete({ name, domain: options.domain }); + } else { + rscCookiesAwaited.delete(name); + } } catch (e) { handleCookieError(e, options); } @@ -169,6 +174,9 @@ export async function setOrDeleteCookie(name: string, value: string | null, opti export function deleteCookieClient(name: string, options: DeleteCookieOptions = {}) { ensureClient(); + if (options.domain !== undefined) { + Cookies.remove(name, { domain: options.domain }); + } Cookies.remove(name); } @@ -181,6 +189,7 @@ export function setCookieClient(name: string, value: string, options: SetCookieO ensureClient(); Cookies.set(name, value, { expires: options.maxAge === undefined ? undefined : new Date(Date.now() + (options.maxAge) * 1000), + domain: options.domain, }); } diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 8ba0d83ef0..ac7659a765 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -27,7 +27,7 @@ import { suspend, suspendIfSsr } from "@stackframe/stack-shared/dist/utils/react import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { Store, storeLock } from "@stackframe/stack-shared/dist/utils/stores"; import { deindent, mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings"; -import { getRelativePart, isRelative } from "@stackframe/stack-shared/dist/utils/urls"; +import { extractBaseDomainFromHost, getRelativePart, isRelative } from "@stackframe/stack-shared/dist/utils/urls"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import * as cookie from "cookie"; import * as NextNavigationUnscrambled from "next/navigation"; // import the entire module to get around some static compiler warnings emitted by Next.js in some cases | THIS_LINE_PLATFORM next @@ -57,7 +57,7 @@ import { useAsyncCache } from "./common"; let isReactServer = false; // IF_PLATFORM next import * as sc from "@stackframe/stack-sc"; -import { cookies } from '@stackframe/stack-sc'; +import { cookies, headers as nextHeaders } from "@stackframe/stack-sc"; isReactServer = sc.isReactServer; // NextNavigation.useRouter does not exist in react-server environments and some bundlers try to be helpful and throw a warning. Ignore the warning. @@ -90,6 +90,7 @@ export class _StackClientAppImplIncomplete; protected readonly _oauthScopesOnSignIn: Partial; + protected readonly _shareCookiesAcrossSubdomains: boolean; private __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false; private readonly _ownedAdminApps = new DependenciesMap<[InternalSession, string], _StackAdminAppImplIncomplete>(); @@ -375,6 +376,7 @@ export class _StackClientAppImplIncomplete>(); protected _requestTokenStores = new WeakMap>(); protected _storedBrowserCookieTokenStore: Store | null = null; + protected _getBrowserCookieDomain(): string | undefined { + if (!this._shareCookiesAcrossSubdomains || !isBrowserLike()) { + return undefined; + } + const host = window.location.host; + const domain = extractBaseDomainFromHost(host); + return domain; + } + protected async _getServerCookieDomain(): Promise { + if (!this._shareCookiesAcrossSubdomains) { + return undefined; + } + // IF_PLATFORM next + try { + const resolvedHeaders = typeof nextHeaders === "function" ? await nextHeaders() : nextHeaders; + const hostHeader = resolvedHeaders?.get("x-forwarded-host") ?? resolvedHeaders?.get("host"); + return hostHeader ? extractBaseDomainFromHost(hostHeader) : undefined; + } catch { + return undefined; + } + // END_PLATFORM + return undefined; + } protected get _refreshTokenCookieName() { return `stack-refresh-${this.projectId}`; } @@ -467,8 +492,12 @@ export class _StackClientAppImplIncomplete { try { - setOrDeleteCookieClient(this._refreshTokenCookieName, value.refreshToken, { maxAge: 60 * 60 * 24 * 365 }); - setOrDeleteCookieClient(this._accessTokenCookieName, value.accessToken ? JSON.stringify([value.refreshToken, value.accessToken]) : null, { maxAge: 60 * 60 * 24 }); + const domain = this._getBrowserCookieDomain(); + setOrDeleteCookieClient(this._refreshTokenCookieName, value.refreshToken, { maxAge: 60 * 60 * 24 * 365, domain }); + setOrDeleteCookieClient(this._accessTokenCookieName, value.accessToken ? JSON.stringify([value.refreshToken, value.accessToken]) : null, { maxAge: 60 * 60 * 24, domain }); + if (domain !== undefined) { + deleteCookieClient('stack-refresh', { domain }); + } deleteCookieClient('stack-refresh'); // delete cookie name from previous versions (for backwards-compatibility) hasSucceededInWriting = true; } catch (e) { @@ -502,6 +531,7 @@ export class _StackClientAppImplIncomplete(tokens); store.onChange((value) => { runAsynchronously(async () => { + const domain = await this._getServerCookieDomain(); // TODO HACK this is a bit of a hack; while the order happens to work in practice (because the only actual // async operation is waiting for the `cookies()` to resolve which always happens at the same time during // the same request), it's not guaranteed to be free of race conditions if there are many updates happening @@ -514,8 +544,8 @@ export class _StackClientAppImplIncomplete, oauthScopesOnSignIn?: Partial, tokenStore?: TokenStoreInit, + shareCookiesAcrossSubdomains?: boolean, redirectMethod?: RedirectMethod, inheritsFrom?: StackClientApp, From ebd14cafd84af9cd3d18a66db0167876e9438178 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 22 Oct 2025 14:15:12 -0700 Subject: [PATCH 03/14] fix test --- apps/e2e/tests/js/app.test.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/apps/e2e/tests/js/app.test.ts b/apps/e2e/tests/js/app.test.ts index c1e19a39a7..1a7e98a43d 100644 --- a/apps/e2e/tests/js/app.test.ts +++ b/apps/e2e/tests/js/app.test.ts @@ -1,11 +1,8 @@ -import { vi, afterEach } from "vitest"; +import { vi } from "vitest"; import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import { it } from "../helpers"; import { createApp, scaffoldProject } from "./js-helpers"; -afterEach(() => { - vi.unstubAllGlobals(); -}); it("should scaffold the project", async ({ expect }) => { const { project } = await scaffoldProject(); From ca282e70e4c785e82ada74804c5a22f06621b49e Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 22 Oct 2025 18:41:40 -0700 Subject: [PATCH 04/14] update --- apps/e2e/tests/js/app.test.ts | 94 +++++- .../src/interface/crud/projects.ts | 1 + packages/template/src/lib/cookie.ts | 32 +- .../apps/implementations/client-app-impl.ts | 293 ++++++++++++++---- .../stack-app/apps/interfaces/client-app.ts | 1 - 5 files changed, 343 insertions(+), 78 deletions(-) diff --git a/apps/e2e/tests/js/app.test.ts b/apps/e2e/tests/js/app.test.ts index 1a7e98a43d..04fc7d3a19 100644 --- a/apps/e2e/tests/js/app.test.ts +++ b/apps/e2e/tests/js/app.test.ts @@ -1,5 +1,8 @@ -import { vi } from "vitest"; +import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes"; +import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; +import { TextEncoder } from "util"; +import { vi } from "vitest"; import { it } from "../helpers"; import { createApp, scaffoldProject } from "./js-helpers"; @@ -94,8 +97,9 @@ it("should throw a helpful error when destructuring user", async ({ expect }) => expect(accessServerUser).toThrowError("Stack Auth: useUser() already returns the user object. Use `const user = useUser()` (or `const user = await app.getUser()`) instead of destructuring it like `const { user } = ...`."); }); -it("should share auth cookies across subdomains when enabled", async ({ expect }) => { +it("should set refresh token cookies for trusted parent domains", async ({ expect }) => { const cookieWrites: string[] = []; + const cookieStore = new Map(); const fakeSessionStorage = { getItem: () => null, @@ -109,6 +113,7 @@ it("should share auth cookies across subdomains when enabled", async ({ expect } hostname: "app.example.com", href: "https://app.example.com/", origin: "https://app.example.com", + protocol: "https:", }; const fakeWindow = { @@ -121,9 +126,21 @@ it("should share auth cookies across subdomains when enabled", async ({ expect } }; Object.defineProperty(fakeDocument, "cookie", { configurable: true, - get: () => cookieWrites.join("; "), + get: () => Array.from(cookieStore.entries()).map(([name, value]) => `${name}=${value}`).join("; "), set: (value: string) => { cookieWrites.push(value); + const [pair] = value.split(";").map((part) => part.trim()).filter(Boolean); + if (!pair) { + return; + } + const [rawName, ...rawValueParts] = pair.split("="); + const name = rawName.trim(); + const storedValue = rawValueParts.join("="); + if (storedValue === "") { + cookieStore.delete(name); + } else { + cookieStore.set(name, storedValue); + } }, }); @@ -131,14 +148,24 @@ it("should share auth cookies across subdomains when enabled", async ({ expect } vi.stubGlobal("document", fakeDocument); vi.stubGlobal("sessionStorage", fakeSessionStorage); - const { clientApp } = await createApp(undefined, { - client: { - tokenStore: "cookie", - shareCookiesAcrossSubdomains: true, - noAutomaticPrefetch: true, + const { clientApp } = await createApp( + { + config: { + domains: [ + { domain: "https://example.com", handlerPath: "/handler" }, + { domain: "https://*.example.com", handlerPath: "/handler" }, + ], + } }, - }); - const email = `${crypto.randomUUID()}@share-cookie.test`; + { + client: { + tokenStore: "cookie", + noAutomaticPrefetch: true, + }, + } + ); + + const email = `${crypto.randomUUID()}@trusted-cookie.test`; const password = "password"; const signUpResult = await clientApp.signUpWithCredential({ email, @@ -155,6 +182,38 @@ it("should share auth cookies across subdomains when enabled", async ({ expect } }); expect(signInResult.status).toBe("ok"); + const defaultCookieName = `__Host-stack-refresh-${clientApp.projectId}--default`; + const customCookieName = `stack-refresh-${clientApp.projectId}--custom-${encodeBase32(new TextEncoder().encode("example.com"))}`; + + const waitUntil = async (predicate: () => boolean, timeoutMs: number) => { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + return false; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return true; + }; + + const defaultReady = await waitUntil(() => cookieStore.has(defaultCookieName), 2_000); + expect(defaultReady).toBe(true); + + const customReady = await waitUntil(() => cookieStore.has(customCookieName), 10_000); + expect(customReady).toBe(true); + + expect(cookieStore.has(defaultCookieName)).toBe(true); + expect(cookieStore.has(customCookieName)).toBe(true); + + const valuesEqual = await waitUntil(() => cookieStore.get(customCookieName) === cookieStore.get(defaultCookieName), 10_000); + expect(valuesEqual).toBe(true); + + const defaultValue = cookieStore.get(defaultCookieName)!; + const parsedValue = JSON.parse(decodeURIComponent(defaultValue)); + expect(typeof parsedValue.refresh_token).toBe("string"); + expect(parsedValue.refresh_token.length).toBeGreaterThan(10); + expect(typeof parsedValue.updated_at).toBe("number"); + const parseCookieAttributes = (name: string) => { const raw = [...cookieWrites].reverse().find((entry) => entry.trim().toLowerCase().startsWith(`${name.toLowerCase()}=`)); if (!raw) { @@ -169,12 +228,15 @@ it("should share auth cookies across subdomains when enabled", async ({ expect } return attrs; }; - const refreshAttrs = parseCookieAttributes(`stack-refresh-${clientApp.projectId}`); - expect(refreshAttrs?.domain).toBe("example.com"); + const defaultAttrs = parseCookieAttributes(defaultCookieName); + expect(defaultAttrs?.domain).toBeUndefined(); + expect(defaultAttrs).not.toBeNull(); + expect(Object.prototype.hasOwnProperty.call(defaultAttrs!, "secure")).toBe(true); - const accessAttrs = parseCookieAttributes("stack-access"); - expect(accessAttrs?.domain).toBe("example.com"); + const customAttrs = parseCookieAttributes(customCookieName); + expect(customAttrs?.domain).toBe("example.com"); - const legacyDelete = cookieWrites.find((entry) => entry.toLowerCase().startsWith("stack-refresh=") && entry.toLowerCase().includes("domain=example.com") && entry.toLowerCase().includes("expires=")); - expect(legacyDelete).toBeTruthy(); + const legacyProjectCookie = `stack-refresh-${clientApp.projectId}`; + expect(cookieWrites.some((entry) => entry.toLowerCase().startsWith(`${legacyProjectCookie.toLowerCase()}=`) && entry.toLowerCase().includes("expires="))).toBe(true); + expect(cookieWrites.some((entry) => entry.toLowerCase().startsWith("stack-refresh=") && entry.toLowerCase().includes("expires="))).toBe(true); }); diff --git a/packages/stack-shared/src/interface/crud/projects.ts b/packages/stack-shared/src/interface/crud/projects.ts index c4516a57e5..b89a1b6b11 100644 --- a/packages/stack-shared/src/interface/crud/projects.ts +++ b/packages/stack-shared/src/interface/crud/projects.ts @@ -108,6 +108,7 @@ export const projectsCrudClientReadSchema = yupObject({ allow_user_api_keys: schemaFields.yupBoolean().defined(), allow_team_api_keys: schemaFields.yupBoolean().defined(), enabled_oauth_providers: yupArray(enabledOAuthProviderSchema.defined()).defined().meta({ openapiField: { hidden: true } }), + domains: yupArray(domainSchema.defined()).defined(), }).defined().meta({ openapiField: { hidden: true } }), }).defined(); diff --git a/packages/template/src/lib/cookie.ts b/packages/template/src/lib/cookie.ts index 15dda28794..d6d0382380 100644 --- a/packages/template/src/lib/cookie.ts +++ b/packages/template/src/lib/cookie.ts @@ -4,7 +4,7 @@ import { StackAssertionError } from '@stackframe/stack-shared/dist/utils/errors' import Cookies from "js-cookie"; import { calculatePKCECodeChallenge, generateRandomCodeVerifier, generateRandomState } from "oauth4webapi"; -type SetCookieOptions = { maxAge?: number, noOpIfServerComponent?: boolean, domain?: string }; +type SetCookieOptions = { maxAge?: number, noOpIfServerComponent?: boolean, domain?: string, secure?: boolean }; type DeleteCookieOptions = { noOpIfServerComponent?: boolean, domain?: string }; function ensureClient() { @@ -15,6 +15,7 @@ function ensureClient() { export type CookieHelper = { get: (name: string) => string | null, + getAll: () => Record, set: (name: string, value: string, options: SetCookieOptions) => void, setOrDelete: (name: string, value: string | null, options: SetCookieOptions & DeleteCookieOptions) => void, delete: (name: string, options: DeleteCookieOptions) => void, @@ -27,6 +28,7 @@ export async function createPlaceholderCookieHelper(): Promise { } return { get: throwError, + getAll: throwError, set: throwError, setOrDelete: throwError, delete: throwError, @@ -51,6 +53,9 @@ export async function createCookieHelper(): Promise { export function createBrowserCookieHelper(): CookieHelper { return { get: getCookieClient, + getAll: () => { + return Cookies.get(); + }, set: setCookieClient, setOrDelete: setOrDeleteCookieClient, delete: deleteCookieClient, @@ -94,6 +99,13 @@ function createNextCookieHelper( } return rscCookiesAwaited.get(name)?.value ?? null; }, + getAll: () => { + const all = rscCookiesAwaited.getAll(); + return all.reduce((acc, entry) => { + acc[entry.name] = entry.value; + return acc; + }, {} as Record); + }, set: (name: string, value: string, options: SetCookieOptions) => { // Whenever the client is on HTTPS, we want to set the Secure flag on the cookie. // @@ -158,6 +170,23 @@ export async function getCookie(name: string): Promise { return cookieHelper.get(name); } +export async function isSecure(): Promise { + if (isBrowserLike()) { + return typeof window !== "undefined" && window.location.protocol === "https:"; + } + // IF_PLATFORM next + const cookies = await rscCookies(); + const headers = await rscHeaders(); + if (headers.get("x-forwarded-proto") === "https") { + return true; + } + if (cookies.get("stack-is-https")) { + return true; + } + // END_PLATFORM + return false; +} + export function setOrDeleteCookieClient(name: string, value: string | null, options: SetCookieOptions & DeleteCookieOptions = {}) { ensureClient(); if (value === null) { @@ -190,6 +219,7 @@ export function setCookieClient(name: string, value: string, options: SetCookieO Cookies.set(name, value, { expires: options.maxAge === undefined ? undefined : new Date(Date.now() + (options.maxAge) * 1000), domain: options.domain, + secure: options.secure, }); } diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index ac7659a765..52c49e74b2 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -16,6 +16,7 @@ import { TeamPermissionsCrud } from "@stackframe/stack-shared/dist/interface/cru import { TeamsCrud } from "@stackframe/stack-shared/dist/interface/crud/teams"; import { UsersCrud } from "@stackframe/stack-shared/dist/interface/crud/users"; import { InternalSession } from "@stackframe/stack-shared/dist/sessions"; +import { 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"; @@ -26,7 +27,7 @@ import { neverResolve, runAsynchronously, wait } from "@stackframe/stack-shared/ import { suspend, suspendIfSsr } from "@stackframe/stack-shared/dist/utils/react"; import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { Store, storeLock } from "@stackframe/stack-shared/dist/utils/stores"; -import { deindent, mergeScopeStrings } from "@stackframe/stack-shared/dist/utils/strings"; +import { deindent, mergeScopeStrings, stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; import { extractBaseDomainFromHost, getRelativePart, isRelative } from "@stackframe/stack-shared/dist/utils/urls"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import * as cookie from "cookie"; @@ -35,7 +36,7 @@ import React, { useCallback, useMemo } from "react"; // THIS_LINE_PLATFORM react import type * as yup from "yup"; import { constructRedirectUrl } from "../../../../utils/url"; import { addNewOAuthProviderOrScope, callOAuthCallback, signInWithOAuth } from "../../../auth"; -import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookieClient, getCookieClient, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie"; +import { CookieHelper, createBrowserCookieHelper, createCookieHelper, createPlaceholderCookieHelper, deleteCookieClient, isSecure as isSecureCookieContext, setOrDeleteCookie, setOrDeleteCookieClient } from "../../../cookie"; import { ApiKey, ApiKeyCreationOptions, ApiKeyUpdateOptions, apiKeyCreationOptionsToCrud } from "../../api-keys"; import { ConvexCtx, GetCurrentPartialUserOptions, GetCurrentUserOptions, HandlerUrls, OAuthScopesOnSignIn, RedirectMethod, RedirectToOptions, RequestLike, TokenStoreInit, stackAppInternalsSymbol } from "../../common"; import { OAuthConnection } from "../../connected-accounts"; @@ -90,7 +91,6 @@ export class _StackClientAppImplIncomplete; protected readonly _oauthScopesOnSignIn: Partial; - protected readonly _shareCookiesAcrossSubdomains: boolean; private __DEMO_ENABLE_SLIGHT_FETCH_DELAY = false; private readonly _ownedAdminApps = new DependenciesMap<[InternalSession, string], _StackAdminAppImplIncomplete>(); @@ -376,7 +376,6 @@ export class _StackClientAppImplIncomplete>(); protected _requestTokenStores = new WeakMap>(); protected _storedBrowserCookieTokenStore: Store | null = null; - protected _getBrowserCookieDomain(): string | undefined { - if (!this._shareCookiesAcrossSubdomains || !isBrowserLike()) { - return undefined; - } - const host = window.location.host; - const domain = extractBaseDomainFromHost(host); - return domain; + private _lastCustomRefreshCookieDomain: string | null = null; + private _lastCustomRefreshCookieUpdatedAt: number | null = null; + protected get _refreshTokenCookieName() { + return `stack-refresh-${this.projectId}`; } - protected async _getServerCookieDomain(): Promise { - if (!this._shareCookiesAcrossSubdomains) { - return undefined; + protected get _refreshTokenDefaultCookieName() { + return `${this._refreshTokenCookieName}--default`; + } + private _getRefreshTokenDefaultCookieNameForSecure(secure: boolean): string { + return `${secure ? "__Host-" : ""}${this._refreshTokenCookieName}--default`; + } + private _getCustomRefreshCookieName(domain: string): string { + const encoded = encodeBase32(new TextEncoder().encode(domain.toLowerCase())); + return `${this._refreshTokenCookieName}--custom-${encoded}`; + } + private _formatRefreshCookieValue(refreshToken: string, updatedAt: number): string { + return JSON.stringify({ + refresh_token: refreshToken, + updated_at: updatedAt, + }); + } + private _parseStructuredRefreshCookie(value: string | null): { refreshToken: string, updatedAt: number | null } | null { + if (!value) { + return null; } - // IF_PLATFORM next try { - const resolvedHeaders = typeof nextHeaders === "function" ? await nextHeaders() : nextHeaders; - const hostHeader = resolvedHeaders?.get("x-forwarded-host") ?? resolvedHeaders?.get("host"); - return hostHeader ? extractBaseDomainFromHost(hostHeader) : undefined; + const parsed = JSON.parse(value); + if (typeof parsed !== "object" || parsed === null) { + return null; + } + const refreshToken = typeof parsed.refresh_token === "string" ? parsed.refresh_token : null; + const updatedAt = typeof parsed.updated_at === "number" ? parsed.updated_at : null; + if (!refreshToken) { + return null; + } + return { + refreshToken, + updatedAt, + }; } catch { - return undefined; + return null; } - // END_PLATFORM - return undefined; } - protected get _refreshTokenCookieName() { - return `stack-refresh-${this.projectId}`; + private _extractRefreshTokenFromCookieMap(cookies: Record): { refreshToken: string | null, updatedAt: number | null } { + const legacyToken = cookies[this._refreshTokenCookieName] ?? cookies["stack-refresh"]; + if (legacyToken) { + return { refreshToken: legacyToken, updatedAt: null }; + } + + const prefix = `${this._refreshTokenCookieName}--`; + const hostPrefix = `__Host-${this._refreshTokenCookieName}--`; + let selected: { refreshToken: string, updatedAt: number | null } | null = null; + for (const [name, value] of Object.entries(cookies)) { + if (!(name.startsWith(prefix) || name.startsWith(hostPrefix))) continue; + const parsed = this._parseStructuredRefreshCookie(value); + if (!parsed) continue; + const candidateUpdatedAt = parsed.updatedAt ?? Number.NEGATIVE_INFINITY; + const selectedUpdatedAt = selected?.updatedAt ?? Number.NEGATIVE_INFINITY; + if (!selected || candidateUpdatedAt > selectedUpdatedAt) { + selected = parsed; + } + } + + if (!selected) { + return { refreshToken: null, updatedAt: null }; + } + + return { + refreshToken: selected.refreshToken, + updatedAt: selected.updatedAt ?? null, + }; } - protected _getTokensFromCookies(cookies: { refreshTokenCookie: string | null, accessTokenCookie: string | null }): TokenObject { - const refreshToken = cookies.refreshTokenCookie; - const accessTokenObject = cookies.accessTokenCookie?.startsWith('[\"') ? JSON.parse(cookies.accessTokenCookie) : null; // gotta check for validity first for backwards-compat, and also in case someone messes with the cookie value - const accessToken = accessTokenObject && refreshToken === accessTokenObject[0] ? accessTokenObject[1] : null; // if the refresh token has changed, the access token is invalid + protected _getTokensFromCookies(cookies: Record): TokenObject { + const { refreshToken } = this._extractRefreshTokenFromCookieMap(cookies); + const accessTokenCookie = cookies[this._accessTokenCookieName] ?? null; + let accessToken: string | null = null; + if (accessTokenCookie && accessTokenCookie.startsWith('[\"')) { + try { + const parsed = JSON.parse(accessTokenCookie); + if ( + Array.isArray(parsed) && + parsed.length === 2 && + typeof parsed[0] === "string" && + typeof parsed[1] === "string" && + parsed[0] === refreshToken + ) { + accessToken = parsed[1]; + } + } catch { + // ignore invalid cookie contents + } + } return { refreshToken, accessToken, @@ -462,6 +523,129 @@ export class _StackClientAppImplIncomplete { + if (!isBrowserLike()) { + return {}; + } + return cookie.parse(document.cookie || ""); + } + private _getBrowserBaseDomain(): string | undefined { + if (!isBrowserLike()) { + return undefined; + } + const host = window.location.host; + return host ? extractBaseDomainFromHost(host) : undefined; + } + private async _getServerBaseDomain(): Promise { + // IF_PLATFORM next + try { + const resolvedHeaders = typeof nextHeaders === "function" ? await nextHeaders() : nextHeaders; + const hostHeader = resolvedHeaders?.get("x-forwarded-host") ?? resolvedHeaders?.get("host"); + return hostHeader ? extractBaseDomainFromHost(hostHeader) : undefined; + } catch { + return undefined; + } + // END_PLATFORM + return undefined; + } + private _deleteLegacyBrowserRefreshCookies() { + deleteCookieClient(this._refreshTokenCookieName); + deleteCookieClient("stack-refresh"); + const domain = this._getBrowserBaseDomain(); + if (domain) { + deleteCookieClient(this._refreshTokenCookieName, { domain }); + deleteCookieClient("stack-refresh", { domain }); + } + } + private async _deleteLegacyServerRefreshCookies() { + const operations: Promise[] = [ + setOrDeleteCookie(this._refreshTokenCookieName, null, { noOpIfServerComponent: true }), + setOrDeleteCookie("stack-refresh", null, { noOpIfServerComponent: true }), + ]; + const domain = await this._getServerBaseDomain(); + if (domain) { + operations.push( + setOrDeleteCookie(this._refreshTokenCookieName, null, { domain, noOpIfServerComponent: true }), + setOrDeleteCookie("stack-refresh", null, { domain, noOpIfServerComponent: true }), + ); + } + await Promise.all(operations); + } + private _queueCustomRefreshCookieUpdate(refreshToken: string | null, updatedAt: number | null, context: "browser" | "server") { + runAsynchronously(async () => { + const domain = await this._fetchTrustedParentDomain(); + const previousDomain = this._lastCustomRefreshCookieDomain; + const setOrDeleteCookieWrapper = context === "browser" + ? async (...args: Parameters) => setOrDeleteCookieClient(...args) + : setOrDeleteCookie; + + const deleteCookie = async (targetDomain: string) => { + const name = this._getCustomRefreshCookieName(targetDomain); + await setOrDeleteCookieWrapper(name, null, { domain: targetDomain, noOpIfServerComponent: true }); + }; + const setCookie = async (targetDomain: string, value: string) => { + const name = this._getCustomRefreshCookieName(targetDomain); + await setOrDeleteCookieWrapper(name, value, { maxAge: 60 * 60 * 24 * 365, domain: targetDomain, noOpIfServerComponent: true }); + }; + + if (!domain) { + if (previousDomain) { + await deleteCookie(previousDomain); + this._lastCustomRefreshCookieDomain = null; + } + return; + } + + if (previousDomain && previousDomain !== domain) { + await deleteCookie(previousDomain); + } + + if (refreshToken && updatedAt !== null) { + if (this._lastCustomRefreshCookieUpdatedAt !== null && updatedAt < this._lastCustomRefreshCookieUpdatedAt) { + // Ignore stale writes + return; + } + const value = this._formatRefreshCookieValue(refreshToken, updatedAt); + await setCookie(domain, value); + this._lastCustomRefreshCookieDomain = domain; + this._lastCustomRefreshCookieUpdatedAt = updatedAt; + } else { + await deleteCookie(domain); + this._lastCustomRefreshCookieDomain = null; + this._lastCustomRefreshCookieUpdatedAt = null; + } + }); + } + private async _fetchTrustedParentDomain(): Promise { + const project = Result.orThrow(await this._interface.getClientProject()); + const domains = project.config.domains; + const direct = new Set(); + const wildcard = new Set(); + for (const domain of domains) { + const withoutProtocol = domain.domain.trim().replace(/^https?:\/\//, ""); + const host = withoutProtocol.split("/")[0]?.toLowerCase(); + if (host.startsWith("*.")) { + const candidate = host.slice(2); + if (candidate && !candidate.includes("*")) { + wildcard.add(candidate); + } + } else if (!host.includes("*")) { + direct.add(host); + } + } + const candidates = Array.from(direct).filter((domain) => wildcard.has(domain)); + candidates.sort((a, b) => { + const aParts = a.split(".").length; + const bParts = b.split(".").length; + if (aParts !== bParts) { + return aParts - bParts; + } + return stringCompare(a, b); + }); + const selected = candidates[0] ?? null; + return selected; + + } protected _getBrowserCookieTokenStore(): Store { if (!isBrowserLike()) { throw new Error("Cannot use cookie token store on the server!"); @@ -469,10 +653,7 @@ export class _StackClientAppImplIncomplete { - const tokens = this._getTokensFromCookies({ - refreshTokenCookie: getCookieClient(this._refreshTokenCookieName) ?? getCookieClient('stack-refresh'), // keep old cookie name for backwards-compatibility - accessTokenCookie: getCookieClient(this._accessTokenCookieName), - }); + const tokens = this._getTokensFromCookies(this._getAllBrowserCookies()); return { refreshToken: tokens.refreshToken, accessToken: tokens.accessToken ?? (old?.refreshToken === tokens.refreshToken ? old.accessToken : null), @@ -492,13 +673,16 @@ export class _StackClientAppImplIncomplete { try { - const domain = this._getBrowserCookieDomain(); - setOrDeleteCookieClient(this._refreshTokenCookieName, value.refreshToken, { maxAge: 60 * 60 * 24 * 365, domain }); - setOrDeleteCookieClient(this._accessTokenCookieName, value.accessToken ? JSON.stringify([value.refreshToken, value.accessToken]) : null, { maxAge: 60 * 60 * 24, domain }); - if (domain !== undefined) { - deleteCookieClient('stack-refresh', { domain }); - } - deleteCookieClient('stack-refresh'); // delete cookie name from previous versions (for backwards-compatibility) + const refreshToken = value.refreshToken; + const updatedAt = refreshToken ? Date.now() : null; + const refreshCookieValue = refreshToken && updatedAt !== null ? this._formatRefreshCookieValue(refreshToken, updatedAt) : null; + const secure = window.location.protocol === "https:"; + const defaultName = this._getRefreshTokenDefaultCookieNameForSecure(secure); + setOrDeleteCookieClient(defaultName, refreshCookieValue, { maxAge: 60 * 60 * 24 * 365, secure }); + const accessTokenPayload = refreshToken && value.accessToken ? JSON.stringify([refreshToken, value.accessToken]) : null; + setOrDeleteCookieClient(this._accessTokenCookieName, accessTokenPayload, { maxAge: 60 * 60 * 24 }); + this._deleteLegacyBrowserRefreshCookies(); + this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "browser"); hasSucceededInWriting = true; } catch (e) { if (!isBrowserLike()) { @@ -524,29 +708,21 @@ export class _StackClientAppImplIncomplete(tokens); store.onChange((value) => { runAsynchronously(async () => { - const domain = await this._getServerCookieDomain(); - // TODO HACK this is a bit of a hack; while the order happens to work in practice (because the only actual - // async operation is waiting for the `cookies()` to resolve which always happens at the same time during - // the same request), it's not guaranteed to be free of race conditions if there are many updates happening - // at the same time - // - // instead, we should create a per-request cookie helper outside of the store onChange and reuse that - // - // but that's kinda hard to do because Next.js doesn't expose a documented way to find out which request - // we're currently processing, and hence we can't find out which per-request cookie helper to use - // - // so hack it is + const refreshToken = value.refreshToken; + const updatedAt = refreshToken ? Date.now() : null; + const refreshCookieValue = refreshToken ? this._formatRefreshCookieValue(refreshToken, updatedAt!) : null; + const secure = await isSecureCookieContext(); + const defaultName = this._getRefreshTokenDefaultCookieNameForSecure(secure); await Promise.all([ - setOrDeleteCookie(this._refreshTokenCookieName, value.refreshToken, { maxAge: 60 * 60 * 24 * 365, noOpIfServerComponent: true, domain }), - setOrDeleteCookie(this._accessTokenCookieName, value.accessToken ? JSON.stringify([value.refreshToken, value.accessToken]) : null, { maxAge: 60 * 60 * 24, noOpIfServerComponent: true, domain }), + setOrDeleteCookie(defaultName, refreshCookieValue, { maxAge: 60 * 60 * 24 * 365, noOpIfServerComponent: true }), + setOrDeleteCookie(this._accessTokenCookieName, refreshToken && value.accessToken ? JSON.stringify([refreshToken, value.accessToken]) : null, { maxAge: 60 * 60 * 24, noOpIfServerComponent: true }), ]); + await this._deleteLegacyServerRefreshCookies(); + this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "server"); }); }); return store; @@ -581,10 +757,7 @@ export class _StackClientAppImplIncomplete({ - refreshToken: parsed[this._refreshTokenCookieName] || parsed['stack-refresh'] || null, // keep old cookie name for backwards-compatibility - accessToken: parsed[this._accessTokenCookieName] || null, - }); + const res = new Store(this._getTokensFromCookies(parsed)); this._requestTokenStores.set(tokenStoreInit, res); return res; } else if ("accessToken" in tokenStoreInit || "refreshToken" in tokenStoreInit) { diff --git a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts index 1196492543..39dde2d8ce 100644 --- a/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts +++ b/packages/template/src/lib/stack-app/apps/interfaces/client-app.ts @@ -15,7 +15,6 @@ export type StackClientAppConstructorOptions, oauthScopesOnSignIn?: Partial, tokenStore?: TokenStoreInit, - shareCookiesAcrossSubdomains?: boolean, redirectMethod?: RedirectMethod, inheritsFrom?: StackClientApp, From 1c1b611f8eabfdecb4b3fcde2a7df9702dbbc370 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Wed, 22 Oct 2025 18:57:54 -0700 Subject: [PATCH 05/14] fix test --- apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts index dfee0cdf20..1787ceca00 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts @@ -43,6 +43,7 @@ it("gets current project (internal)", async ({ expect }) => { "client_team_creation_enabled": true, "client_user_deletion_enabled": false, "credential_enabled": true, + "domains": [], "enabled_oauth_providers": [ { "id": "github" }, { "id": "google" }, From ca451711a492c63d6f69bccb1106d9d6810e6383 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 23 Oct 2025 10:10:22 -0700 Subject: [PATCH 06/14] fix tld --- packages/stack-shared/package.json | 1 + packages/stack-shared/src/utils/urls.tsx | 17 +++++++++++------ pnpm-lock.yaml | 16 ++++++++++++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/packages/stack-shared/package.json b/packages/stack-shared/package.json index 6ac1e47702..9b20a96d6d 100644 --- a/packages/stack-shared/package.json +++ b/packages/stack-shared/package.json @@ -66,6 +66,7 @@ "jose": "^5.2.2", "oauth4webapi": "^2.10.3", "semver": "^7.6.3", + "tldts": "^7.0.17", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/packages/stack-shared/src/utils/urls.tsx b/packages/stack-shared/src/utils/urls.tsx index 3046ffa691..b1f5c98ce4 100644 --- a/packages/stack-shared/src/utils/urls.tsx +++ b/packages/stack-shared/src/utils/urls.tsx @@ -1,5 +1,5 @@ +import { getDomain, parse } from "tldts"; import { generateSecureRandomString } from "./crypto"; -import { isIpAddress } from "./ips"; import { templateIdentity } from "./strings"; export function createUrlIfValid(...args: ConstructorParameters) { @@ -227,15 +227,19 @@ import.meta.vitest?.test("isLocalhost", ({ expect }) => { }); export function extractBaseDomainFromHost(host: string): string { - const hostWithoutPort = host.split(':')[0]; - if (hostWithoutPort === 'localhost' || isIpAddress(hostWithoutPort)) { + const hostWithoutPort = host.split(":")[0]; + if (!hostWithoutPort) { return hostWithoutPort; } - const parts = hostWithoutPort.split('.'); - if (parts.length < 2) { + if (hostWithoutPort === "localhost") { return hostWithoutPort; } - return parts.slice(-2).join('.'); + const parsed = parse(hostWithoutPort, { allowPrivateDomains: true }); + if (parsed.isIp) { + return hostWithoutPort; + } + const domain = getDomain(hostWithoutPort, { allowPrivateDomains: true }); + return domain ?? hostWithoutPort; } import.meta.vitest?.test("extractBaseDomainFromHost", ({ expect }) => { expect(extractBaseDomainFromHost("app.example.com")).toBe("example.com"); @@ -244,6 +248,7 @@ import.meta.vitest?.test("extractBaseDomainFromHost", ({ expect }) => { expect(extractBaseDomainFromHost("localhost:3000")).toBe("localhost"); expect(extractBaseDomainFromHost("127.0.0.1")).toBe("127.0.0.1"); expect(extractBaseDomainFromHost("127.0.0.1:3000")).toBe("127.0.0.1"); + expect(extractBaseDomainFromHost("app.example.co.uk")).toBe("example.co.uk"); }); export function isRelative(url: string) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c7c09165fd..c347ec2de0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1551,6 +1551,9 @@ importers: semver: specifier: ^7.6.3 version: 7.6.3 + tldts: + specifier: ^7.0.17 + version: 7.0.17 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -15287,6 +15290,13 @@ packages: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} + tldts-core@7.0.17: + resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==} + + tldts@7.0.17: + resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==} + hasBin: true + tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -33651,6 +33661,12 @@ snapshots: tinyspy@2.2.1: {} + tldts-core@7.0.17: {} + + tldts@7.0.17: + dependencies: + tldts-core: 7.0.17 + tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 From b2dcb824b4b311403160a4fb625dd214b09c95a7 Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Thu, 23 Oct 2025 10:48:43 -0700 Subject: [PATCH 07/14] improve tests --- apps/e2e/tests/js/app.test.ts | 148 -------------- apps/e2e/tests/js/cookies.test.ts | 322 ++++++++++++++++++++++++++++++ 2 files changed, 322 insertions(+), 148 deletions(-) create mode 100644 apps/e2e/tests/js/cookies.test.ts diff --git a/apps/e2e/tests/js/app.test.ts b/apps/e2e/tests/js/app.test.ts index 04fc7d3a19..18cf51887f 100644 --- a/apps/e2e/tests/js/app.test.ts +++ b/apps/e2e/tests/js/app.test.ts @@ -1,8 +1,4 @@ -import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes"; -import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids"; -import { TextEncoder } from "util"; -import { vi } from "vitest"; import { it } from "../helpers"; import { createApp, scaffoldProject } from "./js-helpers"; @@ -96,147 +92,3 @@ it("should throw a helpful error when destructuring user", async ({ expect }) => const accessServerUser = () => (serverUser as any).user; expect(accessServerUser).toThrowError("Stack Auth: useUser() already returns the user object. Use `const user = useUser()` (or `const user = await app.getUser()`) instead of destructuring it like `const { user } = ...`."); }); - -it("should set refresh token cookies for trusted parent domains", async ({ expect }) => { - const cookieWrites: string[] = []; - const cookieStore = new Map(); - - const fakeSessionStorage = { - getItem: () => null, - setItem: () => { }, - removeItem: () => { }, - clear: () => { }, - }; - - const fakeLocation = { - host: "app.example.com", - hostname: "app.example.com", - href: "https://app.example.com/", - origin: "https://app.example.com", - protocol: "https:", - }; - - const fakeWindow = { - location: fakeLocation, - sessionStorage: fakeSessionStorage, - } as any; - - const fakeDocument: any = { - createElement: () => ({}), - }; - Object.defineProperty(fakeDocument, "cookie", { - configurable: true, - get: () => Array.from(cookieStore.entries()).map(([name, value]) => `${name}=${value}`).join("; "), - set: (value: string) => { - cookieWrites.push(value); - const [pair] = value.split(";").map((part) => part.trim()).filter(Boolean); - if (!pair) { - return; - } - const [rawName, ...rawValueParts] = pair.split("="); - const name = rawName.trim(); - const storedValue = rawValueParts.join("="); - if (storedValue === "") { - cookieStore.delete(name); - } else { - cookieStore.set(name, storedValue); - } - }, - }); - - vi.stubGlobal("window", fakeWindow); - vi.stubGlobal("document", fakeDocument); - vi.stubGlobal("sessionStorage", fakeSessionStorage); - - const { clientApp } = await createApp( - { - config: { - domains: [ - { domain: "https://example.com", handlerPath: "/handler" }, - { domain: "https://*.example.com", handlerPath: "/handler" }, - ], - } - }, - { - client: { - tokenStore: "cookie", - noAutomaticPrefetch: true, - }, - } - ); - - const email = `${crypto.randomUUID()}@trusted-cookie.test`; - const password = "password"; - const signUpResult = await clientApp.signUpWithCredential({ - email, - password, - verificationCallbackUrl: "http://localhost:3000", - noRedirect: true, - }); - expect(signUpResult.status).toBe("ok"); - - const signInResult = await clientApp.signInWithCredential({ - email, - password, - noRedirect: true, - }); - expect(signInResult.status).toBe("ok"); - - const defaultCookieName = `__Host-stack-refresh-${clientApp.projectId}--default`; - const customCookieName = `stack-refresh-${clientApp.projectId}--custom-${encodeBase32(new TextEncoder().encode("example.com"))}`; - - const waitUntil = async (predicate: () => boolean, timeoutMs: number) => { - const startedAt = Date.now(); - while (!predicate()) { - if (Date.now() - startedAt > timeoutMs) { - return false; - } - await new Promise((resolve) => setTimeout(resolve, 100)); - } - return true; - }; - - const defaultReady = await waitUntil(() => cookieStore.has(defaultCookieName), 2_000); - expect(defaultReady).toBe(true); - - const customReady = await waitUntil(() => cookieStore.has(customCookieName), 10_000); - expect(customReady).toBe(true); - - expect(cookieStore.has(defaultCookieName)).toBe(true); - expect(cookieStore.has(customCookieName)).toBe(true); - - const valuesEqual = await waitUntil(() => cookieStore.get(customCookieName) === cookieStore.get(defaultCookieName), 10_000); - expect(valuesEqual).toBe(true); - - const defaultValue = cookieStore.get(defaultCookieName)!; - const parsedValue = JSON.parse(decodeURIComponent(defaultValue)); - expect(typeof parsedValue.refresh_token).toBe("string"); - expect(parsedValue.refresh_token.length).toBeGreaterThan(10); - expect(typeof parsedValue.updated_at).toBe("number"); - - const parseCookieAttributes = (name: string) => { - const raw = [...cookieWrites].reverse().find((entry) => entry.trim().toLowerCase().startsWith(`${name.toLowerCase()}=`)); - if (!raw) { - return null; - } - const [, ...attributeParts] = raw.split(";").map((part) => part.trim()).filter(Boolean); - const attrs: Record = {}; - for (const attribute of attributeParts) { - const [attrName, ...attrValueParts] = attribute.split("="); - attrs[attrName.toLowerCase()] = attrValueParts.join("=") || ""; - } - return attrs; - }; - - const defaultAttrs = parseCookieAttributes(defaultCookieName); - expect(defaultAttrs?.domain).toBeUndefined(); - expect(defaultAttrs).not.toBeNull(); - expect(Object.prototype.hasOwnProperty.call(defaultAttrs!, "secure")).toBe(true); - - const customAttrs = parseCookieAttributes(customCookieName); - expect(customAttrs?.domain).toBe("example.com"); - - const legacyProjectCookie = `stack-refresh-${clientApp.projectId}`; - expect(cookieWrites.some((entry) => entry.toLowerCase().startsWith(`${legacyProjectCookie.toLowerCase()}=`) && entry.toLowerCase().includes("expires="))).toBe(true); - expect(cookieWrites.some((entry) => entry.toLowerCase().startsWith("stack-refresh=") && entry.toLowerCase().includes("expires="))).toBe(true); -}); diff --git a/apps/e2e/tests/js/cookies.test.ts b/apps/e2e/tests/js/cookies.test.ts new file mode 100644 index 0000000000..7c8f3671c2 --- /dev/null +++ b/apps/e2e/tests/js/cookies.test.ts @@ -0,0 +1,322 @@ +import { encodeBase32 } from "@stackframe/stack-shared/dist/utils/bytes"; +import { TextEncoder } from "util"; +import { vi } from "vitest"; +import { it } from "../helpers"; +import { createApp } from "./js-helpers"; + +type BrowserEnvOptions = { + host?: string, + protocol?: "https:" | "http:", +}; + +type BrowserEnv = { + cookieStore: Map, + cookieWrites: string[], + location: { + host: string, + hostname: string, + href: string, + origin: string, + protocol: string, + }, +}; + +function setupBrowserCookieEnv(options: BrowserEnvOptions = {}): BrowserEnv { + const { + host = "app.example.com", + protocol = "https:", + } = options; + + const cookieStore = new Map(); + const cookieWrites: string[] = []; + + const fakeSessionStorage = { + getItem: () => null, + setItem: () => undefined, + removeItem: () => undefined, + clear: () => undefined, + }; + + const location = { + host, + hostname: host, + href: `${protocol}//${host}/`, + origin: `${protocol}//${host}`, + protocol, + }; + + const fakeWindow = { + location, + sessionStorage: fakeSessionStorage, + } as any; + + const fakeDocument: any = { + createElement: () => ({}), + }; + Object.defineProperty(fakeDocument, "cookie", { + configurable: true, + get: () => Array.from(cookieStore.entries()).map(([name, value]) => `${name}=${value}`).join("; "), + set: (value: string) => { + cookieWrites.push(value); + const [pair] = value.split(";").map((part) => part.trim()).filter(Boolean); + if (!pair) { + return; + } + const [rawName, ...rawValueParts] = pair.split("="); + const name = rawName.trim(); + const storedValue = rawValueParts.join("="); + if (storedValue === "") { + cookieStore.delete(name); + } else { + cookieStore.set(name, storedValue); + } + }, + }); + + vi.stubGlobal("window", fakeWindow); + vi.stubGlobal("document", fakeDocument); + vi.stubGlobal("sessionStorage", fakeSessionStorage); + + return { + cookieStore, + cookieWrites, + location, + }; +} + +async function waitUntil(predicate: () => boolean, timeoutMs: number, intervalMs = 100): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + return false; + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + return true; +} + +function findCookieAttributes(cookieWrites: string[], name: string): Map | null { + const raw = [...cookieWrites].reverse().find((entry) => entry.trim().toLowerCase().startsWith(`${name.toLowerCase()}=`)); + if (!raw) { + return null; + } + const [, ...attributeParts] = raw.split(";").map((part) => part.trim()).filter(Boolean); + const attrs = new Map(); + for (const attribute of attributeParts) { + const [attrName, ...attrValueParts] = attribute.split("="); + attrs.set(attrName.toLowerCase(), attrValueParts.join("=") || ""); + } + return attrs; +} + +function getDefaultRefreshCookieName(projectId: string, secure: boolean): string { + const prefix = secure ? "__Host-" : ""; + return `${prefix}stack-refresh-${projectId}--default`; +} + +function getCustomRefreshCookieName(projectId: string, domain: string): string { + const encoded = encodeBase32(new TextEncoder().encode(domain.toLowerCase())); + return `stack-refresh-${projectId}--custom-${encoded}`; +} + +it("should set refresh token cookies for trusted parent domains", async ({ expect }) => { + const { cookieStore, cookieWrites } = setupBrowserCookieEnv({ protocol: "https:" }); + + const { clientApp } = await createApp( + { + config: { + domains: [ + { domain: "https://example.com", handlerPath: "/handler" }, + { domain: "https://*.example.com", handlerPath: "/handler" }, + ], + }, + }, + { + client: { + tokenStore: "cookie", + noAutomaticPrefetch: true, + }, + }, + ); + + const email = `${crypto.randomUUID()}@trusted-cookie.test`; + const password = "password"; + + const signUpResult = await clientApp.signUpWithCredential({ + email, + password, + verificationCallbackUrl: "http://localhost:3000", + noRedirect: true, + }); + expect(signUpResult.status).toBe("ok"); + + const signInResult = await clientApp.signInWithCredential({ + email, + password, + noRedirect: true, + }); + expect(signInResult.status).toBe("ok"); + + const defaultCookieName = getDefaultRefreshCookieName(clientApp.projectId, true); + const customCookieName = getCustomRefreshCookieName(clientApp.projectId, "example.com"); + + const defaultReady = await waitUntil(() => cookieStore.has(defaultCookieName), 2_000); + expect(defaultReady).toBe(true); + + const customReady = await waitUntil(() => cookieStore.has(customCookieName), 10_000); + expect(customReady).toBe(true); + + expect(cookieStore.has(defaultCookieName)).toBe(true); + expect(cookieStore.has(customCookieName)).toBe(true); + + const valuesEqual = await waitUntil(() => cookieStore.get(customCookieName) === cookieStore.get(defaultCookieName), 10_000); + expect(valuesEqual).toBe(true); + + const defaultValue = cookieStore.get(defaultCookieName)!; + const parsedValue = JSON.parse(decodeURIComponent(defaultValue)); + expect(typeof parsedValue.refresh_token).toBe("string"); + expect(parsedValue.refresh_token.length).toBeGreaterThan(10); + expect(typeof parsedValue.updated_at).toBe("number"); + + const defaultAttrs = findCookieAttributes(cookieWrites, defaultCookieName); + expect(defaultAttrs).not.toBeNull(); + expect(defaultAttrs?.has("secure")).toBe(true); + expect(defaultAttrs?.get("domain")).toBeUndefined(); + + const customAttrs = findCookieAttributes(cookieWrites, customCookieName); + expect(customAttrs?.get("domain")).toBe("example.com"); + + const legacyProjectCookie = `stack-refresh-${clientApp.projectId}`; + expect(cookieWrites.some((entry) => entry.toLowerCase().startsWith(`${legacyProjectCookie.toLowerCase()}=`) && entry.toLowerCase().includes("expires="))).toBe(true); + expect(cookieWrites.some((entry) => entry.toLowerCase().startsWith("stack-refresh=") && entry.toLowerCase().includes("expires="))).toBe(true); +}); + +it("should avoid setting custom refresh cookies when no trusted parent domain is configured", async ({ expect }) => { + const { cookieStore } = setupBrowserCookieEnv({ protocol: "https:" }); + + const { clientApp } = await createApp( + { + config: { + domains: [ + { domain: "https://example.com", handlerPath: "/handler" }, + { domain: "https://tenant.example.com", handlerPath: "/handler" }, + ], + }, + }, + { + client: { + tokenStore: "cookie", + noAutomaticPrefetch: true, + }, + }, + ); + + const email = `${crypto.randomUUID()}@no-parent-cookie.test`; + const password = "password"; + + const signUpResult = await clientApp.signUpWithCredential({ + email, + password, + verificationCallbackUrl: "http://localhost:3000", + noRedirect: true, + }); + expect(signUpResult.status).toBe("ok"); + + const signInResult = await clientApp.signInWithCredential({ + email, + password, + noRedirect: true, + }); + expect(signInResult.status).toBe("ok"); + + const defaultCookieName = getDefaultRefreshCookieName(clientApp.projectId, true); + const customCookieName = getCustomRefreshCookieName(clientApp.projectId, "example.com"); + + const defaultReady = await waitUntil(() => cookieStore.has(defaultCookieName), 2_000); + expect(defaultReady).toBe(true); + + const customReady = await waitUntil(() => cookieStore.has(customCookieName), 2_000); + expect(customReady).toBe(false); + expect(cookieStore.has(customCookieName)).toBe(false); +}); + +it("should omit secure-only defaults when running on http origins", async ({ expect }) => { + const { cookieStore, cookieWrites, location } = setupBrowserCookieEnv({ protocol: "http:", host: "app.example.com" }); + + const { clientApp } = await createApp( + { + config: { + domains: [ + { domain: "https://example.com", handlerPath: "/handler" }, + { domain: "https://*.example.com", handlerPath: "/handler" }, + ], + }, + }, + { + client: { + tokenStore: "cookie", + noAutomaticPrefetch: true, + }, + }, + ); + + // Sanity-check that we are in an HTTP context. + expect(location.protocol).toBe("http:"); + + const email = `${crypto.randomUUID()}@http-cookie.test`; + const password = "password"; + + const signUpResult = await clientApp.signUpWithCredential({ + email, + password, + verificationCallbackUrl: "http://localhost:3000", + noRedirect: true, + }); + expect(signUpResult.status).toBe("ok"); + + const signInResult = await clientApp.signInWithCredential({ + email, + password, + noRedirect: true, + }); + expect(signInResult.status).toBe("ok"); + + const insecureDefaultCookieName = getDefaultRefreshCookieName(clientApp.projectId, false); + const secureDefaultCookieName = getDefaultRefreshCookieName(clientApp.projectId, true); + + const defaultReady = await waitUntil(() => cookieStore.has(insecureDefaultCookieName), 2_000); + expect(defaultReady).toBe(true); + + expect(cookieStore.has(secureDefaultCookieName)).toBe(false); + + const insecureAttrs = findCookieAttributes(cookieWrites, insecureDefaultCookieName); + expect(insecureAttrs).not.toBeNull(); + expect(insecureAttrs?.has("secure")).toBe(false); + expect(insecureAttrs?.get("domain")).toBeUndefined(); +}); + +it("should read the newest refresh token payload from cookie storage", async ({ expect }) => { + const { clientApp } = await createApp(); + + const defaultCookieName = getDefaultRefreshCookieName(clientApp.projectId, true); + const customCookieName = getCustomRefreshCookieName(clientApp.projectId, "example.com"); + + const staleCookieValue = JSON.stringify({ + refresh_token: "stale-token", + updated_at: 1700000000000, + }); + const freshCookieValue = JSON.stringify({ + refresh_token: "fresh-token", + updated_at: 1800000000000, + }); + + const cookieMap: Record = { + [defaultCookieName]: staleCookieValue, + [customCookieName]: freshCookieValue, + "stack-access": JSON.stringify(["fresh-token", "fresh-access-token"]), + }; + + const tokens = (clientApp as any)._getTokensFromCookies(cookieMap); + expect(tokens.refreshToken).toBe("fresh-token"); + expect(tokens.accessToken).toBe("fresh-access-token"); +}); From 221a12e42cd4eae7918bb322f5ad12b74e9aa8eb Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 28 Oct 2025 09:33:44 -0700 Subject: [PATCH 08/14] wip --- packages/stack-shared/src/utils/urls.tsx | 26 --- packages/template/src/lib/cookie.ts | 88 +++++--- .../apps/implementations/client-app-impl.ts | 205 +++++++----------- 3 files changed, 129 insertions(+), 190 deletions(-) diff --git a/packages/stack-shared/src/utils/urls.tsx b/packages/stack-shared/src/utils/urls.tsx index b1f5c98ce4..cb19763faa 100644 --- a/packages/stack-shared/src/utils/urls.tsx +++ b/packages/stack-shared/src/utils/urls.tsx @@ -1,4 +1,3 @@ -import { getDomain, parse } from "tldts"; import { generateSecureRandomString } from "./crypto"; import { templateIdentity } from "./strings"; @@ -226,31 +225,6 @@ import.meta.vitest?.test("isLocalhost", ({ expect }) => { expect(isLocalhost("")).toBe(false); }); -export function extractBaseDomainFromHost(host: string): string { - const hostWithoutPort = host.split(":")[0]; - if (!hostWithoutPort) { - return hostWithoutPort; - } - if (hostWithoutPort === "localhost") { - return hostWithoutPort; - } - const parsed = parse(hostWithoutPort, { allowPrivateDomains: true }); - if (parsed.isIp) { - return hostWithoutPort; - } - const domain = getDomain(hostWithoutPort, { allowPrivateDomains: true }); - return domain ?? hostWithoutPort; -} -import.meta.vitest?.test("extractBaseDomainFromHost", ({ expect }) => { - expect(extractBaseDomainFromHost("app.example.com")).toBe("example.com"); - expect(extractBaseDomainFromHost("sub.app.example.com")).toBe("example.com"); - expect(extractBaseDomainFromHost("example.com")).toBe("example.com"); - expect(extractBaseDomainFromHost("localhost:3000")).toBe("localhost"); - expect(extractBaseDomainFromHost("127.0.0.1")).toBe("127.0.0.1"); - expect(extractBaseDomainFromHost("127.0.0.1:3000")).toBe("127.0.0.1"); - expect(extractBaseDomainFromHost("app.example.co.uk")).toBe("example.co.uk"); -}); - export function isRelative(url: string) { const randomDomain = `${generateSecureRandomString()}.stack-auth.example.com`; const u = createUrlIfValid(url, `https://${randomDomain}`); diff --git a/packages/template/src/lib/cookie.ts b/packages/template/src/lib/cookie.ts index d6d0382380..651cdf56a1 100644 --- a/packages/template/src/lib/cookie.ts +++ b/packages/template/src/lib/cookie.ts @@ -53,9 +53,7 @@ export async function createCookieHelper(): Promise { export function createBrowserCookieHelper(): CookieHelper { return { get: getCookieClient, - getAll: () => { - return Cookies.get(); - }, + getAll: getAllCookiesClient, set: setCookieClient, setOrDelete: setOrDeleteCookieClient, delete: deleteCookieClient, @@ -81,25 +79,26 @@ function createNextCookieHelper( ): CookieHelper { const cookieHelper = { get: (name: string) => { + const all = cookieHelper.getAll(); + return all[name] ?? null; + }, + getAll: () => { // set a helper cookie, see comment in `NextCookieHelper.set` below try { - rscCookiesAwaited.set("stack-is-https", "true", { secure: true }); + rscCookiesAwaited.set("stack-is-https", "true", { secure: true }); } catch (e) { if ( typeof e === 'object' - && e !== null - && 'message' in e - && typeof e.message === 'string' - && e.message.includes('Cookies can only be modified in a Server Action or Route Handler') + && e !== null + && 'message' in e + && typeof e.message === 'string' + && e.message.includes('Cookies can only be modified in a Server Action or Route Handler') ) { // ignore } else { throw e; } } - return rscCookiesAwaited.get(name)?.value ?? null; - }, - getAll: () => { const all = rscCookiesAwaited.getAll(); return all.reduce((acc, entry) => { acc[entry.name] = entry.value; @@ -120,10 +119,7 @@ function createNextCookieHelper( // Note that malicious clients could theoretically manipulate the `stack-is-https` cookie or // the `X-Forwarded-Proto` header; that wouldn't cause any trouble except for themselves, // though. - let isSecureCookie = !!rscCookiesAwaited.get("stack-is-https"); - if (rscHeadersAwaited.get("x-forwarded-proto") === "https") { - isSecureCookie = true; - } + const isSecureCookie = determineSecureFromServerContext(rscCookiesAwaited, rscHeadersAwaited); try { rscCookiesAwaited.set(name, value, { @@ -159,10 +155,15 @@ function createNextCookieHelper( // END_PLATFORM export function getCookieClient(name: string): string | null { + const all = getAllCookiesClient(); + return all[name] ?? null; +} + +export function getAllCookiesClient(): Record { ensureClient(); // set a helper cookie, see comment in `NextCookieHelper.set` above Cookies.set("stack-is-https", "true", { secure: true }); - return Cookies.get(name) ?? null; + return Cookies.get(); } export async function getCookie(name: string): Promise { @@ -172,27 +173,51 @@ export async function getCookie(name: string): Promise { export async function isSecure(): Promise { if (isBrowserLike()) { - return typeof window !== "undefined" && window.location.protocol === "https:"; + return determineSecureFromClientContext(); } // IF_PLATFORM next - const cookies = await rscCookies(); - const headers = await rscHeaders(); + return determineSecureFromServerContext(await rscCookies(), await rscHeaders()); + // END_PLATFORM + return false; +} + +function determineSecureFromClientContext(): boolean { + return typeof window !== "undefined" && window.location.protocol === "https:"; +} + +function determineSecureFromServerContext( + cookies: Awaited>, + headers: Awaited>, +): boolean { + let isSecureCookie = !!cookies.get("stack-is-https"); if (headers.get("x-forwarded-proto") === "https") { - return true; + isSecureCookie = true; } - if (cookies.get("stack-is-https")) { - return true; + return isSecureCookie; +} + +function setCookieClientInternal(name: string, value: string, options: SetCookieOptions = {}) { + const secure = options.secure ?? determineSecureFromClientContext(); + Cookies.set(name, value, { + expires: options.maxAge === undefined ? undefined : new Date(Date.now() + (options.maxAge) * 1000), + domain: options.domain, + secure, + }); +} + +function deleteCookieClientInternal(name: string, options: DeleteCookieOptions = {}) { + if (options.domain !== undefined) { + Cookies.remove(name, { domain: options.domain }); } - // END_PLATFORM - return false; + Cookies.remove(name); } export function setOrDeleteCookieClient(name: string, value: string | null, options: SetCookieOptions & DeleteCookieOptions = {}) { ensureClient(); if (value === null) { - deleteCookieClient(name, options); + deleteCookieClientInternal(name, options); } else { - setCookieClient(name, value, options); + setCookieClientInternal(name, value, options); } } @@ -203,10 +228,7 @@ export async function setOrDeleteCookie(name: string, value: string | null, opti export function deleteCookieClient(name: string, options: DeleteCookieOptions = {}) { ensureClient(); - if (options.domain !== undefined) { - Cookies.remove(name, { domain: options.domain }); - } - Cookies.remove(name); + deleteCookieClientInternal(name, options); } export async function deleteCookie(name: string, options: DeleteCookieOptions = {}) { @@ -216,11 +238,7 @@ export async function deleteCookie(name: string, options: DeleteCookieOptions = export function setCookieClient(name: string, value: string, options: SetCookieOptions = {}) { ensureClient(); - Cookies.set(name, value, { - expires: options.maxAge === undefined ? undefined : new Date(Date.now() + (options.maxAge) * 1000), - domain: options.domain, - secure: options.secure, - }); + setCookieClientInternal(name, value, options); } export async function setCookie(name: string, value: string, options: SetCookieOptions = {}) { diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 52c49e74b2..7f48ed387d 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -28,7 +28,7 @@ import { suspend, suspendIfSsr } from "@stackframe/stack-shared/dist/utils/react import { Result } from "@stackframe/stack-shared/dist/utils/results"; import { Store, storeLock } from "@stackframe/stack-shared/dist/utils/stores"; import { deindent, mergeScopeStrings, stringCompare } from "@stackframe/stack-shared/dist/utils/strings"; -import { extractBaseDomainFromHost, getRelativePart, isRelative } from "@stackframe/stack-shared/dist/utils/urls"; +import { getRelativePart, isRelative } from "@stackframe/stack-shared/dist/utils/urls"; import { generateUuid } from "@stackframe/stack-shared/dist/utils/uuids"; import * as cookie from "cookie"; import * as NextNavigationUnscrambled from "next/navigation"; // import the entire module to get around some static compiler warnings emitted by Next.js in some cases | THIS_LINE_PLATFORM next @@ -50,6 +50,7 @@ import { ActiveSession, Auth, BaseUser, CurrentUser, InternalUserExtra, OAuthPro import { StackClientApp, StackClientAppConstructorOptions, StackClientAppJson } from "../interfaces/client-app"; import { _StackAdminAppImplIncomplete } from "./admin-app-impl"; import { TokenObject, clientVersion, createCache, createCacheBySession, createEmptyTokenStore, getBaseUrl, getDefaultExtraRequestHeaders, getDefaultProjectId, getDefaultPublishableClientKey, getUrls, resolveConstructorOptions } from "./common"; +import { parseJson } from "@stackframe/stack-shared/dist/utils/json"; // IF_PLATFORM react-like import { useAsyncCache } from "./common"; @@ -58,7 +59,7 @@ import { useAsyncCache } from "./common"; let isReactServer = false; // IF_PLATFORM next import * as sc from "@stackframe/stack-sc"; -import { cookies, headers as nextHeaders } from "@stackframe/stack-sc"; +import { cookies } from "@stackframe/stack-sc"; isReactServer = sc.isReactServer; // NextNavigation.useRouter does not exist in react-server environments and some bundlers try to be helpful and throw a warning. Ignore the warning. @@ -272,6 +273,10 @@ export class _StackClientAppImplIncomplete await this._getPartialUserFromConvex(ctx as any) ); + private readonly _trustedParentDomainCache = createCache<[string], string | null>( + async ([domain]) => await this._getTrustedParentDomain(domain) + ); + private _anonymousSignUpInProgress: Promise<{ accessToken: string, refreshToken: string }> | null = null; protected async _createCookieHelper(): Promise { @@ -419,8 +424,10 @@ export class _StackClientAppImplIncomplete>(); protected _requestTokenStores = new WeakMap>(); protected _storedBrowserCookieTokenStore: Store | null = null; - private _lastCustomRefreshCookieDomain: string | null = null; - private _lastCustomRefreshCookieUpdatedAt: number | null = null; + private _mostRecentQueuedCookieRefreshIndex: number = 0; + protected get _legacyRefreshTokenCookieName() { + return `stack-refresh-${this.projectId}`; + } protected get _refreshTokenCookieName() { return `stack-refresh-${this.projectId}`; } @@ -437,30 +444,28 @@ export class _StackClientAppImplIncomplete): { refreshToken: string | null, updatedAt: number | null } { const legacyToken = cookies[this._refreshTokenCookieName] ?? cookies["stack-refresh"]; @@ -496,19 +501,18 @@ export class _StackClientAppImplIncomplete { if (!isBrowserLike()) { - return {}; + throw new StackAssertionError("Cannot get browser cookies on the server!"); } return cookie.parse(document.cookie || ""); } - private _getBrowserBaseDomain(): string | undefined { - if (!isBrowserLike()) { - return undefined; - } - const host = window.location.host; - return host ? extractBaseDomainFromHost(host) : undefined; - } - private async _getServerBaseDomain(): Promise { - // IF_PLATFORM next - try { - const resolvedHeaders = typeof nextHeaders === "function" ? await nextHeaders() : nextHeaders; - const hostHeader = resolvedHeaders?.get("x-forwarded-host") ?? resolvedHeaders?.get("host"); - return hostHeader ? extractBaseDomainFromHost(hostHeader) : undefined; - } catch { - return undefined; - } - // END_PLATFORM - return undefined; - } private _deleteLegacyBrowserRefreshCookies() { - deleteCookieClient(this._refreshTokenCookieName); + deleteCookieClient(this._legacyRefreshTokenCookieName); deleteCookieClient("stack-refresh"); - const domain = this._getBrowserBaseDomain(); - if (domain) { - deleteCookieClient(this._refreshTokenCookieName, { domain }); - deleteCookieClient("stack-refresh", { domain }); - } } private async _deleteLegacyServerRefreshCookies() { - const operations: Promise[] = [ - setOrDeleteCookie(this._refreshTokenCookieName, null, { noOpIfServerComponent: true }), - setOrDeleteCookie("stack-refresh", null, { noOpIfServerComponent: true }), - ]; - const domain = await this._getServerBaseDomain(); - if (domain) { - operations.push( - setOrDeleteCookie(this._refreshTokenCookieName, null, { domain, noOpIfServerComponent: true }), - setOrDeleteCookie("stack-refresh", null, { domain, noOpIfServerComponent: true }), - ); - } - await Promise.all(operations); + await setOrDeleteCookie(this._legacyRefreshTokenCookieName, null, { noOpIfServerComponent: true }); + await setOrDeleteCookie("stack-refresh", null, { noOpIfServerComponent: true }); } private _queueCustomRefreshCookieUpdate(refreshToken: string | null, updatedAt: number | null, context: "browser" | "server") { runAsynchronously(async () => { - const domain = await this._fetchTrustedParentDomain(); - const previousDomain = this._lastCustomRefreshCookieDomain; - const setOrDeleteCookieWrapper = context === "browser" - ? async (...args: Parameters) => setOrDeleteCookieClient(...args) - : setOrDeleteCookie; + this._mostRecentQueuedCookieRefreshIndex++; + const updateIndex = this._mostRecentQueuedCookieRefreshIndex; + const domain = await this._trustedParentDomainCache.getOrWait([window.location.hostname], "read-write"); - const deleteCookie = async (targetDomain: string) => { - const name = this._getCustomRefreshCookieName(targetDomain); - await setOrDeleteCookieWrapper(name, null, { domain: targetDomain, noOpIfServerComponent: true }); - }; const setCookie = async (targetDomain: string, value: string) => { const name = this._getCustomRefreshCookieName(targetDomain); - await setOrDeleteCookieWrapper(name, value, { maxAge: 60 * 60 * 24 * 365, domain: targetDomain, noOpIfServerComponent: true }); + const options = { maxAge: 60 * 60 * 24 * 365, domain: targetDomain, noOpIfServerComponent: true }; + if (context === "browser") { + setOrDeleteCookieClient(name, value, options); + } else { + await setOrDeleteCookie(name, value, options); + } }; - if (!domain) { - if (previousDomain) { - await deleteCookie(previousDomain); - this._lastCustomRefreshCookieDomain = null; - } + if (domain.status === "error") { return; } - - if (previousDomain && previousDomain !== domain) { - await deleteCookie(previousDomain); - } - - if (refreshToken && updatedAt !== null) { - if (this._lastCustomRefreshCookieUpdatedAt !== null && updatedAt < this._lastCustomRefreshCookieUpdatedAt) { - // Ignore stale writes - return; - } - const value = this._formatRefreshCookieValue(refreshToken, updatedAt); - await setCookie(domain, value); - this._lastCustomRefreshCookieDomain = domain; - this._lastCustomRefreshCookieUpdatedAt = updatedAt; - } else { - await deleteCookie(domain); - this._lastCustomRefreshCookieDomain = null; - this._lastCustomRefreshCookieUpdatedAt = null; + if (updateIndex !== this._mostRecentQueuedCookieRefreshIndex) { + return; } + const value = this._formatRefreshCookieValue(refreshToken, updatedAt); + await setCookie(domain, value); }); } - private async _fetchTrustedParentDomain(): Promise { + private async _getTrustedParentDomain(currentDomain: string): Promise { const project = Result.orThrow(await this._interface.getClientProject()); - const domains = project.config.domains; - const direct = new Set(); - const wildcard = new Set(); - for (const domain of domains) { - const withoutProtocol = domain.domain.trim().replace(/^https?:\/\//, ""); - const host = withoutProtocol.split("/")[0]?.toLowerCase(); - if (host.startsWith("*.")) { - const candidate = host.slice(2); - if (candidate && !candidate.includes("*")) { - wildcard.add(candidate); - } - } else if (!host.includes("*")) { - direct.add(host); + const domains = project.config.domains.map(d => d.domain.trim().replace(/^https?:\/\//, "").split("/")[0]?.toLowerCase()); + const trustedWildcards = domains.filter(d => d.startsWith("**.")); + const parts = currentDomain.split('.'); + for (let i = parts.length - 2; i >= 0; i--) { + const parentDomain = parts.slice(i).join('.'); + if (domains.includes(parentDomain) && trustedWildcards.includes("**." + parentDomain)) { + return parentDomain; } } - const candidates = Array.from(direct).filter((domain) => wildcard.has(domain)); - candidates.sort((a, b) => { - const aParts = a.split(".").length; - const bParts = b.split(".").length; - if (aParts !== bParts) { - return aParts - bParts; - } - return stringCompare(a, b); - }); - const selected = candidates[0] ?? null; - return selected; + return null; } + protected _getBrowserCookieTokenStore(): Store { if (!isBrowserLike()) { throw new Error("Cannot use cookie token store on the server!"); @@ -712,6 +648,17 @@ export class _StackClientAppImplIncomplete(tokens); store.onChange((value) => { runAsynchronously(async () => { + // TODO HACK this is a bit of a hack; while the order happens to work in practice (because the only actual + // async operation is waiting for the `cookies()` to resolve which always happens at the same time during + // the same request), it's not guaranteed to be free of race conditions if there are many updates happening + // at the same time + // + // instead, we should create a per-request cookie helper outside of the store onChange and reuse that + // + // but that's kinda hard to do because Next.js doesn't expose a documented way to find out which request + // we're currently processing, and hence we can't find out which per-request cookie helper to use + // + // so hack it is const refreshToken = value.refreshToken; const updatedAt = refreshToken ? Date.now() : null; const refreshCookieValue = refreshToken ? this._formatRefreshCookieValue(refreshToken, updatedAt!) : null; From 7d852d3b88b3c011323d1154460a3ac97b9007ce Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 28 Oct 2025 14:23:46 -0700 Subject: [PATCH 09/14] fix type issue and simplify cookie logic --- packages/template/src/lib/cookie.ts | 3 +- .../apps/implementations/client-app-impl.ts | 102 +++++++++++++----- 2 files changed, 77 insertions(+), 28 deletions(-) diff --git a/packages/template/src/lib/cookie.ts b/packages/template/src/lib/cookie.ts index 651cdf56a1..f650132d8c 100644 --- a/packages/template/src/lib/cookie.ts +++ b/packages/template/src/lib/cookie.ts @@ -184,7 +184,7 @@ export async function isSecure(): Promise { function determineSecureFromClientContext(): boolean { return typeof window !== "undefined" && window.location.protocol === "https:"; } - +// IF_PLATFORM next function determineSecureFromServerContext( cookies: Awaited>, headers: Awaited>, @@ -195,6 +195,7 @@ function determineSecureFromServerContext( } return isSecureCookie; } +// END_PLATFORM function setCookieClientInternal(name: string, value: string, options: SetCookieOptions = {}) { const secure = options.secure ?? determineSecureFromClientContext(); diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 1bf05272e9..e9d9d08f44 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -447,6 +447,9 @@ export class _StackClientAppImplIncomplete): { refreshToken: string | null, updatedAt: number | null } { - const legacyToken = cookies[this._refreshTokenCookieName] ?? cookies["stack-refresh"]; - if (legacyToken) { - return { refreshToken: legacyToken, updatedAt: null }; + const { legacyNames, structuredPrefixes } = this._getRefreshTokenCookieNamePatterns(); + for (const name of legacyNames) { + const value = cookies[name]; + if (value) { + return { refreshToken: value, updatedAt: null }; + } } - const prefix = `${this._refreshTokenCookieName}--`; - const hostPrefix = `__Host-${this._refreshTokenCookieName}--`; let selected: { refreshToken: string, updatedAt: number | null } | null = null; for (const [name, value] of Object.entries(cookies)) { - if (!(name.startsWith(prefix) || name.startsWith(hostPrefix))) continue; + if (!structuredPrefixes.some(prefix => name.startsWith(prefix))) continue; const parsed = this._parseStructuredRefreshCookie(value); if (!parsed) continue; const candidateUpdatedAt = parsed.updatedAt ?? Number.NEGATIVE_INFINITY; @@ -533,13 +537,47 @@ export class _StackClientAppImplIncomplete): Set { + const { legacyNames, structuredPrefixes } = this._getRefreshTokenCookieNamePatterns(); + const names = new Set(); + for (const name of legacyNames) { + if (cookies[name]) { + names.add(name); + } + } + for (const name of Object.keys(cookies)) { + if (structuredPrefixes.some(prefix => name.startsWith(prefix))) { + names.add(name); + } + } + return names; + } + private _prepareRefreshCookieUpdate( + existingCookies: Record, + refreshToken: string | null, + accessToken: string | null, + defaultCookieName: string, + ) { + const cookieNames = this._collectRefreshTokenCookieNames(existingCookies); + cookieNames.delete(defaultCookieName); + const updatedAt = refreshToken ? Date.now() : null; + const refreshCookieValue = refreshToken && updatedAt !== null ? this._formatRefreshCookieValue(refreshToken, updatedAt) : null; + const accessTokenPayload = this._formatAccessCookieValue(refreshToken, accessToken); + return { + updatedAt, + refreshCookieValue, + accessTokenPayload, + cookieNamesToDelete: [...cookieNames], + }; } private _queueCustomRefreshCookieUpdate(refreshToken: string | null, updatedAt: number | null, context: "browser" | "server") { runAsynchronously(async () => { @@ -547,7 +585,7 @@ export class _StackClientAppImplIncomplete { + const setCookie = async (targetDomain: string, value: string | null) => { const name = this._getCustomRefreshCookieName(targetDomain); const options = { maxAge: 60 * 60 * 24 * 365, domain: targetDomain, noOpIfServerComponent: true }; if (context === "browser") { @@ -557,14 +595,11 @@ export class _StackClientAppImplIncomplete { @@ -610,14 +645,17 @@ export class _StackClientAppImplIncomplete { try { const refreshToken = value.refreshToken; - const updatedAt = refreshToken ? Date.now() : null; - const refreshCookieValue = refreshToken && updatedAt !== null ? this._formatRefreshCookieValue(refreshToken, updatedAt) : null; const secure = window.location.protocol === "https:"; const defaultName = this._getRefreshTokenDefaultCookieNameForSecure(secure); + const { updatedAt, refreshCookieValue, accessTokenPayload, cookieNamesToDelete } = this._prepareRefreshCookieUpdate( + this._getAllBrowserCookies(), + refreshToken, + value.accessToken ?? null, + defaultName, + ); setOrDeleteCookieClient(defaultName, refreshCookieValue, { maxAge: 60 * 60 * 24 * 365, secure }); - const accessTokenPayload = refreshToken && value.accessToken ? JSON.stringify([refreshToken, value.accessToken]) : null; setOrDeleteCookieClient(this._accessTokenCookieName, accessTokenPayload, { maxAge: 60 * 60 * 24 }); - this._deleteLegacyBrowserRefreshCookies(); + cookieNamesToDelete.forEach((name) => deleteCookieClient(name)); this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "browser"); hasSucceededInWriting = true; } catch (e) { @@ -660,15 +698,25 @@ export class _StackClientAppImplIncomplete 0) { + await Promise.all( + cookieNamesToDelete.map((name) => + setOrDeleteCookie(name, null, { noOpIfServerComponent: true }), + ), + ); + } this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "server"); }); }); From af5144354f0f3e1eb05e5427a53cf666914a857b Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 28 Oct 2025 15:23:29 -0700 Subject: [PATCH 10/14] fix tests --- apps/e2e/tests/js/cookies.test.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/e2e/tests/js/cookies.test.ts b/apps/e2e/tests/js/cookies.test.ts index 7c8f3671c2..1cca80c986 100644 --- a/apps/e2e/tests/js/cookies.test.ts +++ b/apps/e2e/tests/js/cookies.test.ts @@ -127,7 +127,7 @@ it("should set refresh token cookies for trusted parent domains", async ({ expec config: { domains: [ { domain: "https://example.com", handlerPath: "/handler" }, - { domain: "https://*.example.com", handlerPath: "/handler" }, + { domain: "https://**.example.com", handlerPath: "/handler" }, ], }, }, @@ -176,7 +176,7 @@ it("should set refresh token cookies for trusted parent domains", async ({ expec const parsedValue = JSON.parse(decodeURIComponent(defaultValue)); expect(typeof parsedValue.refresh_token).toBe("string"); expect(parsedValue.refresh_token.length).toBeGreaterThan(10); - expect(typeof parsedValue.updated_at).toBe("number"); + expect(typeof parsedValue.updated_at_millis).toBe("number"); const defaultAttrs = findCookieAttributes(cookieWrites, defaultCookieName); expect(defaultAttrs).not.toBeNull(); @@ -185,10 +185,7 @@ it("should set refresh token cookies for trusted parent domains", async ({ expec const customAttrs = findCookieAttributes(cookieWrites, customCookieName); expect(customAttrs?.get("domain")).toBe("example.com"); - - const legacyProjectCookie = `stack-refresh-${clientApp.projectId}`; - expect(cookieWrites.some((entry) => entry.toLowerCase().startsWith(`${legacyProjectCookie.toLowerCase()}=`) && entry.toLowerCase().includes("expires="))).toBe(true); - expect(cookieWrites.some((entry) => entry.toLowerCase().startsWith("stack-refresh=") && entry.toLowerCase().includes("expires="))).toBe(true); + expect(cookieWrites.some((entry) => entry.toLowerCase().startsWith("stack-refresh-") && entry.toLowerCase().includes("expires="))).toBe(true); }); it("should avoid setting custom refresh cookies when no trusted parent domain is configured", async ({ expect }) => { @@ -303,11 +300,11 @@ it("should read the newest refresh token payload from cookie storage", async ({ const staleCookieValue = JSON.stringify({ refresh_token: "stale-token", - updated_at: 1700000000000, + updated_at_millis: 1700000000000, }); const freshCookieValue = JSON.stringify({ refresh_token: "fresh-token", - updated_at: 1800000000000, + updated_at_millis: 1800000000000, }); const cookieMap: Record = { From 544fac9bf6d50c13b3a8332168181565e55e6f2b Mon Sep 17 00:00:00 2001 From: Bilal Godil Date: Tue, 28 Oct 2025 15:37:34 -0700 Subject: [PATCH 11/14] fix server hostname --- .../apps/implementations/client-app-impl.ts | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index e9d9d08f44..3c1e48d59a 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -583,7 +583,17 @@ export class _StackClientAppImplIncomplete { this._mostRecentQueuedCookieRefreshIndex++; const updateIndex = this._mostRecentQueuedCookieRefreshIndex; - const domain = await this._trustedParentDomainCache.getOrWait([window.location.hostname], "read-write"); + let hostname; + if (isBrowserLike()) { + hostname = window.location.hostname; + } + // IF_PLATFORM next + hostname = (await sc.headers?.())?.get("host"); + // END_PLATFORM + if (!hostname) { + return; + } + const domain = await this._trustedParentDomainCache.getOrWait([hostname], "read-write"); const setCookie = async (targetDomain: string, value: string | null) => { const name = this._getCustomRefreshCookieName(targetDomain); @@ -2071,9 +2081,9 @@ export class _StackClientAppImplIncomplete Date: Wed, 5 Nov 2025 14:38:14 -0800 Subject: [PATCH 12/14] small fixes --- packages/template/src/lib/cookie.ts | 6 +----- .../apps/implementations/client-app-impl.ts | 14 ++++++++------ 2 files changed, 9 insertions(+), 11 deletions(-) diff --git a/packages/template/src/lib/cookie.ts b/packages/template/src/lib/cookie.ts index f650132d8c..84af79785b 100644 --- a/packages/template/src/lib/cookie.ts +++ b/packages/template/src/lib/cookie.ts @@ -189,11 +189,7 @@ function determineSecureFromServerContext( cookies: Awaited>, headers: Awaited>, ): boolean { - let isSecureCookie = !!cookies.get("stack-is-https"); - if (headers.get("x-forwarded-proto") === "https") { - isSecureCookie = true; - } - return isSecureCookie; + return cookies.has("stack-is-https") || headers.get("x-forwarded-proto") === "https"; } // END_PLATFORM diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index 3c1e48d59a..bdcdb2dc13 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -431,9 +431,6 @@ export class _StackClientAppImplIncomplete Date: Wed, 5 Nov 2025 15:45:42 -0800 Subject: [PATCH 13/14] fix lint --- .../src/lib/stack-app/apps/implementations/client-app-impl.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts index bdcdb2dc13..3bd88aa5c5 100644 --- a/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts +++ b/packages/template/src/lib/stack-app/apps/implementations/client-app-impl.ts @@ -516,7 +516,7 @@ export class _StackClientAppImplIncomplete Date: Wed, 5 Nov 2025 17:46:04 -0800 Subject: [PATCH 14/14] remove tldts package --- packages/stack-shared/package.json | 1 - pnpm-lock.yaml | 74 ++++++++++++++++++++++-------- 2 files changed, 54 insertions(+), 21 deletions(-) diff --git a/packages/stack-shared/package.json b/packages/stack-shared/package.json index 563710002d..a86585c6b5 100644 --- a/packages/stack-shared/package.json +++ b/packages/stack-shared/package.json @@ -66,7 +66,6 @@ "jose": "^5.2.2", "oauth4webapi": "^2.10.3", "semver": "^7.6.3", - "tldts": "^7.0.17", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c347ec2de0..1176e9b3e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1551,9 +1551,6 @@ importers: semver: specifier: ^7.6.3 version: 7.6.3 - tldts: - specifier: ^7.0.17 - version: 7.0.17 uuid: specifier: ^9.0.1 version: 9.0.1 @@ -1563,7 +1560,7 @@ importers: devDependencies: '@sentry/nextjs': specifier: ^10.11.0 - version: 10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.0(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0(@swc/core@1.3.101)(esbuild@0.24.2)) + version: 10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.0(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0) '@simplewebauthn/types': specifier: ^11.0.0 version: 11.0.0 @@ -15290,13 +15287,6 @@ packages: resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} engines: {node: '>=14.0.0'} - tldts-core@7.0.17: - resolution: {integrity: sha512-DieYoGrP78PWKsrXr8MZwtQ7GLCUeLxihtjC1jZsW1DnvSMdKPitJSe8OSYDM2u5H6g3kWJZpePqkp43TfLh0g==} - - tldts@7.0.17: - resolution: {integrity: sha512-Y1KQBgDd/NUc+LfOtKS6mNsC9CCaH+m2P1RoIZy7RAPo3C3/t8X45+zgut31cRZtZ3xKPjfn3TkGTrctC2TQIQ==} - hasBin: true - tmp@0.0.33: resolution: {integrity: sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==} engines: {node: '>=0.6.0'} @@ -22919,7 +22909,7 @@ snapshots: - supports-color - webpack - '@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.0(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0(@swc/core@1.3.101)(esbuild@0.24.2))': + '@sentry/nextjs@10.11.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(encoding@0.1.13)(next@16.0.0(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))(react@19.0.0)(webpack@5.92.0)': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.37.0 @@ -22931,7 +22921,7 @@ snapshots: '@sentry/opentelemetry': 10.11.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0) '@sentry/react': 10.11.0(react@19.0.0) '@sentry/vercel-edge': 10.11.0 - '@sentry/webpack-plugin': 4.3.0(encoding@0.1.13)(webpack@5.92.0(@swc/core@1.3.101)(esbuild@0.24.2)) + '@sentry/webpack-plugin': 4.3.0(encoding@0.1.13)(webpack@5.92.0) chalk: 3.0.0 next: 16.0.0(@babel/core@7.28.0)(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) resolve: 1.22.8 @@ -23074,6 +23064,16 @@ snapshots: - encoding - supports-color + '@sentry/webpack-plugin@4.3.0(encoding@0.1.13)(webpack@5.92.0)': + dependencies: + '@sentry/bundler-plugin-core': 4.3.0(encoding@0.1.13) + unplugin: 1.0.1 + uuid: 9.0.1 + webpack: 5.92.0 + transitivePeerDependencies: + - encoding + - supports-color + '@shikijs/core@3.6.0': dependencies: '@shikijs/types': 3.6.0 @@ -29199,7 +29199,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 20.17.6 + '@types/node': 22.15.34 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -33544,6 +33544,15 @@ snapshots: '@swc/core': 1.3.101 esbuild: 0.24.2 + terser-webpack-plugin@5.3.10(webpack@5.92.0): + dependencies: + '@jridgewell/trace-mapping': 0.3.29 + jest-worker: 27.5.1 + schema-utils: 3.3.0 + serialize-javascript: 6.0.2 + terser: 5.43.1 + webpack: 5.92.0 + terser@5.43.1: dependencies: '@jridgewell/source-map': 0.3.10 @@ -33661,12 +33670,6 @@ snapshots: tinyspy@2.2.1: {} - tldts-core@7.0.17: {} - - tldts@7.0.17: - dependencies: - tldts-core: 7.0.17 - tmp@0.0.33: dependencies: os-tmpdir: 1.0.2 @@ -34586,6 +34589,37 @@ snapshots: webpack-virtual-modules@0.5.0: {} + webpack@5.92.0: + dependencies: + '@types/eslint-scope': 3.7.7 + '@types/estree': 1.0.8 + '@webassemblyjs/ast': 1.12.1 + '@webassemblyjs/wasm-edit': 1.12.1 + '@webassemblyjs/wasm-parser': 1.12.1 + acorn: 8.15.0 + acorn-import-attributes: 1.9.5(acorn@8.15.0) + browserslist: 4.25.1 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.18.1 + es-module-lexer: 1.5.3 + eslint-scope: 5.1.1 + events: 3.3.0 + glob-to-regexp: 0.4.1 + graceful-fs: 4.2.11 + json-parse-even-better-errors: 2.3.1 + loader-runner: 4.3.0 + mime-types: 2.1.35 + neo-async: 2.6.2 + schema-utils: 3.3.0 + tapable: 2.2.1 + terser-webpack-plugin: 5.3.10(webpack@5.92.0) + watchpack: 2.4.1 + webpack-sources: 3.2.3 + transitivePeerDependencies: + - '@swc/core' + - esbuild + - uglify-js + webpack@5.92.0(@swc/core@1.3.101)(esbuild@0.24.2): dependencies: '@types/eslint-scope': 3.7.7