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:
parent
62efdd29b2
commit
2290ec7f3c
|
|
@ -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}`,
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue