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 55167644b5..44bc8dcf0d 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" }, diff --git a/apps/e2e/tests/js/app.test.ts b/apps/e2e/tests/js/app.test.ts index 3f25252dcb..048b91d6b1 100644 --- a/apps/e2e/tests/js/app.test.ts +++ b/apps/e2e/tests/js/app.test.ts @@ -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"); diff --git a/apps/e2e/tests/js/cookies.test.ts b/apps/e2e/tests/js/cookies.test.ts new file mode 100644 index 0000000000..1cca80c986 --- /dev/null +++ b/apps/e2e/tests/js/cookies.test.ts @@ -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, + 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_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 = { + [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"); +}); 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/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..cb19763faa 100644 --- a/packages/stack-shared/src/utils/urls.tsx +++ b/packages/stack-shared/src/utils/urls.tsx @@ -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 }) => { @@ -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 }) => { @@ -378,4 +378,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..84af79785b 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, secure?: boolean }; +type DeleteCookieOptions = { noOpIfServerComponent?: boolean, domain?: string }; function ensureClient() { if (!isBrowserLike()) { @@ -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,7 @@ export async function createCookieHelper(): Promise { export function createBrowserCookieHelper(): CookieHelper { return { get: getCookieClient, + getAll: getAllCookiesClient, set: setCookieClient, setOrDelete: setOrDeleteCookieClient, delete: deleteCookieClient, @@ -76,23 +79,31 @@ 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; + 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. @@ -108,15 +119,13 @@ 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, { secure: isSecureCookie, maxAge: options.maxAge, + domain: options.domain, }); } catch (e) { handleCookieError(e, options); @@ -131,7 +140,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); } @@ -142,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 { @@ -153,12 +171,50 @@ export async function getCookie(name: string): Promise { return cookieHelper.get(name); } +export async function isSecure(): Promise { + if (isBrowserLike()) { + return determineSecureFromClientContext(); + } + // IF_PLATFORM next + return determineSecureFromServerContext(await rscCookies(), await rscHeaders()); + // END_PLATFORM + return false; +} + +function determineSecureFromClientContext(): boolean { + return typeof window !== "undefined" && window.location.protocol === "https:"; +} +// IF_PLATFORM next +function determineSecureFromServerContext( + cookies: Awaited>, + headers: Awaited>, +): boolean { + return cookies.has("stack-is-https") || headers.get("x-forwarded-proto") === "https"; +} +// END_PLATFORM + +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 }); + } + 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); } } @@ -169,7 +225,7 @@ export async function setOrDeleteCookie(name: string, value: string | null, opti export function deleteCookieClient(name: string, options: DeleteCookieOptions = {}) { ensureClient(); - Cookies.remove(name); + deleteCookieClientInternal(name, options); } export async function deleteCookie(name: string, options: DeleteCookieOptions = {}) { @@ -179,9 +235,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), - }); + 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 c9ec6f1a46..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 @@ -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 { 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"; @@ -49,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"; @@ -57,7 +59,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 } 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. @@ -271,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 { @@ -418,13 +424,103 @@ export class _StackClientAppImplIncomplete>(); protected _requestTokenStores = new WeakMap>(); protected _storedBrowserCookieTokenStore: Store | null = null; + private _mostRecentQueuedCookieRefreshIndex: number = 0; + protected get _legacyRefreshTokenCookieName() { + return `stack-refresh-${this.projectId}`; + } protected get _refreshTokenCookieName() { return `stack-refresh-${this.projectId}`; } - 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 + 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_millis: updatedAt, + }); + } + private _formatAccessCookieValue(refreshToken: string | null, accessToken: string | null): string | null { + return refreshToken && accessToken ? JSON.stringify([refreshToken, accessToken]) : null; + } + private _parseStructuredRefreshCookie(value: string | null): { refreshToken: string, updatedAt: number | null } | null { + if (!value) { + return null; + } + const parsed = parseJson(value); + if (parsed.status !== "ok" || typeof parsed.data !== "object" || parsed.data === null) { + console.warn("Failed to parse structured refresh cookie"); + return null; + } + const data = parsed.data; + const refreshToken = "refresh_token" in data && typeof data.refresh_token === "string" ? data.refresh_token : null; + const updatedAt = "updated_at_millis" in data && typeof data.updated_at_millis === "number" ? data.updated_at_millis : null; + if (!refreshToken) { + console.warn("Refresh token not found in structured refresh cookie"); + return null; + } + return { + refreshToken, + updatedAt, + }; + + } + private _extractRefreshTokenFromCookieMap(cookies: Record): { refreshToken: string | null, updatedAt: number | null } { + const { legacyNames, structuredPrefixes } = this._getRefreshTokenCookieNamePatterns(); + for (const name of legacyNames) { + const value = cookies[name]; + if (value) { + return { refreshToken: value, updatedAt: null }; + } + } + + let selected: { refreshToken: string, updatedAt: number | null } | null = null; + for (const [name, value] of Object.entries(cookies)) { + if (!structuredPrefixes.some(prefix => name.startsWith(prefix))) 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: Record): TokenObject { + const { refreshToken } = this._extractRefreshTokenFromCookieMap(cookies); + const accessTokenCookie = cookies[this._accessTokenCookieName] ?? null; + let accessToken: string | null = null; + if (accessTokenCookie && accessTokenCookie.startsWith('[\"')) { + const parsed = parseJson(accessTokenCookie); + if ( + parsed.status === "ok" && + typeof parsed.data === "object" && + parsed.data !== null && + Array.isArray(parsed.data) && + parsed.data.length === 2 && + typeof parsed.data[0] === "string" && + typeof parsed.data[1] === "string" + ) { + if (parsed.data[0] === refreshToken) { + accessToken = parsed.data[1]; + } + } else { + console.warn("Access token cookie has invalid format"); + } + } return { refreshToken, accessToken, @@ -437,6 +533,102 @@ export class _StackClientAppImplIncomplete { + if (!isBrowserLike()) { + throw new StackAssertionError("Cannot get browser cookies on the server!"); + } + return cookie.parse(document.cookie || ""); + } + private _getRefreshTokenCookieNamePatterns(): { legacyNames: string[], structuredPrefixes: string[] } { + return { + legacyNames: [this._legacyRefreshTokenCookieName, "stack-refresh"], + structuredPrefixes: [ + `${this._refreshTokenCookieName}--`, + `__Host-${this._refreshTokenCookieName}--`, + ], + }; + } + private _collectRefreshTokenCookieNames(cookies: Record): 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 () => { + this._mostRecentQueuedCookieRefreshIndex++; + const updateIndex = this._mostRecentQueuedCookieRefreshIndex; + 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); + 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.status === "error" || !domain.data || updateIndex !== this._mostRecentQueuedCookieRefreshIndex) { + return; + } + const value = refreshToken && updatedAt ? this._formatRefreshCookieValue(refreshToken, updatedAt) : null; + await setCookie(domain.data, value); + }); + } + private async _getTrustedParentDomain(currentDomain: string): Promise { + const project = Result.orThrow(await this._interface.getClientProject()); + 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; + } + } + + return null; + } + protected _getBrowserCookieTokenStore(): Store { if (!isBrowserLike()) { throw new Error("Cannot use cookie token store on the server!"); @@ -444,10 +636,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), @@ -467,9 +656,19 @@ 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 }); - deleteCookieClient('stack-refresh'); // delete cookie name from previous versions (for backwards-compatibility) + const refreshToken = value.refreshToken; + 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 }); + setOrDeleteCookieClient(this._accessTokenCookieName, accessTokenPayload, { maxAge: 60 * 60 * 24 }); + cookieNamesToDelete.forEach((name) => deleteCookieClient(name)); + this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "browser"); hasSucceededInWriting = true; } catch (e) { if (!isBrowserLike()) { @@ -495,10 +694,7 @@ export class _StackClientAppImplIncomplete(tokens); store.onChange((value) => { runAsynchronously(async () => { @@ -513,10 +709,27 @@ export class _StackClientAppImplIncomplete 0) { + await Promise.all( + cookieNamesToDelete.map((name) => + setOrDeleteCookie(name, null, { noOpIfServerComponent: true }), + ), + ); + } + this._queueCustomRefreshCookieUpdate(refreshToken, updatedAt, "server"); }); }); return store; @@ -551,10 +764,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) { @@ -1873,9 +2083,9 @@ export class _StackClientAppImplIncomplete