From 2290ec7f3c51c65090d821932045c2025d2249d5 Mon Sep 17 00:00:00 2001 From: DIYgod Date: Thu, 12 Feb 2026 20:58:03 +0800 Subject: [PATCH] fix: preserve session when switching API domain (#4833) * feat(auth): migrate session across API domain switch * fix(auth): address PR review on migration flow * fix(ci): avoid package.json mutation during renderer build --- .../main/src/lib/auth-cookie-migration.ts | 91 ++++++++++++ .../layer/main/src/manager/bootstrap.ts | 5 + .../plugins/vite/generate-main-hash.ts | 25 +++- apps/mobile/src/initialize/index.ts | 4 + apps/mobile/src/lib/auth-cookie-migration.ts | 139 ++++++++++++++++++ packages/internal/shared/src/env.common.ts | 4 +- 6 files changed, 260 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/layer/main/src/lib/auth-cookie-migration.ts create mode 100644 apps/mobile/src/lib/auth-cookie-migration.ts diff --git a/apps/desktop/layer/main/src/lib/auth-cookie-migration.ts b/apps/desktop/layer/main/src/lib/auth-cookie-migration.ts new file mode 100644 index 000000000..9d5f88593 --- /dev/null +++ b/apps/desktop/layer/main/src/lib/auth-cookie-migration.ts @@ -0,0 +1,91 @@ +import type { Cookie, CookiesSetDetails, Session } from "electron" + +import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" + +import { logger } from "../logger" + +const LEGACY_PROD_API_URL = "https://api.follow.is" +const BETTER_AUTH_SESSION_DATA_COOKIE_NAME = "better-auth.session_data" + +const isBetterAuthSessionTokenCookie = (cookieName: string) => { + return cookieName.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN) +} + +const isBetterAuthSessionCookie = (cookieName: string) => { + return ( + cookieName.includes(BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN) || + cookieName.includes(BETTER_AUTH_SESSION_DATA_COOKIE_NAME) + ) +} + +const toCookieSetDetails = (cookie: Cookie, url: string, domain: string): CookiesSetDetails => { + const details: CookiesSetDetails = { + url, + name: cookie.name, + value: cookie.value, + domain, + path: cookie.path, + secure: cookie.secure, + httpOnly: cookie.httpOnly, + sameSite: cookie.sameSite, + } + + if (!cookie.session && cookie.expirationDate) { + details.expirationDate = cookie.expirationDate + } + + return details +} + +export const migrateAuthCookiesToNewApiDomain = async ( + cookieSession: Session, + options: { + currentApiURL: string + legacyApiURL?: string + }, +) => { + const legacyApiURL = options.legacyApiURL ?? LEGACY_PROD_API_URL + if (!options.currentApiURL || options.currentApiURL === legacyApiURL) { + return + } + + const currentHost = new URL(options.currentApiURL).hostname + const legacyHost = new URL(legacyApiURL).hostname + + if (currentHost === legacyHost) { + return + } + + const currentDomainCookies = await cookieSession.cookies.get({ + domain: currentHost, + }) + const hasCurrentDomainSessionTokenCookie = currentDomainCookies.some((cookie) => + isBetterAuthSessionTokenCookie(cookie.name), + ) + if (hasCurrentDomainSessionTokenCookie) { + return + } + + const legacyDomainCookies = await cookieSession.cookies.get({ + domain: legacyHost, + }) + const legacySessionCookies = legacyDomainCookies.filter((cookie) => + isBetterAuthSessionCookie(cookie.name), + ) + + if (legacySessionCookies.length === 0) { + return + } + + await Promise.all( + legacySessionCookies.map((cookie) => { + return cookieSession.cookies.set( + toCookieSetDetails(cookie, options.currentApiURL, currentHost), + ) + }), + ) + + logger.info( + `Migrated ${legacySessionCookies.length} auth cookie(s) from ${legacyHost} to ${currentHost}`, + ) +} diff --git a/apps/desktop/layer/main/src/manager/bootstrap.ts b/apps/desktop/layer/main/src/manager/bootstrap.ts index 4e8f19d30..2d0205ed9 100644 --- a/apps/desktop/layer/main/src/manager/bootstrap.ts +++ b/apps/desktop/layer/main/src/manager/bootstrap.ts @@ -13,6 +13,7 @@ import { join } from "pathe" import { WindowManager } from "~/manager/window" import { isMacOS } from "../env" +import { migrateAuthCookiesToNewApiDomain } from "../lib/auth-cookie-migration" import { handleUrlRouting } from "../lib/router" import { store } from "../lib/store" import { updateNotificationsToken } from "../lib/user" @@ -81,6 +82,10 @@ export class BootstrapManager { callback({ cancel: false, requestHeaders: details.requestHeaders }) }) + await migrateAuthCookiesToNewApiDomain(session.defaultSession, { + currentApiURL: env.VITE_API_URL, + }) + // Bypass CORS for PostHog analytics session.defaultSession.webRequest.onHeadersReceived((details, callback) => { const url = new URL(details.url) diff --git a/apps/desktop/plugins/vite/generate-main-hash.ts b/apps/desktop/plugins/vite/generate-main-hash.ts index 3bf857a9b..fb535e308 100644 --- a/apps/desktop/plugins/vite/generate-main-hash.ts +++ b/apps/desktop/plugins/vite/generate-main-hash.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto" import fs from "node:fs/promises" +import { fileURLToPath } from "node:url" import fg from "fast-glob" import path from "pathe" @@ -34,13 +35,25 @@ export async function calculateMainHash( async function main() { const cwd = process.cwd() - const hash = await calculateMainHash(path.resolve(cwd, "layer/main"), [ - path.resolve(cwd, "package.json"), - ]) + const packageJsonPath = path.resolve(cwd, "package.json") + const hash = await calculateMainHash(path.resolve(cwd, "layer/main"), [packageJsonPath]) - const packageJson = JSON.parse(await fs.readFile(path.resolve(cwd, "package.json"), "utf-8")) + const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf-8")) packageJson.mainHash = hash - await fs.writeFile(path.resolve(cwd, "package.json"), JSON.stringify(packageJson, null, 2)) + + const nextPackageJson = `${JSON.stringify(packageJson, null, 2)}\n` + const tempPackageJsonPath = `${packageJsonPath}.tmp` + await fs.writeFile(tempPackageJsonPath, nextPackageJson, "utf-8") + await fs.rename(tempPackageJsonPath, packageJsonPath) } -main() +const isExecutedDirectly = process.argv[1] + ? path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) + : false + +if (isExecutedDirectly) { + void main().catch((error) => { + console.error(error) + process.exitCode = 1 + }) +} diff --git a/apps/mobile/src/initialize/index.ts b/apps/mobile/src/initialize/index.ts index b2208226d..bc7d58d1f 100644 --- a/apps/mobile/src/initialize/index.ts +++ b/apps/mobile/src/initialize/index.ts @@ -3,6 +3,7 @@ import { hydrateDatabaseToStore } from "@follow/store/hydrate" import { tracker } from "@follow/tracker" import { nativeApplicationVersion } from "expo-application" +import { migrateLegacyApiSession } from "../lib/auth-cookie-migration" import { settingSyncQueue } from "../modules/settings/sync-queue" import { initAnalytics } from "./analytics" import { initializeAppCheck } from "./app-check" @@ -24,6 +25,9 @@ export const initializeApp = async () => { await initDeviceType() await initializeDB() + void apm("migrateLegacyApiSession", migrateLegacyApiSession).catch((error) => { + console.error("migrateLegacyApiSession failed", error) + }) await apm("migrateDatabase", migrateDatabase) initializeDayjs() diff --git a/apps/mobile/src/lib/auth-cookie-migration.ts b/apps/mobile/src/lib/auth-cookie-migration.ts new file mode 100644 index 000000000..96a6e06b0 --- /dev/null +++ b/apps/mobile/src/lib/auth-cookie-migration.ts @@ -0,0 +1,139 @@ +import { createMobileAPIHeaders } from "@follow/utils/headers" +import { nativeApplicationVersion } from "expo-application" +import { Platform } from "react-native" +import DeviceInfo from "react-native-device-info" + +import { getCookie, oneTimeToken } from "./auth" +import { getClientId, getSessionId } from "./client-session" +import { getUserAgent } from "./native/user-agent" +import { proxyEnv } from "./proxy-env" + +const LEGACY_PROD_API_URL = "https://api.follow.is" +const NEW_PROD_API_URL = "https://api.folo.is" + +const authSessionEndpoint = "/better-auth/get-session" +const migrationRequestTimeout = 6000 +let migrationAttempted = false + +const fetchWithTimeout = async (input: string, options: RequestInit) => { + const controller = new AbortController() + const timer = setTimeout(() => { + controller.abort() + }, migrationRequestTimeout) + + try { + return await fetch(input, { + ...options, + signal: controller.signal, + }) + } finally { + clearTimeout(timer) + } +} + +const createMigrationHeaders = async () => { + const headers = createMobileAPIHeaders({ + version: nativeApplicationVersion || "", + rnPlatform: { + OS: Platform.OS, + isPad: Platform.OS === "ios" && Platform.isPad, + }, + installerPackageName: await DeviceInfo.getInstallerPackageName(), + }) + + return { + ...headers, + "X-Client-Id": getClientId(), + "X-Session-Id": getSessionId(), + "User-Agent": await getUserAgent(), + "expo-origin": "follow://", + "x-skip-oauth-proxy": "true", + } +} + +const hasValidSessionOnApiDomain = async (apiURL: string) => { + const cookie = getCookie() + if (!cookie) { + return false + } + + try { + const migrationHeaders = await createMigrationHeaders() + const response = await fetchWithTimeout(`${apiURL}${authSessionEndpoint}`, { + credentials: "omit", + headers: { + cookie, + ...migrationHeaders, + }, + method: "GET", + }) + + if (!response.ok) { + return false + } + + const data = (await response.json()) as { user?: unknown } + return Boolean(data?.user) + } catch { + return false + } +} + +const getLegacyOneTimeToken = async () => { + const cookie = getCookie() + if (!cookie) { + return null + } + + try { + const migrationHeaders = await createMigrationHeaders() + const response = await fetchWithTimeout( + `${LEGACY_PROD_API_URL}/better-auth/one-time-token/generate`, + { + credentials: "omit", + headers: { + cookie, + ...migrationHeaders, + }, + method: "GET", + }, + ) + + if (!response.ok) { + return null + } + + const data = (await response.json()) as { token?: string } + return data.token ?? null + } catch { + return null + } +} + +export const migrateLegacyApiSession = async () => { + if (migrationAttempted) { + return + } + migrationAttempted = true + + if (proxyEnv.API_URL !== NEW_PROD_API_URL) { + return + } + + const hasSessionOnNewApi = await hasValidSessionOnApiDomain(NEW_PROD_API_URL) + if (hasSessionOnNewApi) { + return + } + + const hasSessionOnLegacyApi = await hasValidSessionOnApiDomain(LEGACY_PROD_API_URL) + if (!hasSessionOnLegacyApi) { + return + } + + const token = await getLegacyOneTimeToken() + if (!token) { + return + } + + await oneTimeToken.apply({ token }) +} diff --git a/packages/internal/shared/src/env.common.ts b/packages/internal/shared/src/env.common.ts index 83278847f..c9128fafb 100644 --- a/packages/internal/shared/src/env.common.ts +++ b/packages/internal/shared/src/env.common.ts @@ -1,6 +1,6 @@ export const DEFAULT_VALUES = { PROD: { - API_URL: "https://api.follow.is", + API_URL: "https://api.folo.is", WEB_URL: "https://app.folo.is", INBOXES_EMAIL: "@follow.re", OPENPANEL_CLIENT_ID: "4382168f-b8d2-40c1-9a26-133a312d072b", @@ -16,7 +16,7 @@ export const DEFAULT_VALUES = { INBOXES_EMAIL: "__dev@follow.re", }, STAGING: { - API_URL: "https://api.follow.is", + API_URL: "https://api.folo.is", WEB_URL: "https://staging.follow.is", INBOXES_EMAIL: "@follow.re", OPENPANEL_CLIENT_ID: "4382168f-b8d2-40c1-9a26-133a312d072b",