Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clerk-cli-macos-keychain-password-prompt-issue.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"clerk": patch
---

Prevent local unsigned macOS builds from sharing the release keychain entry.
46 changes: 46 additions & 0 deletions packages/cli-core/src/lib/credential-store-signing.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
66 changes: 56 additions & 10 deletions packages/cli-core/src/lib/credential-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -29,6 +33,7 @@ function credentialsFile(): string {
}

let keyringModule: typeof import("@napi-rs/keyring") | null | undefined;
let keychainServicePromise: Promise<string> | undefined;

async function getKeyring(): Promise<typeof import("@napi-rs/keyring") | null> {
if (keyringModule !== undefined) return keyringModule;
Expand All @@ -41,15 +46,56 @@ async function getKeyring(): Promise<typeof import("@napi-rs/keyring") | null> {
}
}

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<string> {
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<boolean> {
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 {
Expand All @@ -64,10 +110,11 @@ async function keyringGet(): Promise<string | null> {
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;
Expand All @@ -80,12 +127,11 @@ async function keyringGet(): Promise<string | null> {
async function keyringDelete(): Promise<boolean> {
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 {
Expand Down
2 changes: 2 additions & 0 deletions packages/cli-core/src/test/integration/lib/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
);

Expand Down
Loading