Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e6aabb8
test
BilalG1 Oct 22, 2025
0e23112
cookie subdomain sharing option
BilalG1 Oct 22, 2025
ebd14ca
fix test
BilalG1 Oct 22, 2025
ca282e7
update
BilalG1 Oct 23, 2025
1c1b611
fix test
BilalG1 Oct 23, 2025
98e9a80
Merge branch 'dev' into cookie-subdomain-sharing
BilalG1 Oct 23, 2025
ca45171
fix tld
BilalG1 Oct 23, 2025
b2dcb82
improve tests
BilalG1 Oct 23, 2025
9f2ee24
Merge dev into cookie-subdomain-sharing
N2D4 Oct 24, 2025
a22d358
Merge dev into cookie-subdomain-sharing
N2D4 Oct 25, 2025
4c5b1e0
Merge dev into cookie-subdomain-sharing
N2D4 Oct 26, 2025
b88758b
Merge dev into cookie-subdomain-sharing
N2D4 Oct 28, 2025
221a12e
wip
BilalG1 Oct 28, 2025
d82e9e2
Merge branch 'cookie-subdomain-sharing' of https://github.com/stack-a…
BilalG1 Oct 28, 2025
7d852d3
fix type issue and simplify cookie logic
BilalG1 Oct 28, 2025
af51443
fix tests
BilalG1 Oct 28, 2025
544fac9
fix server hostname
BilalG1 Oct 28, 2025
8eccbaa
Merge dev into cookie-subdomain-sharing
N2D4 Oct 30, 2025
26533d0
Merge dev into cookie-subdomain-sharing
N2D4 Oct 31, 2025
7c64edb
Merge dev into cookie-subdomain-sharing
N2D4 Nov 4, 2025
7fb5dd2
Merge dev into cookie-subdomain-sharing
N2D4 Nov 5, 2025
5d66fe0
small fixes
BilalG1 Nov 5, 2025
b0c74d9
Merge remote-tracking branch 'origin/dev' into cookie-subdomain-sharing
BilalG1 Nov 5, 2025
c80b08c
fix lint
BilalG1 Nov 5, 2025
5b0a670
Merge branch 'dev' into cookie-subdomain-sharing
BilalG1 Nov 6, 2025
362b787
remove tldts package
BilalG1 Nov 6, 2025
710c13f
Merge branch 'cookie-subdomain-sharing' of https://github.com/stack-a…
BilalG1 Nov 6, 2025
e08f124
Merge remote-tracking branch 'origin/dev' into cookie-subdomain-sharing
BilalG1 Nov 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/e2e/tests/backend/endpoints/api/v1/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
1 change: 1 addition & 0 deletions apps/e2e/tests/js/app.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { isUuid } from "@stackframe/stack-shared/dist/utils/uuids";
import { it } from "../helpers";
import { createApp, scaffoldProject } from "./js-helpers";


it("should scaffold the project", async ({ expect }) => {
const { project } = await scaffoldProject();
expect(project.displayName).toBe("New Project");
Expand Down
319 changes: 319 additions & 0 deletions apps/e2e/tests/js/cookies.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,319 @@
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<string, string>,
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<string, string>();
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<boolean> {
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<string, string> | 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<string, string>();
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_millis).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");
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_millis: 1700000000000,
});
const freshCookieValue = JSON.stringify({
refresh_token: "fresh-token",
updated_at_millis: 1800000000000,
});

const cookieMap: Record<string, string> = {
[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");
});
1 change: 1 addition & 0 deletions packages/stack-shared/src/interface/crud/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
2 changes: 1 addition & 1 deletion packages/stack-shared/src/utils/ips.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => {
Expand Down
5 changes: 2 additions & 3 deletions packages/stack-shared/src/utils/urls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,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 }) => {
Expand Down Expand Up @@ -311,7 +311,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 }) => {
Expand Down Expand Up @@ -378,4 +378,3 @@ import.meta.vitest?.test("isSubPath", ({ expect }) => {
expect(isChildPath("/path/", "/path-abc")).toBe(false);
expect(isChildPath("/path/", "/path-abc/")).toBe(false);
});

Loading