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
This commit is contained in:
DIYgod 2026-02-12 20:58:03 +08:00 committed by GitHub
parent 62efdd29b2
commit 2290ec7f3c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 260 additions and 8 deletions

View File

@ -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}`,
)
}

View File

@ -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)

View File

@ -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
})
}

View File

@ -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()

View File

@ -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 })
}

View File

@ -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",