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
123 changes: 123 additions & 0 deletions src/auth/importers.permissions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { chmod, mkdtemp, stat } from "node:fs/promises";
import os from "node:os";
import path from "node:path";

import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { readUtf8File, removeDirectory, writeUtf8File } from "../filesystem/index.js";

import { importClaudeCodeAuth, importCodexAuth, importEnvFile } from "./importers.js";
import { ensureAuthProfile, requireAuthProfile, setAuthProfileEnv } from "./profileStore.js";
import type { ResolvedAuthProfile } from "./types.js";

const sources = [
{ kind: "codex", file: "auth.json", importAuth: importCodexAuth },
{ kind: "claude-code", file: ".credentials.json", importAuth: importClaudeCodeAuth }
] as const;
const temporaryDirectories: string[] = [];
const previousSpawnfileHome = process.env.SPAWNFILE_HOME;
let previousUmask: number;

const createTempDirectory = async (): Promise<string> => {
const directory = await mkdtemp(path.join(os.tmpdir(), "spawnfile-auth-permissions-"));
temporaryDirectories.push(directory);
return directory;
};

const expectMode = async (entry: string, mode: number): Promise<void> => {
expect((await stat(entry)).mode & 0o7777, entry).toBe(mode);
};

const expectPrivateProfile = async (profile: ResolvedAuthProfile): Promise<void> => {
await expectMode(profile.authHome, 0o700);
await expectMode(path.dirname(profile.profileDirectory), 0o700);
await expectMode(profile.profileDirectory, 0o700);
await expectMode(profile.profilePath, 0o600);
for (const source of sources) {
const entry = profile.imports[source.kind];
if (!entry) continue;
await expectMode(path.dirname(entry.path), 0o700);
await expectMode(entry.path, 0o700);
await expectMode(path.join(entry.path, source.file), 0o600);
}
};

describe.skipIf(process.platform === "win32")("private auth imports under umask 022", () => {
beforeEach(async () => {
previousUmask = process.umask(0o022);
process.env.SPAWNFILE_HOME = path.join(await createTempDirectory(), "new-home");
});

afterEach(async () => {
process.umask(previousUmask);
if (previousSpawnfileHome === undefined) delete process.env.SPAWNFILE_HOME;
else process.env.SPAWNFILE_HOME = previousSpawnfileHome;
await Promise.all(temporaryDirectories.splice(0).map(removeDirectory));
});

it("creates an empty profile with private directories and metadata", async () => {
await expectPrivateProfile(await ensureAuthProfile("dev"));
});

it.each(sources)("creates private $kind credentials in a clean home", async (source) => {
const sourceDirectory = await createTempDirectory();
const sourceFile = path.join(sourceDirectory, source.file);
const content = '{"token":"first-account"}\n';
await writeUtf8File(sourceFile, content);
await chmod(sourceFile, 0o644);

const profile = await source.importAuth("dev", sourceDirectory);

await expectMode(path.join(profile.imports[source.kind]!.path, source.file), 0o600);
await expectPrivateProfile(profile);
expect(await readUtf8File(path.join(profile.imports[source.kind]!.path, source.file))).toBe(content);
expect(await readUtf8File(sourceFile)).toBe(content);
await expectMode(sourceFile, 0o644);
});

it.each(sources)("keeps a replaced $kind import private and preserves other auth", async (source) => {
const sourceDirectory = await createTempDirectory();
await setAuthProfileEnv("dev", { SERVICE_API_KEY: "existing-env" });
for (const entry of sources) {
await writeUtf8File(path.join(sourceDirectory, entry.file), `{"token":"${entry.kind}"}\n`);
await entry.importAuth("dev", sourceDirectory);
}
const before = await requireAuthProfile("dev");
const directory = before.imports[source.kind]!.path;
const file = path.join(directory, source.file);
await chmod(directory, 0o700);
await chmod(file, 0o600);
await chmod(before.profilePath, 0o600);
await writeUtf8File(path.join(sourceDirectory, source.file), '{"token":"replacement-account"}\n');

const after = await source.importAuth("dev", sourceDirectory);

await expectMode(file, 0o600);
await expectPrivateProfile(after);
expect(after).toEqual(before);
expect(await requireAuthProfile("dev")).toEqual(before);
expect(await readUtf8File(file)).toBe('{"token":"replacement-account"}\n');
const other = sources.find((entry) => entry.kind !== source.kind)!;
expect(await readUtf8File(path.join(after.imports[other.kind]!.path, other.file))).toBe(
`{"token":"${other.kind}"}\n`
);
});

it("keeps env credentials private when creating and updating legacy profiles", async () => {
const envFile = path.join(await createTempDirectory(), ".env");
await writeUtf8File(envFile, "SERVICE_API_KEY=first\n");
const initial = await importEnvFile("dev", envFile);
await expectPrivateProfile(initial);
await chmod(initial.profilePath, 0o644);
await chmod(initial.profileDirectory, 0o755);
await chmod(path.dirname(initial.profileDirectory), 0o755);
await chmod(initial.authHome, 0o755);
await writeUtf8File(envFile, "OTHER_API_KEY=second\n");

const updated = await importEnvFile("dev", envFile);

await expectPrivateProfile(updated);
expect(updated.env).toEqual({ SERVICE_API_KEY: "first", OTHER_API_KEY: "second" });
expect(await requireAuthProfile("dev")).toEqual(updated);
});
});
8 changes: 3 additions & 5 deletions src/auth/importers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,9 @@ import path from "node:path";
import os from "node:os";

import {
ensureDirectory,
fileExists,
readUtf8File,
writeUtf8File
writePrivateUtf8File
} from "../filesystem/index.js";
import { SpawnfileError } from "../shared/index.js";

Expand Down Expand Up @@ -108,7 +107,7 @@ export const importCodexAuth = async (
}

const { directory, profile } = await registerImportedAuth(profileName, "codex");
await writeUtf8File(path.join(directory, "auth.json"), await readUtf8File(authFilePath));
await writePrivateUtf8File(path.join(directory, "auth.json"), await readUtf8File(authFilePath));
return profile;
};

Expand Down Expand Up @@ -142,7 +141,6 @@ export const importClaudeCodeAuth = async (
}

const { directory, profile } = await registerImportedAuth(profileName, "claude-code");
await ensureDirectory(directory);
await writeUtf8File(path.join(directory, ".credentials.json"), credentialsContent);
await writePrivateUtf8File(path.join(directory, ".credentials.json"), credentialsContent);
return profile;
};
21 changes: 11 additions & 10 deletions src/auth/profileStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,20 @@ import path from "node:path";
import { z } from "zod";

import {
ensureDirectory,
ensurePrivateDirectory,
fileExists,
readUtf8File,
removeDirectory,
writeUtf8File
writePrivateUtf8File
} from "../filesystem/index.js";
import { SpawnfileError } from "../shared/index.js";

import {
resolveAuthHome,
resolveImportedAuthDirectory,
resolveProfileDirectory,
resolveProfilePath
resolveProfilePath,
resolveProfilesRoot
} from "./paths.js";
import type { AuthProfile, ImportedAuthKind, ResolvedAuthProfile } from "./types.js";

Expand Down Expand Up @@ -110,16 +111,15 @@ export const ensureAuthProfile = async (
return existing;
}

const profileDirectory = resolveProfileDirectory(profileName);
await ensureDirectory(profileDirectory);
await writeUtf8File(resolveProfilePath(profileName), `${JSON.stringify(createEmptyProfile(), null, 2)}\n`);
return createResolvedAuthProfile(profileName, createEmptyProfile());
return writeProfile(profileName, createEmptyProfile());
};

const writeProfile = async (profileName: string, profile: AuthProfile): Promise<ResolvedAuthProfile> => {
const profileDirectory = resolveProfileDirectory(profileName);
await ensureDirectory(profileDirectory);
await writeUtf8File(resolveProfilePath(profileName), `${JSON.stringify(profile, null, 2)}\n`);
await ensurePrivateDirectory(resolveAuthHome());
await ensurePrivateDirectory(resolveProfilesRoot());
await ensurePrivateDirectory(profileDirectory);
await writePrivateUtf8File(resolveProfilePath(profileName), `${JSON.stringify(profile, null, 2)}\n`);
return createResolvedAuthProfile(profileName, profile);
};

Expand Down Expand Up @@ -152,8 +152,9 @@ export const registerImportedAuth = async (
): Promise<{ directory: string; profile: ResolvedAuthProfile }> => {
const current = (await loadAuthProfile(profileName)) ?? (await ensureAuthProfile(profileName));
const importDirectory = resolveImportedAuthDirectory(profileName, kind);
await ensurePrivateDirectory(path.dirname(importDirectory));
await removeDirectory(importDirectory);
await ensureDirectory(importDirectory);
await ensurePrivateDirectory(importDirectory);

const nextProfile: AuthProfile = {
env: { ...current.env },
Expand Down
18 changes: 17 additions & 1 deletion src/filesystem/io.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import path from "node:path";
import { cp, mkdir, lstat, readFile, rm, stat, writeFile } from "node:fs/promises";
import { chmod, cp, mkdir, lstat, open, readFile, rm, stat, writeFile } from "node:fs/promises";

export interface CopyDirectoryOptions {
filter?: (sourcePath: string, destinationPath: string) => boolean;
Expand All @@ -21,6 +21,11 @@ export const ensureDirectory = async (directoryPath: string): Promise<void> => {
await mkdir(directoryPath, { recursive: true });
};

export const ensurePrivateDirectory = async (directoryPath: string): Promise<void> => {
await mkdir(directoryPath, { recursive: true, mode: 0o700 });
await chmod(directoryPath, 0o700);
};

export const fileExists = async (filePath: string): Promise<boolean> => {
try {
await stat(filePath);
Expand Down Expand Up @@ -83,3 +88,14 @@ export const writeUtf8File = async (
): Promise<void> => {
await writeFile(filePath, content, "utf8");
};

export const writePrivateUtf8File = async (filePath: string, content: string): Promise<void> => {
const handle = await open(filePath, "w", 0o600);
try {
// Creation mode does not change an existing file's permissions.
await handle.chmod(0o600);
await handle.writeFile(content, "utf8");
} finally {
await handle.close();
}
};
Loading