diff --git a/.changeset/clerk-cli-macos-keychain-password-prompt-issue.md b/.changeset/clerk-cli-macos-keychain-password-prompt-issue.md new file mode 100644 index 00000000..a5103f9d --- /dev/null +++ b/.changeset/clerk-cli-macos-keychain-password-prompt-issue.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +Prevent local unsigned macOS builds from sharing the release keychain entry. diff --git a/packages/cli-core/src/lib/credential-store-signing.test.ts b/packages/cli-core/src/lib/credential-store-signing.test.ts new file mode 100644 index 00000000..243f5137 --- /dev/null +++ b/packages/cli-core/src/lib/credential-store-signing.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test"; +import { isReleaseSignedMacosBinary } from "./credential-store.ts"; + +describe("isReleaseSignedMacosBinary", () => { + test("returns false for unversioned dev builds", () => { + const output = ` +Executable=/tmp/clerk +Identifier=clerk +TeamIdentifier=L8SD6SB282 +`; + + expect(isReleaseSignedMacosBinary(undefined, output)).toBe(false); + }); + + test("returns true for Clerk-signed release binaries", () => { + const output = ` +Executable=/tmp/clerk +Identifier=clerk +Authority=Developer ID Application: Clerk, Inc (L8SD6SB282) +TeamIdentifier=L8SD6SB282 +`; + + expect(isReleaseSignedMacosBinary("1.2.3", output)).toBe(true); + }); + + test("returns false for non-Clerk-signed binaries", () => { + const output = ` +Executable=/opt/homebrew/bin/bun +Identifier=bun +Authority=Developer ID Application: Oven SH (7FRXF46ZSN) +TeamIdentifier=7FRXF46ZSN +`; + + expect(isReleaseSignedMacosBinary("1.2.3", output)).toBe(false); + }); + + test("returns false when the identifier does not match Clerk", () => { + const output = ` +Executable=/tmp/not-clerk +Identifier=not-clerk +TeamIdentifier=L8SD6SB282 +`; + + expect(isReleaseSignedMacosBinary("1.2.3", output)).toBe(false); + }); +}); diff --git a/packages/cli-core/src/lib/credential-store.ts b/packages/cli-core/src/lib/credential-store.ts index ebf3b9fd..576ceade 100644 --- a/packages/cli-core/src/lib/credential-store.ts +++ b/packages/cli-core/src/lib/credential-store.ts @@ -12,9 +12,13 @@ import { mkdir, chmod, unlink } from "node:fs/promises"; import { CREDENTIALS_FILE } from "./constants.ts"; import { getCurrentEnvName } from "./environment.ts"; import { log } from "./log.ts"; +import { resolveCliVersion } from "./version.ts"; export const KEYCHAIN_SERVICE = "clerk-cli"; +export const LOCAL_DEV_KEYCHAIN_SERVICE = "clerk-cli-dev"; export const KEYCHAIN_ACCOUNT = "oauth-access-token"; +const RELEASE_MACOS_TEAM_ID = "L8SD6SB282"; +const RELEASE_MACOS_IDENTIFIER = "clerk"; function keychainAccount(): string { const envName = getCurrentEnvName(); @@ -29,6 +33,7 @@ function credentialsFile(): string { } let keyringModule: typeof import("@napi-rs/keyring") | null | undefined; +let keychainServicePromise: Promise | undefined; async function getKeyring(): Promise { if (keyringModule !== undefined) return keyringModule; @@ -41,15 +46,56 @@ async function getKeyring(): Promise { } } +export function isReleaseSignedMacosBinary( + cliVersion: string | undefined, + codesignOutput: string, +): boolean { + if (!cliVersion) return false; + return ( + codesignOutput.includes(`TeamIdentifier=${RELEASE_MACOS_TEAM_ID}`) && + codesignOutput.includes(`Identifier=${RELEASE_MACOS_IDENTIFIER}`) + ); +} + +async function resolveKeychainService(): Promise { + if (process.platform !== "darwin") return KEYCHAIN_SERVICE; + if (keychainServicePromise) return keychainServicePromise; + + keychainServicePromise = (async () => { + const cliVersion = resolveCliVersion(); + if (!cliVersion) { + log.debug( + `credentials: using local macOS keychain namespace (service=${LOCAL_DEV_KEYCHAIN_SERVICE}, reason=unversioned-cli)`, + ); + return LOCAL_DEV_KEYCHAIN_SERVICE; + } + + const proc = Bun.spawnSync(["codesign", "-dvvv", process.execPath], { + stdio: ["ignore", "pipe", "pipe"], + }); + const codesignOutput = `${proc.stdout.toString()}${proc.stderr.toString()}`; + + if (proc.exitCode === 0 && isReleaseSignedMacosBinary(cliVersion, codesignOutput)) { + return KEYCHAIN_SERVICE; + } + + log.debug( + `credentials: using local macOS keychain namespace (service=${LOCAL_DEV_KEYCHAIN_SERVICE}, execPath=${process.execPath})`, + ); + return LOCAL_DEV_KEYCHAIN_SERVICE; + })(); + + return keychainServicePromise; +} + async function keyringStore(token: string): Promise { const mod = await getKeyring(); if (!mod) return false; + const service = await resolveKeychainService(); const account = keychainAccount(); - log.debug( - `credentials: storing token in keyring (service=${KEYCHAIN_SERVICE}, account=${account})`, - ); + log.debug(`credentials: storing token in keyring (service=${service}, account=${account})`); try { - const entry = new mod.Entry(KEYCHAIN_SERVICE, account); + const entry = new mod.Entry(service, account); entry.setPassword(token); return true; } catch { @@ -64,10 +110,11 @@ async function keyringGet(): Promise { log.debug("credentials: keyring not available"); return null; } + const service = await resolveKeychainService(); const account = keychainAccount(); - log.debug(`credentials: checking keyring (service=${KEYCHAIN_SERVICE}, account=${account})`); + log.debug(`credentials: checking keyring (service=${service}, account=${account})`); try { - const entry = new mod.Entry(KEYCHAIN_SERVICE, account); + const entry = new mod.Entry(service, account); const token = entry.getPassword(); log.debug(`credentials: ${token ? "found token in keyring" : "no token in keyring"}`); return token; @@ -80,12 +127,11 @@ async function keyringGet(): Promise { async function keyringDelete(): Promise { const mod = await getKeyring(); if (!mod) return false; + const service = await resolveKeychainService(); const account = keychainAccount(); - log.debug( - `credentials: deleting token from keyring (service=${KEYCHAIN_SERVICE}, account=${account})`, - ); + log.debug(`credentials: deleting token from keyring (service=${service}, account=${account})`); try { - const entry = new mod.Entry(KEYCHAIN_SERVICE, account); + const entry = new mod.Entry(service, account); entry.deletePassword(); return true; } catch { diff --git a/packages/cli-core/src/test/integration/lib/harness.ts b/packages/cli-core/src/test/integration/lib/harness.ts index 4c79b23e..6397e72d 100644 --- a/packages/cli-core/src/test/integration/lib/harness.ts +++ b/packages/cli-core/src/test/integration/lib/harness.ts @@ -53,7 +53,9 @@ mock.module( }, _setTokenOverride: () => {}, KEYCHAIN_SERVICE: "clerk-cli", + LOCAL_DEV_KEYCHAIN_SERVICE: "clerk-cli-dev", KEYCHAIN_ACCOUNT: "oauth-access-token", + isReleaseSignedMacosBinary: () => true, }) satisfies typeof import("../../../lib/credential-store.ts"), );