diff --git a/apps/cli/package.json b/apps/cli/package.json index 836893178..e81ff96a3 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "folocli", "type": "module", - "version": "0.0.1", + "version": "0.0.4", "description": "Folo CLI for terminal workflows and automation", "author": "Folo Team", "license": "AGPL-3.0-only", diff --git a/apps/cli/src/auth-command.test.ts b/apps/cli/src/auth-command.test.ts new file mode 100644 index 000000000..9dace6bf8 --- /dev/null +++ b/apps/cli/src/auth-command.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from "vitest" + +vi.mock("./browser-login", () => ({ + loginWithBrowser: vi.fn(), + resolveBrowserLoginToken: vi.fn(), +})) + +const { loginWithBrowser, resolveBrowserLoginToken } = await import("./browser-login") +const { resolveLoginToken } = await import("./commands/auth") + +describe("resolveLoginToken", () => { + it("exchanges a provided one-time token into a session token", async () => { + vi.mocked(resolveBrowserLoginToken).mockResolvedValueOnce("session-token") + + await expect( + resolveLoginToken({ + inputToken: "one-time-token", + apiUrl: "https://api.folo.is", + timeoutMs: 180_000, + onStatus: vi.fn(), + }), + ).resolves.toBe("session-token") + + expect(resolveBrowserLoginToken).toHaveBeenCalledWith("https://api.folo.is", "one-time-token") + }) + + it("falls back to browser login when no token is provided", async () => { + vi.mocked(loginWithBrowser).mockResolvedValueOnce({ + token: "browser-session-token", + callbackUrl: "http://127.0.0.1/callback", + loginUrl: "https://app.folo.is/login", + }) + + await expect( + resolveLoginToken({ + apiUrl: "https://api.folo.is", + timeoutMs: 180_000, + onStatus: vi.fn(), + }), + ).resolves.toBe("browser-session-token") + }) +}) diff --git a/apps/cli/src/browser-login.test.ts b/apps/cli/src/browser-login.test.ts index 7fc46a685..d4d8def79 100644 --- a/apps/cli/src/browser-login.test.ts +++ b/apps/cli/src/browser-login.test.ts @@ -56,12 +56,15 @@ describe("browser login helpers", () => { const fetchMock = vi.fn().mockResolvedValueOnce( new Response( JSON.stringify({ - session: { token: "session-token" }, user: { id: "user-1" }, }), { status: 200, - headers: { "content-type": "application/json" }, + headers: { + "content-type": "application/json", + "set-cookie": + "__Secure-better-auth.session_token=session-token; Path=/; HttpOnly; Secure; SameSite=None", + }, }, ), ) @@ -72,6 +75,49 @@ describe("browser login helpers", () => { expect(token).toBe("session-token") expect(fetchMock).toHaveBeenCalledTimes(1) expect(fetchMock).toHaveBeenCalledWith( + "https://api.folo.is/better-auth/one-time-token/apply", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ token: "one-time-token" }), + }), + ) + }) + + it("falls back to verify when apply endpoint is unavailable", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + new Response(null, { + status: 404, + }), + ) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + session: { token: "session-token" }, + user: { id: "user-1" }, + }), + { + status: 200, + headers: { "content-type": "application/json" }, + }, + ), + ) + vi.stubGlobal("fetch", fetchMock) + + const token = await resolveBrowserLoginToken(DEFAULT_VALUES.PROD.API_URL, "one-time-token") + + expect(token).toBe("session-token") + expect(fetchMock).toHaveBeenNthCalledWith( + 1, + "https://api.folo.is/better-auth/one-time-token/apply", + expect.objectContaining({ + method: "POST", + body: JSON.stringify({ token: "one-time-token" }), + }), + ) + expect(fetchMock).toHaveBeenNthCalledWith( + 2, "https://api.folo.is/better-auth/one-time-token/verify", expect.objectContaining({ method: "POST", diff --git a/apps/cli/src/browser-login.ts b/apps/cli/src/browser-login.ts index d19a863f6..856ccc1ac 100644 --- a/apps/cli/src/browser-login.ts +++ b/apps/cli/src/browser-login.ts @@ -8,6 +8,7 @@ import { CLIError } from "./output" const LOCAL_CALLBACK_HOST = "127.0.0.1" const LOCAL_CALLBACK_PATH = "/callback" const DEFAULT_TIMEOUT_MS = 3 * 60 * 1000 +const ONE_TIME_TOKEN_APPLY_PATH = "/better-auth/one-time-token/apply" const ONE_TIME_TOKEN_VERIFY_PATH = "/better-auth/one-time-token/verify" const SESSION_CHECK_PATH = "/better-auth/get-session" @@ -114,6 +115,38 @@ const isRecord = (value: unknown): value is Record => { return typeof value === "object" && value !== null && !Array.isArray(value) } +const readSetCookieValues = (response: Response): string[] => { + if (typeof response.headers.getSetCookie === "function") { + return response.headers.getSetCookie() + } + + const setCookie = response.headers.get("set-cookie") + return setCookie ? [setCookie] : [] +} + +const extractSessionTokenFromSetCookie = (setCookieValues: string[]): string | undefined => { + for (const setCookie of setCookieValues) { + const match = setCookie.match(/(?:__Secure-)?better-auth\.session_token=([^;]+)/) + if (match?.[1]) { + return match[1] + } + } + + return undefined +} + +const extractSessionTokenFromBody = (data: unknown): string | undefined => { + if (!isRecord(data)) { + return undefined + } + + if (isRecord(data.session) && typeof data.session.token === "string") { + return data.session.token + } + + return undefined +} + const extractErrorMessage = async (response: Response): Promise => { const contentType = response.headers.get("content-type") ?? "" @@ -147,42 +180,80 @@ const hasValidSessionToken = async (apiUrl: string, token: string): Promise => { + const applyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_APPLY_PATH) const verifyUrl = resolveAuthEndpointUrl(apiUrl, ONE_TIME_TOKEN_VERIFY_PATH) - let response: Response + const requestBody = JSON.stringify({ token }) + + let response: Response | undefined + let errorMessage: string | undefined try { - response = await fetch(verifyUrl, { + response = await fetch(applyUrl, { method: "POST", headers: { "content-type": "application/json", }, - body: JSON.stringify({ token }), + body: requestBody, }) } catch (error) { throw new CLIError( "NETWORK_ERROR", - `Failed to verify browser login token: ${(error as Error).message}`, + `Failed to apply browser login token: ${(error as Error).message}`, ) } if (response.ok) { const data = (await response.json().catch(() => null)) as unknown const sessionToken = - isRecord(data) && isRecord(data.session) && typeof data.session.token === "string" - ? data.session.token - : undefined + extractSessionTokenFromSetCookie(readSetCookieValues(response)) ?? + extractSessionTokenFromBody(data) if (!sessionToken) { throw new CLIError( "UNAUTHORIZED", - "Browser login verification succeeded without returning a session token.", + "Browser login token apply succeeded without returning a session token.", ) } return sessionToken } - const errorMessage = await extractErrorMessage(response) + errorMessage = await extractErrorMessage(response) + + if (response.status === 404) { + try { + response = await fetch(verifyUrl, { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: requestBody, + }) + } catch (error) { + throw new CLIError( + "NETWORK_ERROR", + `Failed to verify browser login token: ${(error as Error).message}`, + ) + } + + if (response.ok) { + const data = (await response.json().catch(() => null)) as unknown + const sessionToken = + extractSessionTokenFromSetCookie(readSetCookieValues(response)) ?? + extractSessionTokenFromBody(data) + + if (!sessionToken) { + throw new CLIError( + "UNAUTHORIZED", + "Browser login verification succeeded without returning a session token.", + ) + } + + return sessionToken + } + + errorMessage = await extractErrorMessage(response) + } try { if (await hasValidSessionToken(apiUrl, token)) { diff --git a/apps/cli/src/client.ts b/apps/cli/src/client.ts index 2cfa020d0..d690f819c 100644 --- a/apps/cli/src/client.ts +++ b/apps/cli/src/client.ts @@ -12,7 +12,7 @@ const readString = (value: unknown): string | undefined => { return typeof value === "string" && value.length > 0 ? value : undefined } -const normalizeToken = (token: string | undefined) => { +export const normalizeToken = (token: string | undefined) => { if (!token || !token.includes("%")) { return token } @@ -42,6 +42,61 @@ export interface CommandContext { token?: string } +export interface CLIAuthSession { + user?: Record + session?: Record + role?: unknown + roleEndAt?: unknown + feedSubscriptionLimit?: unknown + rsshubSubscriptionLimit?: unknown +} + +const readSessionErrorMessage = async (response: Response): Promise => { + const contentType = response.headers.get("content-type") ?? "" + + if (contentType.includes("application/json")) { + const data = (await response.json().catch(() => null)) as Record | null + return typeof data?.message === "string" ? data.message : undefined + } + + const text = await response.text().catch(() => "") + return text || undefined +} + +export const fetchAuthSession = async ({ + apiUrl, + token, + verbose = false, +}: { + apiUrl: string + token: string + verbose?: boolean +}): Promise => { + const requestUrl = `${apiUrl}/better-auth/get-session` + if (verbose) { + console.error(`[request] GET ${requestUrl}`) + } + + const response = await fetch(requestUrl, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + Cookie: `__Secure-better-auth.session_token=${token}; better-auth.session_token=${token}`, + }, + }) + + if (verbose) { + console.error(`[response] GET ${requestUrl} -> ${response.status}`) + } + + if (!response.ok) { + const message = await readSessionErrorMessage(response) + throw new CLIError("UNAUTHORIZED", message || "Token is invalid or expired.") + } + + return (await response.json()) as CLIAuthSession +} + export const getGlobalOptions = (command: Command): GlobalOptions => { const options = command.optsWithGlobals() as Record diff --git a/apps/cli/src/commands/auth.ts b/apps/cli/src/commands/auth.ts index 19c92ad15..e6f2ec46f 100644 --- a/apps/cli/src/commands/auth.ts +++ b/apps/cli/src/commands/auth.ts @@ -1,12 +1,36 @@ import type { Command } from "commander" import { parsePositiveInt } from "../args" -import { loginWithBrowser } from "../browser-login" -import { getGlobalOptions } from "../client" +import { loginWithBrowser, resolveBrowserLoginToken } from "../browser-login" +import { fetchAuthSession, getGlobalOptions, normalizeToken } from "../client" import { runCommand } from "../command" import { clearToken, getConfigPath, updateConfig } from "../config" import { CLIError } from "../output" +export const resolveLoginToken = async ({ + inputToken, + apiUrl, + timeoutMs, + onStatus, +}: { + inputToken?: string + apiUrl: string + timeoutMs: number + onStatus: (message: string) => void +}) => { + if (inputToken) { + return await resolveBrowserLoginToken(apiUrl, inputToken) + } + + const browserLogin = await loginWithBrowser({ + apiUrl, + timeoutMs, + onStatus, + }) + + return browserLogin.token +} + interface AuthLoginOptions { token?: string timeout?: number @@ -16,21 +40,22 @@ const runLoginAction = async function (this: Command, options: AuthLoginOptions) await runCommand( this, async ({ client, options: globalOptions }) => { - let token = options.token ?? getGlobalOptions(this).token - if (!token) { - const timeoutMs = (options.timeout ?? 180) * 1000 - const browserLogin = await loginWithBrowser({ - apiUrl: globalOptions.apiUrl, - timeoutMs, - onStatus: (message) => { - console.error(`[auth] ${message}`) - }, - }) - token = browserLogin.token - } + const resolvedToken = await resolveLoginToken({ + inputToken: options.token ?? getGlobalOptions(this).token, + apiUrl: globalOptions.apiUrl, + timeoutMs: (options.timeout ?? 180) * 1000, + onStatus: (message) => { + console.error(`[auth] ${message}`) + }, + }) + const token = normalizeToken(resolvedToken) ?? resolvedToken client.setAuthToken(token) - const session = await client.api.auth.getSession() + const session = await fetchAuthSession({ + apiUrl: globalOptions.apiUrl, + token, + verbose: globalOptions.verbose, + }) if (!session.user || !session.session) { throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.") @@ -66,8 +91,16 @@ const runLogoutAction = async function (this: Command) { } const runWhoamiAction = async function (this: Command) { - await runCommand(this, async ({ client }) => { - const session = await client.api.auth.getSession() + await runCommand(this, async ({ token, options: globalOptions }) => { + if (!token) { + throw new CLIError("UNAUTHORIZED", "Missing token.") + } + + const session = await fetchAuthSession({ + apiUrl: globalOptions.apiUrl, + token, + verbose: globalOptions.verbose, + }) if (!session.user || !session.session) { throw new CLIError("UNAUTHORIZED", "Token is invalid or expired.") } @@ -87,7 +120,7 @@ const registerLoginCommand = (program: Command, name: string, description: strin program .command(name) .description(description) - .option("--token ", "Session token from Folo") + .option("--token ", "Session or one-time token from Folo") .option( "--timeout ", "Browser login timeout in seconds (default: 180)", diff --git a/apps/desktop/changelog/1.5.0.md b/apps/desktop/changelog/1.5.0.md new file mode 100644 index 000000000..3a7a888f0 --- /dev/null +++ b/apps/desktop/changelog/1.5.0.md @@ -0,0 +1,22 @@ +# What's new in v1.5.0 + +## Shiny new things + +## Improvements + +- Polished authentication, wallet, notifications, and discover surfaces +- Kept the AI chat input within the viewport during longer conversations + +## No longer broken + +- Fixed Electron sign-in for accounts using two-factor authentication +- Fixed PDF export through Electron IPC +- Fixed auth origin headers and restored renderer API requests +- Fixed theme preference persistence +- Fixed hovered unread entries disappearing in poor network conditions +- Fixed Obsidian vault selection and export reliability on macOS with native folder picking +- Fixed Android video playback changing the original audio pitch + +## Thanks + +Special thanks to volunteer contributor @Eumenides-K for their valuable contributions diff --git a/apps/desktop/layer/main/src/ipc/services/auth.ts b/apps/desktop/layer/main/src/ipc/services/auth.ts index 1bb2620dc..3a868123d 100644 --- a/apps/desktop/layer/main/src/ipc/services/auth.ts +++ b/apps/desktop/layer/main/src/ipc/services/auth.ts @@ -9,16 +9,19 @@ import { WindowManager } from "~/manager/window" import { buildManagedAuthCookieHeader, + buildManagedAuthCookieHeaderFromSetCookieHeader, getManagedAuthCookies, persistManagedAuthCookiesFromSetCookieHeader, } from "../../lib/auth-cookies" -import { getSessionTokenFromCookies, syncSessionToCliConfig } from "../../lib/cli-session-sync" +import { getCliSessionToken, syncSessionToCliConfig } from "../../lib/cli-session-sync" import { deleteNotificationsToken, updateNotificationsToken } from "../../lib/user" import { logger } from "../../logger" export class AuthService extends IpcService { static override readonly groupName = "auth" + private pendingTwoFactorCookieHeader: string | null = null + private getAuthRequestHeaders(additionalHeaders?: Record) { return { ...createDesktopAPIHeaders({ version: PKG.version }), @@ -61,6 +64,7 @@ export class AuthService extends IpcService { } private async clearSessionToken(): Promise { + this.pendingTwoFactorCookieHeader = null const mainWindow = WindowManager.getMainWindow() if (!mainWindow) { return @@ -93,7 +97,12 @@ export class AuthService extends IpcService { .json() .catch(async () => ({ message: await response.text() }))) as Record - const setCookie = response.headers.get("set-cookie") || "" + const setCookieValues = + typeof response.headers.getSetCookie === "function" ? response.headers.getSetCookie() : [] + const setCookie = + setCookieValues.length > 0 + ? setCookieValues.join(", ") + : response.headers.get("set-cookie") || "" const mainWindow = WindowManager.getMainWindow() if (response.ok && setCookie && mainWindow) { await persistManagedAuthCookiesFromSetCookieHeader({ @@ -102,6 +111,13 @@ export class AuthService extends IpcService { setCookieHeader: setCookie, }) } + + const pendingTwoFactorCookieHeader = buildManagedAuthCookieHeaderFromSetCookieHeader(setCookie) + this.pendingTwoFactorCookieHeader = + response.ok && typeof data.twoFactorRedirect === "boolean" && data.twoFactorRedirect + ? pendingTwoFactorCookieHeader || null + : null + const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/) const sessionToken = sessionCookieMatch?.[1] ?? null const token = typeof data.token === "string" ? data.token : null @@ -126,11 +142,13 @@ export class AuthService extends IpcService { } @IpcMethod() - async sessionChanged(_context: IpcContext): Promise { + async sessionChanged(_context: IpcContext, preferredToken?: string): Promise { await updateNotificationsToken() // Sync the current desktop session to the npm CLI login. - const token = await getSessionTokenFromCookies() + const token = await getCliSessionToken({ + preferredToken, + }) await syncSessionToCliConfig(token).catch((err) => { logger.error("Failed to sync session to CLI config:", err) }) @@ -168,14 +186,16 @@ export class AuthService extends IpcService { payload: { code: string; trustDevice?: boolean; headers?: Record }, ) { const mainWindow = WindowManager.getMainWindow() - const cookieHeader = mainWindow - ? buildManagedAuthCookieHeader( - await getManagedAuthCookies({ - apiURL: env.VITE_API_URL, - session: mainWindow.webContents.session, - }), - ) - : "" + const cookieHeader = + this.pendingTwoFactorCookieHeader || + (mainWindow + ? buildManagedAuthCookieHeader( + await getManagedAuthCookies({ + apiURL: env.VITE_API_URL, + session: mainWindow.webContents.session, + }), + ) + : "") const response = await fetch(`${env.VITE_API_URL}/better-auth/two-factor/verify-totp`, { method: "POST", @@ -193,7 +213,10 @@ export class AuthService extends IpcService { const data = (await response .json() .catch(async () => ({ message: await response.text() }))) as Record - const setCookie = response.headers.get("set-cookie") || "" + const setCookie = + typeof response.headers.getSetCookie === "function" + ? response.headers.getSetCookie().join(", ") + : response.headers.get("set-cookie") || "" if (response.ok && setCookie && mainWindow) { await persistManagedAuthCookiesFromSetCookieHeader({ apiURL: env.VITE_API_URL, @@ -202,6 +225,22 @@ export class AuthService extends IpcService { }) } + const sessionCookieMatch = setCookie.match(/better-auth\.session_token=([^;]+)/) + const sessionTokenFromCookie = sessionCookieMatch?.[1] ?? null + const sessionTokenFromBody = + data.session && typeof data.session === "object" && "token" in data.session + ? (data.session as { token?: unknown }).token + : null + const sessionToken = + typeof sessionTokenFromBody === "string" ? sessionTokenFromBody : sessionTokenFromCookie + if (typeof sessionToken === "string") { + data.sessionToken = sessionToken + } + + if (response.ok) { + this.pendingTwoFactorCookieHeader = null + } + return { data, error: response.ok diff --git a/apps/desktop/layer/main/src/ipc/services/cli.ts b/apps/desktop/layer/main/src/ipc/services/cli.ts index feef0aa8d..09dd2f0be 100644 --- a/apps/desktop/layer/main/src/ipc/services/cli.ts +++ b/apps/desktop/layer/main/src/ipc/services/cli.ts @@ -6,6 +6,7 @@ import { getCliConfigPath, getCliInstallCommand, getCliLoginCommand, + getCliSessionToken, getSessionTokenFromCookies, isCliRunnerAvailable, readCliConfig, @@ -45,13 +46,18 @@ export class CliService extends IpcService { } @IpcMethod() - async installCli(_context: IpcContext): Promise<{ success: boolean; error?: string }> { + async installCli( + _context: IpcContext, + preferredToken?: string, + ): Promise<{ success: boolean; error?: string }> { try { if (!(await isCliRunnerAvailable())) { return { success: false, error: "npx is not available. Install Node.js and npm first." } } - const token = await getSessionTokenFromCookies() + const token = await getCliSessionToken({ + preferredToken, + }) if (!token) { return { success: false, error: "Sign in to Folo Desktop first." } } diff --git a/apps/desktop/layer/main/src/lib/auth-cookies.test.ts b/apps/desktop/layer/main/src/lib/auth-cookies.test.ts index d8290360c..9c27a33cb 100644 --- a/apps/desktop/layer/main/src/lib/auth-cookies.test.ts +++ b/apps/desktop/layer/main/src/lib/auth-cookies.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it, vi } from "vitest" import { buildManagedAuthCookieHeader, + buildManagedAuthCookieHeaderFromSetCookieHeader, getManagedAuthCookieNames, persistManagedAuthCookiesFromSetCookieHeader, } from "./auth-cookies" @@ -25,6 +26,19 @@ describe("auth cookies", () => { expect(getManagedAuthCookieNames()).toContain("two_factor") }) + it("keeps prefixed two-factor cookies from a set-cookie header", () => { + const header = buildManagedAuthCookieHeaderFromSetCookieHeader( + [ + "__Secure-better-auth.two_factor=signed-two-factor; Path=/; HttpOnly; Secure; SameSite=Lax", + "better-auth.last_used_login_method=email; Path=/; HttpOnly; Secure; SameSite=Lax", + ].join(", "), + ) + + expect(header).toBe( + "__Secure-better-auth.two_factor=signed-two-factor; better-auth.last_used_login_method=email", + ) + }) + it("persists managed auth cookies and removes expired ones from a set-cookie header", async () => { const set = vi.fn().mockImplementation(async () => {}) const remove = vi.fn().mockImplementation(async () => {}) diff --git a/apps/desktop/layer/main/src/lib/auth-cookies.ts b/apps/desktop/layer/main/src/lib/auth-cookies.ts index 698ed3b19..f8770b635 100644 --- a/apps/desktop/layer/main/src/lib/auth-cookies.ts +++ b/apps/desktop/layer/main/src/lib/auth-cookies.ts @@ -7,8 +7,14 @@ const MANAGED_AUTH_COOKIE_NAMES = [ "better-auth.session_data", "better-auth.last_used_login_method", "dont_remember", + "__Secure-better-auth.dont_remember", + "better-auth.dont_remember", "trust_device", + "__Secure-better-auth.trust_device", + "better-auth.trust_device", "two_factor", + "__Secure-better-auth.two_factor", + "better-auth.two_factor", ] as const type ManagedAuthCookieName = (typeof MANAGED_AUTH_COOKIE_NAMES)[number] @@ -161,6 +167,18 @@ export const getManagedAuthCookieNames = () => { return [...MANAGED_AUTH_COOKIE_NAMES] } +export const buildManagedAuthCookieHeaderFromSetCookieHeader = (setCookieHeader: string) => { + if (!setCookieHeader.trim()) { + return "" + } + + return parseSetCookieHeader(setCookieHeader) + .filter((cookie) => isManagedAuthCookie(cookie.name)) + .filter((cookie) => !shouldRemoveCookie(cookie)) + .map((cookie) => `${cookie.name}=${cookie.value}`) + .join("; ") +} + export const buildManagedAuthCookieHeader = (cookies: Array>) => { return cookies .filter((cookie) => isManagedAuthCookie(cookie.name)) diff --git a/apps/desktop/layer/main/src/lib/cli-login-token.ts b/apps/desktop/layer/main/src/lib/cli-login-token.ts new file mode 100644 index 000000000..aa218106a --- /dev/null +++ b/apps/desktop/layer/main/src/lib/cli-login-token.ts @@ -0,0 +1,7 @@ +export const resolveCliSessionToken = ({ + preferredToken, + cookieToken, +}: { + preferredToken?: string + cookieToken?: string +}) => cookieToken || preferredToken diff --git a/apps/desktop/layer/main/src/lib/cli-session-sync.test.ts b/apps/desktop/layer/main/src/lib/cli-session-sync.test.ts new file mode 100644 index 000000000..189d5a29e --- /dev/null +++ b/apps/desktop/layer/main/src/lib/cli-session-sync.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest" + +import { resolveCliSessionToken } from "./cli-login-token" + +describe("resolveCliSessionToken", () => { + it("prefers the desktop session cookie token", () => { + expect( + resolveCliSessionToken({ + preferredToken: "one-time-token", + cookieToken: "session-token", + }), + ).toBe("session-token") + }) + + it("falls back to the preferred token when no cookie token exists", () => { + expect( + resolveCliSessionToken({ + preferredToken: "session-token", + }), + ).toBe("session-token") + }) + + it("returns undefined when neither token is available", () => { + expect(resolveCliSessionToken({})).toBeUndefined() + }) +}) diff --git a/apps/desktop/layer/main/src/lib/cli-session-sync.ts b/apps/desktop/layer/main/src/lib/cli-session-sync.ts index 79fcdf0c9..81157be55 100644 --- a/apps/desktop/layer/main/src/lib/cli-session-sync.ts +++ b/apps/desktop/layer/main/src/lib/cli-session-sync.ts @@ -4,12 +4,16 @@ import { homedir } from "node:os" import { promisify } from "node:util" import { env } from "@follow/shared/env.desktop" +import { createAuthRequestOriginHeaders, createDesktopAPIHeaders } from "@follow/utils/headers" +import PKG from "@pkg" import { join } from "pathe" import { BETTER_AUTH_COOKIE_NAME_SESSION_TOKEN } from "~/constants/app" import { WindowManager } from "~/manager/window" import { logger } from "../logger" +import { buildManagedAuthCookieHeader, getManagedAuthCookies } from "./auth-cookies" +import { resolveCliSessionToken } from "./cli-login-token" const execFileAsync = promisify(execFile) export const CLI_NPM_PACKAGE_NAME = "folocli" @@ -18,6 +22,12 @@ const CLI_CONFIG_DIR = join(homedir(), ".folo") const CLI_CONFIG_PATH = join(CLI_CONFIG_DIR, "config.json") const getNpxCommand = () => (process.platform === "win32" ? "npx.cmd" : "npx") +const getCliSyncRequestHeaders = (additionalHeaders?: Record) => ({ + ...createDesktopAPIHeaders({ version: PKG.version }), + ...createAuthRequestOriginHeaders(env.VITE_WEB_URL), + ...additionalHeaders, +}) + export interface CliConfig { token?: string apiUrl?: string @@ -41,8 +51,7 @@ export const getCliConfigPath = () => CLI_CONFIG_PATH export const getCliInstallCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} --help` -export const getCliLoginCommand = () => - `npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token ` +export const getCliLoginCommand = () => `npx --yes ${CLI_NPX_PACKAGE_SPEC} login --token ` const runCliCommand = async (args: string[]) => { await execFileAsync(getNpxCommand(), ["--yes", CLI_NPX_PACKAGE_SPEC, ...args], { @@ -90,6 +99,93 @@ export const getSessionTokenFromCookies = async (): Promise return sessionCookie?.value } +const generateOneTimeTokenFromCurrentSession = async (): Promise => { + const window = WindowManager.getMainWindow() + if (!window) return undefined + + const cookieHeader = buildManagedAuthCookieHeader( + await getManagedAuthCookies({ + apiURL: env.VITE_API_URL, + session: window.webContents.session, + }), + ) + + if (!cookieHeader) { + return undefined + } + + const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/generate`, { + method: "GET", + headers: getCliSyncRequestHeaders({ + Cookie: cookieHeader, + }), + }) + + if (!response.ok) { + return undefined + } + + const data = (await response.json().catch(() => null)) as { token?: unknown } | null + return typeof data?.token === "string" ? data.token : undefined +} + +const resolveSessionTokenFromOneTimeToken = async ( + oneTimeToken: string, +): Promise => { + const response = await fetch(`${env.VITE_API_URL}/better-auth/one-time-token/apply`, { + method: "POST", + headers: getCliSyncRequestHeaders({ + "content-type": "application/json", + }), + body: JSON.stringify({ token: oneTimeToken }), + }) + + if (!response.ok) { + return undefined + } + + const setCookieValues = + typeof response.headers.getSetCookie === "function" + ? response.headers.getSetCookie() + : ([response.headers.get("set-cookie")].filter(Boolean) as string[]) + for (const setCookie of setCookieValues) { + const match = setCookie.match(/(?:__Secure-)?better-auth\.session_token=([^;]+)/) + if (match?.[1]) { + return match[1] + } + } + + const data = (await response.json().catch(() => null)) as { session?: { token?: unknown } } | null + return typeof data?.session?.token === "string" ? data.session.token : undefined +} + +export const getCliSessionToken = async ({ + preferredToken, +}: { + preferredToken?: string +} = {}): Promise => { + const oneTimeToken = await generateOneTimeTokenFromCurrentSession().catch((error) => { + logger.error("Failed to generate one-time token for CLI sync:", error) + return + }) + + if (oneTimeToken) { + const sessionToken = await resolveSessionTokenFromOneTimeToken(oneTimeToken).catch((error) => { + logger.error("Failed to resolve session token from one-time token:", error) + return + }) + + if (sessionToken) { + return sessionToken + } + } + + return resolveCliSessionToken({ + preferredToken, + cookieToken: await getSessionTokenFromCookies(), + }) +} + export const syncSessionToCliConfig = async (token?: string): Promise => { if (token) { const config = await readCliConfig() diff --git a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx index 6d54589c6..51c5c365e 100644 --- a/apps/desktop/layer/renderer/src/modules/auth/Form.tsx +++ b/apps/desktop/layer/renderer/src/modules/auth/Form.tsx @@ -37,25 +37,44 @@ const getAuthTokenFromResult = (result: unknown) => { return null } + if ("token" in result && typeof result.token === "string") { + return result.token + } + if ("sessionToken" in result && typeof result.sessionToken === "string") { return result.sessionToken } - if ("token" in result && typeof result.token === "string") { - return result.token + if ("session" in result && result.session && typeof result.session === "object") { + const { token } = result.session as { token?: unknown } + if (typeof token === "string") { + return token + } } if ( "data" in result && result.data && typeof result.data === "object" && - ("sessionToken" in result.data || "token" in result.data) + ("sessionToken" in result.data || "token" in result.data || "session" in result.data) ) { - const { sessionToken, token } = result.data as { sessionToken?: unknown; token?: unknown } - if (typeof sessionToken === "string") { - return sessionToken + const { sessionToken, token, session } = result.data as { + sessionToken?: unknown + token?: unknown + session?: { token?: unknown } | unknown } - return typeof token === "string" ? token : null + if (typeof token === "string") { + return token + } + if ( + session && + typeof session === "object" && + "token" in session && + typeof session.token === "string" + ) { + return session.token + } + return typeof sessionToken === "string" ? sessionToken : null } return null @@ -74,7 +93,15 @@ const normalizeElectronAuthResult = (result: unknown): ElectronAuthResult => { return {} } - return result as ElectronAuthResult + const normalized = result as ElectronAuthResult & Record + if ("data" in normalized || "error" in normalized) { + return normalized + } + + return { + data: normalized, + error: null, + } } const setElectronSessionToken = async (token: string) => { diff --git a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx index 896a1184a..500e57aca 100644 --- a/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx +++ b/apps/desktop/layer/renderer/src/modules/command/commands/integration.tsx @@ -283,6 +283,7 @@ const useRegisterObsidianCommands = () => { author: string publishedAt: string vaultPath: string + description?: string }) => { return await ipcServices?.integration.saveToObsidian(data) }, diff --git a/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx b/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx index 6144ff7fc..87b22252d 100644 --- a/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx +++ b/apps/desktop/layer/renderer/src/modules/profile/two-factor.tsx @@ -78,7 +78,7 @@ export function TOTPForm({ const updateMutation = useMutation({ mutationFn: onSubmitMutationFn, onError: (error) => { - const { code } = getFetchErrorInfo(error) + const { code, message } = getFetchErrorInfo(error) if (error.message === "invalid two factor authentication" || code === 4007) { form.resetField("code") form.setError("code", { @@ -90,7 +90,10 @@ export function TOTPForm({ form.setFocus("code") }, 10) controls.start("shake") + return } + + toast.error(message || t("profile.totp_code.invalid")) }, onSuccess, }) @@ -135,6 +138,11 @@ export function TOTPForm({ )} /> +
+ +
) diff --git a/apps/desktop/layer/renderer/src/modules/settings/icons/Android2CuteReIcon.tsx b/apps/desktop/layer/renderer/src/modules/settings/icons/Android2CuteReIcon.tsx new file mode 100644 index 000000000..634b49525 --- /dev/null +++ b/apps/desktop/layer/renderer/src/modules/settings/icons/Android2CuteReIcon.tsx @@ -0,0 +1,24 @@ +import type { SVGProps } from "react" + +export const Android2CuteReIcon = (props: SVGProps) => ( + + + + + +) diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx index 0ff314e64..73e20d84f 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/cli.tsx @@ -4,10 +4,38 @@ import { useCallback, useEffect, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" +import { oneTimeToken } from "~/lib/auth" import { ipcServices } from "~/lib/client" +import { getAuthSessionToken } from "~/lib/client-session" +import { copyToClipboard } from "~/lib/clipboard" import { SettingSectionTitle } from "../section" +const getOneTimeTokenFromResult = (result: unknown) => { + if (!result || typeof result !== "object") { + return null + } + + if ("token" in result && typeof result.token === "string") { + return result.token + } + + if ( + "data" in result && + result.data && + typeof result.data === "object" && + "token" in result.data && + typeof result.data.token === "string" + ) { + return result.data.token + } + + return null +} + +const LATEST_WITH_NPX_COMMAND = "npx --yes folocli@latest --help" +const AGENT_PROMPT = "Read https://api.folo.is/skill.md and follow the instructions to use Folo." + export const SettingCli = () => { interface CliInstallStatus { connected: boolean @@ -36,7 +64,10 @@ export const SettingCli = () => { const handleInstall = useCallback(async () => { setLoading(true) try { - const result = await ipcServices?.cli.installCli() + const generatedOneTimeToken = getOneTimeTokenFromResult(await oneTimeToken.generate()) + const result = await ipcServices?.cli.installCli( + generatedOneTimeToken ?? getAuthSessionToken() ?? undefined, + ) if (result?.success) { toast.success(t("cli.install_success")) } else { @@ -102,24 +133,39 @@ export const SettingCli = () => {
-
- {t("cli.package")} +
+ RUN LATEST WITH NPX +
- {status.packageName} + {LATEST_WITH_NPX_COMMAND}
-
- {t("cli.global_install")} +
+ AGENT PROMPT +
- {status.installCommand} -
- -
-
- {t("cli.desktop_sync")} -
- {status.loginCommand} +

{AGENT_PROMPT}

diff --git a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx index 4fec83da8..9433743e5 100644 --- a/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx +++ b/apps/desktop/layer/renderer/src/modules/settings/tabs/integration/index.tsx @@ -14,7 +14,7 @@ import { } from "@follow/components/ui/platform-icon/icons.js" import { IN_ELECTRON } from "@follow/shared/constants" import type { FC } from "react" -import { useEffect, useMemo, useState } from "react" +import { useCallback, useEffect, useMemo, useState } from "react" import { useTranslation } from "react-i18next" import { toast } from "sonner" diff --git a/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx b/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx index a64966178..cad159987 100644 --- a/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx +++ b/apps/desktop/layer/renderer/src/pages/settings/(settings)/cli.tsx @@ -1,14 +1,15 @@ import { IN_ELECTRON } from "@follow/shared/constants" +import { Android2CuteReIcon } from "~/modules/settings/icons/Android2CuteReIcon" import { SettingCli } from "~/modules/settings/tabs/cli" import { SettingsTitle } from "~/modules/settings/title" import { defineSettingPageData } from "~/modules/settings/utils" -const iconName = "i-mgc-terminal-cute-re" const priority = (1000 << 1) + 25 export const loader = defineSettingPageData({ - icon: iconName, + icon: , + headerIcon: , name: "titles.cli", priority, hideIf: () => !IN_ELECTRON, diff --git a/apps/desktop/layer/renderer/src/queries/auth.ts b/apps/desktop/layer/renderer/src/queries/auth.ts index 7562e152c..935ce318e 100644 --- a/apps/desktop/layer/renderer/src/queries/auth.ts +++ b/apps/desktop/layer/renderer/src/queries/auth.ts @@ -94,7 +94,8 @@ export const useSession = (options?: { enabled?: boolean }) => { export const handleSessionChanges = () => { setLoginModalShow(false) - ipcServices?.auth.sessionChanged() + const authSessionToken = getAuthSessionToken() + ipcServices?.auth.sessionChanged(authSessionToken ?? undefined) window.location.reload() } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 2c5137fe6..99b995dc5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,7 +1,7 @@ { "name": "Folo", "type": "module", - "version": "1.4.0", + "version": "1.5.0", "private": true, "description": "Follow everything in one place", "author": "Folo Team", @@ -94,5 +94,5 @@ "vite-tsconfig-paths": "6.1.1" }, "productName": "Folo", - "mainHash": "0c464fca7c98fd4b42abba743abb1cd590e216043987acf4f6d1e10392ce0e57" + "mainHash": "4778cc1cb43d08ed2a5edf391f0d22694641dc15eb27eaefb45bfa09ff176091" }