diff --git a/apps/desktop/src/app/ClerkTokenStore.test.ts b/apps/desktop/src/app/ClerkTokenStore.test.ts new file mode 100644 index 0000000000000..b7ac0e38d5142 --- /dev/null +++ b/apps/desktop/src/app/ClerkTokenStore.test.ts @@ -0,0 +1,189 @@ +// @effect-diagnostics nodeBuiltinImport:off -- These tests assert real POSIX modes on a temporary directory. +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import { assert, describe, it } from "@effect/vitest"; +import { vi } from "vite-plus/test"; + +vi.mock("node:fs", { spy: true }); + +import { openClerkTokenStorage, type ClerkTokenStorage } from "./ClerkTokenStore.ts"; + +// oxlint-disable-next-line t3code/no-global-process-runtime -- Unix mode bits are the assertion; Windows reports them through ACLs. +const describePosix = describe.skipIf(process.platform === "win32"); + +const modeOf = (path: string): number => NodeFS.statSync(path).mode & 0o777; + +function writeLoose(path: string, contents: string): void { + NodeFS.writeFileSync(path, contents, { mode: 0o666 }); + NodeFS.chmodSync(path, 0o666); +} + +async function withStateDir(run: (stateDir: string) => Promise | void): Promise { + const stateDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-clerk-tokens-")); + try { + await run(stateDir); + } finally { + NodeFS.rmSync(stateDir, { recursive: true, force: true }); + } +} + +const idleStorage = (): ClerkTokenStorage => ({ + getItem: async () => null, + setItem: async () => undefined, + removeItem: () => undefined, +}); + +describePosix("ClerkTokenStore", () => { + it("keeps clerk tokens at 0600 inside a 0700 directory", async () => { + await withStateDir(async (stateDir) => { + NodeFS.chmodSync(stateDir, 0o755); + const directory = NodePath.join(stateDir, "clerk"); + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + const tokenPath = NodePath.join(directory, "clerk-tokens.json"); + NodeFS.mkdirSync(directory, { mode: 0o755 }); + NodeFS.chmodSync(directory, 0o755); + writeLoose(legacyPath, '{"__clerk_client_jwt":"enc:existing"}\n'); + + const opened: string[] = []; + const storage = openClerkTokenStorage(stateDir, (cwd) => { + opened.push(cwd); + return { + getItem: async () => { + writeLoose(tokenPath, "from-get"); + return "from-get"; + }, + setItem: (_key, value) => { + writeLoose(tokenPath, value); + }, + removeItem: () => { + NodeFS.rmSync(tokenPath, { force: true }); + }, + }; + }); + + assert.deepEqual(opened, [directory]); + assert.equal(modeOf(stateDir), 0o755); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal( + NodeFS.readFileSync(tokenPath, "utf8"), + '{"__clerk_client_jwt":"enc:existing"}\n', + ); + + assert.equal(await storage.getItem("client"), "from-get"); + assert.equal(modeOf(tokenPath), 0o600); + await storage.setItem("client", "from-set"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "from-set"); + storage.removeItem("client"); + assert.equal(NodeFS.existsSync(tokenPath), false); + + writeLoose(tokenPath, "kept"); + writeLoose(legacyPath, "stale"); + NodeFS.chmodSync(directory, 0o755); + openClerkTokenStorage(stateDir, () => idleStorage()); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept"); + }); + }); + + it("removes the legacy token file when the private copy already exists", async () => { + await withStateDir((stateDir) => { + const directory = NodePath.join(stateDir, "clerk"); + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + const tokenPath = NodePath.join(directory, "clerk-tokens.json"); + NodeFS.mkdirSync(directory, { mode: 0o755 }); + writeLoose(legacyPath, "stale-credential"); + writeLoose(tokenPath, "kept-credential"); + + openClerkTokenStorage(stateDir, () => idleStorage()); + + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(modeOf(directory), 0o700); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "kept-credential"); + }); + }); + + it("resolves a relative state directory before opening storage", async () => { + await withStateDir(async (stateDir) => { + const relativeStateDir = NodePath.relative(process.cwd(), stateDir); + const opened: string[] = []; + openClerkTokenStorage(relativeStateDir, (directory) => { + opened.push(directory); + return idleStorage(); + }); + + const directory = NodePath.join(stateDir, "clerk"); + assert.equal(NodePath.isAbsolute(relativeStateDir), false); + assert.deepEqual(opened, [directory]); + assert.equal(modeOf(directory), 0o700); + }); + }); + + it("migrates relative state paths from Electron userData, not the working directory", async () => { + await withStateDir((stateDir) => { + const relativeStateDir = NodePath.relative(process.cwd(), stateDir); + const userData = NodePath.join( + stateDir, + "electron", + ...relativeStateDir.split(NodePath.sep).map(() => "profile"), + ); + const legacyStateDir = NodePath.join(userData, relativeStateDir); + const legacyPath = NodePath.join(legacyStateDir, "clerk-tokens.json"); + NodeFS.mkdirSync(legacyStateDir, { recursive: true }); + writeLoose(legacyPath, "existing-session"); + const unrelatedPath = NodePath.join(stateDir, "clerk-tokens.json"); + writeLoose(unrelatedPath, "not-the-electron-session"); + + openClerkTokenStorage(relativeStateDir, idleStorage, userData); + + const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json"); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + assert.equal(NodeFS.readFileSync(unrelatedPath, "utf8"), "not-the-electron-session"); + }); + }); + + it("migrates tokens when the legacy directory is on another filesystem", async () => { + await withStateDir((stateDir) => { + const legacyPath = NodePath.join(stateDir, "clerk-tokens.json"); + writeLoose(legacyPath, "existing-session"); + using _rename = vi.spyOn(NodeFS, "renameSync").mockImplementation(() => { + throw Object.assign(new Error("cross-device link"), { code: "EXDEV" }); + }); + + openClerkTokenStorage(stateDir, idleStorage); + + const tokenPath = NodePath.join(stateDir, "clerk", "clerk-tokens.json"); + assert.equal(NodeFS.readFileSync(tokenPath, "utf8"), "existing-session"); + assert.equal(modeOf(tokenPath), 0o600); + assert.equal(NodeFS.existsSync(legacyPath), false); + }); + }); + + it("does not follow a symlinked clerk directory or token file", async () => { + await withStateDir(async (stateDir) => { + const outsideDirectory = NodePath.join(stateDir, "outside-dir"); + NodeFS.mkdirSync(outsideDirectory, { mode: 0o755 }); + NodeFS.chmodSync(outsideDirectory, 0o755); + NodeFS.symlinkSync(outsideDirectory, NodePath.join(stateDir, "clerk")); + + assert.throws(() => openClerkTokenStorage(stateDir, idleStorage), /must be a real directory/); + assert.equal(modeOf(outsideDirectory), 0o755); + NodeFS.unlinkSync(NodePath.join(stateDir, "clerk")); + + const outsideFile = NodePath.join(stateDir, "outside.json"); + NodeFS.writeFileSync(outsideFile, "secret", { mode: 0o644 }); + NodeFS.chmodSync(outsideFile, 0o644); + NodeFS.symlinkSync(outsideFile, NodePath.join(stateDir, "clerk-tokens.json")); + assert.throws(() => openClerkTokenStorage(stateDir, idleStorage), /must be a regular file/); + assert.equal(modeOf(outsideFile), 0o644); + }); + }); +}); diff --git a/apps/desktop/src/app/ClerkTokenStore.ts b/apps/desktop/src/app/ClerkTokenStore.ts new file mode 100644 index 0000000000000..005736c97cb38 --- /dev/null +++ b/apps/desktop/src/app/ClerkTokenStore.ts @@ -0,0 +1,117 @@ +// @effect-diagnostics nodeBuiltinImport:off -- The Clerk bridge must lock the token file down before startup can yield to Electron's ready event. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +// electron-store chmods every write to 0666; the private directory protects tokens during writes. +const CLERK_TOKEN_DIRECTORY_MODE = 0o700; +const CLERK_TOKEN_FILE_MODE = 0o600; +const CLERK_TOKEN_DIRECTORY_NAME = "clerk"; +const CLERK_TOKEN_FILE_NAME = "clerk-tokens.json"; + +type Awaitable = T | Promise; + +export interface ClerkTokenStorage { + readonly getItem: (key: string) => Awaitable; + readonly setItem: (key: string, value: string) => Awaitable; + readonly removeItem: (key: string) => Awaitable; +} + +function isErrno(error: unknown, code: string): boolean { + return typeof error === "object" && error !== null && "code" in error && error.code === code; +} + +function lstatIfExists(path: string): NodeFS.Stats | undefined { + try { + return NodeFS.lstatSync(path); + } catch (error) { + if (isErrno(error, "ENOENT")) return undefined; + throw error; + } +} + +function assertRegularFile(path: string, stat: NodeFS.Stats): void { + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new Error(`Clerk token file "${path}" must be a regular file.`); + } +} + +function restrictClerkTokenFile(path: string): void { + const stat = lstatIfExists(path); + if (stat === undefined) return; + assertRegularFile(path, stat); + if ((stat.mode & 0o777) === CLERK_TOKEN_FILE_MODE) return; + NodeFS.chmodSync(path, CLERK_TOKEN_FILE_MODE); +} + +function migrateLegacyClerkTokenFile(stateDir: string, directory: string): void { + const legacyPath = NodePath.join(stateDir, CLERK_TOKEN_FILE_NAME); + const tokenPath = NodePath.join(directory, CLERK_TOKEN_FILE_NAME); + const legacy = lstatIfExists(legacyPath); + if (legacy !== undefined) { + assertRegularFile(legacyPath, legacy); + NodeFS.chmodSync(legacyPath, CLERK_TOKEN_FILE_MODE); + } + + const destination = lstatIfExists(tokenPath); + if (destination === undefined) { + if (legacy === undefined) return; + try { + NodeFS.copyFileSync(legacyPath, tokenPath, NodeFS.constants.COPYFILE_EXCL); + NodeFS.chmodSync(tokenPath, CLERK_TOKEN_FILE_MODE); + NodeFS.unlinkSync(legacyPath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } + return; + } + + assertRegularFile(tokenPath, destination); + NodeFS.chmodSync(tokenPath, CLERK_TOKEN_FILE_MODE); + if (legacy === undefined) return; + try { + NodeFS.unlinkSync(legacyPath); + } catch (error) { + if (!isErrno(error, "ENOENT")) throw error; + } +} + +function restrictAfter(run: () => Awaitable, tighten: () => void): Awaitable { + let result: Awaitable; + try { + result = run(); + } catch (error) { + tighten(); + throw error; + } + if (result instanceof Promise) return result.finally(tighten); + tighten(); + return result; +} + +/** Opens Clerk token storage in a private directory and keeps the file at `0600`. */ +export function openClerkTokenStorage( + stateDir: string, + createStorage: (directory: string) => ClerkTokenStorage, + userDataPath = ".", +): ClerkTokenStorage { + const absoluteStateDir = NodePath.resolve(stateDir); + const directory = NodePath.join(absoluteStateDir, CLERK_TOKEN_DIRECTORY_NAME); + NodeFS.mkdirSync(directory, { recursive: true, mode: CLERK_TOKEN_DIRECTORY_MODE }); + const stat = NodeFS.lstatSync(directory); + if (stat.isSymbolicLink() || !stat.isDirectory()) { + throw new Error(`Clerk token directory "${directory}" must be a real directory.`); + } + NodeFS.chmodSync(directory, CLERK_TOKEN_DIRECTORY_MODE); + // electron-store rebases relative paths under Electron userData, not the working directory. + migrateLegacyClerkTokenFile(NodePath.resolve(userDataPath, stateDir), directory); + const tokenPath = NodePath.join(directory, CLERK_TOKEN_FILE_NAME); + restrictClerkTokenFile(tokenPath); + + const storage = createStorage(directory); + const tighten = () => restrictClerkTokenFile(tokenPath); + return { + getItem: (key) => restrictAfter(() => storage.getItem(key), tighten), + setItem: (key, value) => restrictAfter(() => storage.setItem(key, value), tighten), + removeItem: (key) => restrictAfter(() => storage.removeItem(key), tighten), + }; +} diff --git a/apps/desktop/src/app/DesktopClerk.test.ts b/apps/desktop/src/app/DesktopClerk.test.ts index 602383309be44..ca194ba8c5fa0 100644 --- a/apps/desktop/src/app/DesktopClerk.test.ts +++ b/apps/desktop/src/app/DesktopClerk.test.ts @@ -1,13 +1,21 @@ // @effect-diagnostics nodeBuiltinImport:off globalFetchInEffect:off - Hosted handoff test uses a real localhost listener without an OpenAI account. +import * as NodeFS from "node:fs"; import * as NodeHttp from "node:http"; -import * as NodePath from "@effect/platform-node/NodePath"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; +import * as NodePathLayer from "@effect/platform-node/NodePath"; import { codexAuthHandoffUrl, readCodexAuthDelivery } from "@t3tools/shared/codexAuthHandoff"; import { EnvironmentId, ProviderInstanceId } from "@t3tools/contracts"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { assert, describe, it } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import { beforeEach, vi } from "vite-plus/test"; +import { afterAll, beforeEach, vi } from "vite-plus/test"; + +const clerkStateDir = NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), "t3-desktop-clerk-")); +afterAll(() => { + NodeFS.rmSync(clerkStateDir, { recursive: true, force: true }); +}); const { createClerkBridgeMock, requestSingleInstanceLockMock, storageAdapter, storageMock } = vi.hoisted(() => ({ @@ -60,7 +68,7 @@ const layerDesktopClerk = ( isPackaged = false, ) => { const environment = DesktopEnvironment.DesktopEnvironment.of({ - stateDir: "/tmp/t3-state", + stateDir: clerkStateDir, isDevelopment, appDataDirectory: "/tmp/app-data", platform, @@ -96,7 +104,7 @@ const layerDesktopClerk = ( return DesktopClerk.layer.pipe( Layer.provide( Layer.mergeAll( - NodePath.layerPosix, + NodePathLayer.layerPosix, Layer.succeed(DesktopEnvironment.DesktopEnvironment, environment), Layer.succeed(ElectronApp.ElectronApp, electronApp), Layer.succeed(ElectronShell.ElectronShell, shell), @@ -199,6 +207,10 @@ describe("DesktopClerk", () => { "on:open-url", "createClerkBridge", ]); + assert.deepEqual(storageMock.mock.calls, [ + [{ path: NodePath.join(NodePath.resolve(clerkStateDir), "clerk") }], + ]); + assert.notStrictEqual(createClerkBridgeMock.mock.calls[0]?.[0]?.storage, storageAdapter); }, ); diff --git a/apps/desktop/src/app/DesktopClerk.ts b/apps/desktop/src/app/DesktopClerk.ts index fd49354a048e4..2aa9726ee49f1 100644 --- a/apps/desktop/src/app/DesktopClerk.ts +++ b/apps/desktop/src/app/DesktopClerk.ts @@ -22,6 +22,7 @@ import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; import * as DesktopUserData from "./DesktopUserData.ts"; import * as DesktopEnvironment from "./DesktopEnvironment.ts"; +import { openClerkTokenStorage } from "./ClerkTokenStore.ts"; import * as DesktopWebLinks from "./DesktopWebLinks.ts"; declare const __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__: string | undefined; @@ -85,9 +86,18 @@ export const desktopClerkFrontendApiHostname = resolveDesktopClerkFrontendApiHos : __T3CODE_BUILD_CLERK_PUBLISHABLE_KEY__, ); -function createDesktopClerkBridge(stateDir: string, isDevelopment: boolean, version: string) { +function createDesktopClerkBridge( + stateDir: string, + isDevelopment: boolean, + version: string, + userDataPath: string, +) { return createClerkBridge({ - storage: storage({ path: stateDir }), + storage: openClerkTokenStorage( + stateDir, + (directory) => storage({ path: directory }), + userDataPath, + ), manageSingleInstanceLock: false, passkeys: true, renderer: { @@ -137,6 +147,7 @@ export const make = Effect.gen(function* () { environment.stateDir, environment.isDevelopment, environment.appVersion, + userDataPath, ), catch: (cause) => new DesktopClerkBridgeInitializationError({