-
Notifications
You must be signed in to change notification settings - Fork 501
Cookie subdomain sharing #971
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
28 commits
Select commit
Hold shift + click to select a range
e6aabb8
test
BilalG1 0e23112
cookie subdomain sharing option
BilalG1 ebd14ca
fix test
BilalG1 ca282e7
update
BilalG1 1c1b611
fix test
BilalG1 98e9a80
Merge branch 'dev' into cookie-subdomain-sharing
BilalG1 ca45171
fix tld
BilalG1 b2dcb82
improve tests
BilalG1 9f2ee24
Merge dev into cookie-subdomain-sharing
N2D4 a22d358
Merge dev into cookie-subdomain-sharing
N2D4 4c5b1e0
Merge dev into cookie-subdomain-sharing
N2D4 b88758b
Merge dev into cookie-subdomain-sharing
N2D4 221a12e
wip
BilalG1 d82e9e2
Merge branch 'cookie-subdomain-sharing' of https://github.com/stack-a…
BilalG1 7d852d3
fix type issue and simplify cookie logic
BilalG1 af51443
fix tests
BilalG1 544fac9
fix server hostname
BilalG1 8eccbaa
Merge dev into cookie-subdomain-sharing
N2D4 26533d0
Merge dev into cookie-subdomain-sharing
N2D4 7c64edb
Merge dev into cookie-subdomain-sharing
N2D4 7fb5dd2
Merge dev into cookie-subdomain-sharing
N2D4 5d66fe0
small fixes
BilalG1 b0c74d9
Merge remote-tracking branch 'origin/dev' into cookie-subdomain-sharing
BilalG1 c80b08c
fix lint
BilalG1 5b0a670
Merge branch 'dev' into cookie-subdomain-sharing
BilalG1 362b787
remove tldts package
BilalG1 710c13f
Merge branch 'cookie-subdomain-sharing' of https://github.com/stack-a…
BilalG1 e08f124
Merge remote-tracking branch 'origin/dev' into cookie-subdomain-sharing
BilalG1 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"); | ||
| }); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.