diff --git a/packages/opencode/src/auth/index.ts b/packages/opencode/src/auth/index.ts index a0914343810..0d616115eee 100644 --- a/packages/opencode/src/auth/index.ts +++ b/packages/opencode/src/auth/index.ts @@ -1,7 +1,6 @@ -import path from "path" import { Global } from "../global" -import fs from "fs/promises" import { z } from "zod" +import { secrets } from "bun" export namespace Auth { export const Oauth = z @@ -31,33 +30,28 @@ export namespace Auth { export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" }) export type Info = z.infer - const filepath = path.join(Global.Path.data, "auth.json") + const keychainName = "auth" - export async function get(providerID: string) { - const file = Bun.file(filepath) - return file - .json() - .catch(() => ({})) - .then((x) => x[providerID] as Info | undefined) + export async function get(providerID: string): Promise { + const data = await all() + return data[providerID] as Info | undefined } export async function all(): Promise> { - const file = Bun.file(filepath) - return file.json().catch(() => ({})) + const data = await secrets.get({ service: Global.keychainService, name: keychainName }) + return JSON.parse(data ?? "{}") } export async function set(key: string, info: Info) { - const file = Bun.file(filepath) + Info.parse(info) const data = await all() - await Bun.write(file, JSON.stringify({ ...data, [key]: info }, null, 2)) - await fs.chmod(file.name!, 0o600) + data[key] = info + await secrets.set({ service: Global.keychainService, name: keychainName, value: JSON.stringify(data) }) } export async function remove(key: string) { - const file = Bun.file(filepath) const data = await all() delete data[key] - await Bun.write(file, JSON.stringify(data, null, 2)) - await fs.chmod(file.name!, 0o600) + await secrets.set({ service: Global.keychainService, name: keychainName, value: JSON.stringify(data) }) } } diff --git a/packages/opencode/src/global/index.ts b/packages/opencode/src/global/index.ts index 4ebea7c66d4..743df324929 100644 --- a/packages/opencode/src/global/index.ts +++ b/packages/opencode/src/global/index.ts @@ -10,6 +10,7 @@ const config = path.join(xdgConfig!, app) const state = path.join(xdgState!, app) export namespace Global { + export const keychainService = "sst.opencode" export const Path = { data, bin: path.join(data, "bin"), diff --git a/packages/opencode/test/auth.test.ts b/packages/opencode/test/auth.test.ts new file mode 100644 index 00000000000..18f91b1f130 --- /dev/null +++ b/packages/opencode/test/auth.test.ts @@ -0,0 +1,177 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { Auth } from "../src/auth/index" +import { Keychain } from "../src/auth/keychain" +import path from "path" +import fs from "fs/promises" +import { Global } from "../src/global" + +describe("Auth", () => { + const testProvider = "test-provider" + const testApiInfo: Auth.Info = { type: "api", key: "test-key-123" } + const testOauthInfo: Auth.Info = { + type: "oauth", + refresh: "refresh-token", + access: "access-token", + expires: Date.now() + 3600000, + } + + beforeEach(async () => { + // Clean up any existing test entries + await Keychain.remove(testProvider) + await Keychain.remove("test-provider-2") + }) + + afterEach(async () => { + // Clean up after tests + await Keychain.remove(testProvider) + await Keychain.remove("test-provider-2") + }) + + test("set and get API key", async () => { + await Auth.set(testProvider, testApiInfo) + const retrieved = await Auth.get(testProvider) + expect(retrieved).toEqual(testApiInfo) + }) + + test("set and get OAuth token", async () => { + await Auth.set(testProvider, testOauthInfo) + const retrieved = await Auth.get(testProvider) + expect(retrieved).toEqual(testOauthInfo) + }) + + test("remove provider", async () => { + await Auth.set(testProvider, testApiInfo) + const before = await Auth.get(testProvider) + expect(before).toEqual(testApiInfo) + + await Auth.remove(testProvider) + const after = await Auth.get(testProvider) + expect(after).toBeUndefined() + }) + + test("all() returns all providers", async () => { + await Auth.set(testProvider, testApiInfo) + await Auth.set("test-provider-2", testOauthInfo) + + const all = await Auth.all() + expect(all[testProvider]).toEqual(testApiInfo) + expect(all["test-provider-2"]).toEqual(testOauthInfo) + }) + + test("get returns undefined for non-existent provider", async () => { + const result = await Auth.get("non-existent") + expect(result).toBeUndefined() + }) + + test("validates Info schema on set", async () => { + const invalidInfo = { type: "invalid", data: "test" } as any + expect(Auth.set(testProvider, invalidInfo)).rejects.toThrow() + }) +}) + +describe("Auth Migration", () => { + const filepath = path.join(Global.Path.data, "auth.json") + const testProvider = "migration-test" + const testInfo: Auth.Info = { type: "api", key: "migration-key" } + + beforeEach(async () => { + // Clean up keychain + await Keychain.remove(testProvider) + }) + + afterEach(async () => { + // Clean up + await Keychain.remove(testProvider) + try { + await fs.unlink(filepath) + } catch {} + }) + + test("migrates from file to keychain on get", async () => { + // Write directly to file + await Bun.write(filepath, JSON.stringify({ [testProvider]: testInfo })) + await fs.chmod(filepath, 0o600) + + // First get should migrate + const retrieved = await Auth.get(testProvider) + expect(retrieved).toEqual(testInfo) + + // Check it's in keychain + const fromKeychain = await Keychain.get(testProvider) + expect(fromKeychain).toBeTruthy() + const parsed = JSON.parse(fromKeychain!) + expect(parsed).toEqual(testInfo) + + // Check it's removed from file + const fileContent = await Bun.file(filepath).json().catch(() => ({})) + expect(fileContent[testProvider]).toBeUndefined() + }) + + test("migrates all entries from file on all()", async () => { + const provider2 = "migration-test-2" + const info2: Auth.Info = { type: "api", key: "key2" } + + // Write multiple entries to file + await Bun.write( + filepath, + JSON.stringify({ + [testProvider]: testInfo, + [provider2]: info2, + }), + ) + await fs.chmod(filepath, 0o600) + + // Call all() should migrate everything + const all = await Auth.all() + expect(all[testProvider]).toEqual(testInfo) + expect(all[provider2]).toEqual(info2) + + // Check both are in keychain + const kc1 = await Keychain.get(testProvider) + const kc2 = await Keychain.get(provider2) + expect(kc1).toBeTruthy() + expect(kc2).toBeTruthy() + + // Check file is empty + const fileContent = await Bun.file(filepath).json().catch(() => ({})) + expect(Object.keys(fileContent).length).toBe(0) + + // Clean up + await Keychain.remove(provider2) + }) + + test("prefers keychain over file if both exist", async () => { + const fileInfo: Auth.Info = { type: "api", key: "old-key" } + const keychainInfo: Auth.Info = { type: "api", key: "new-key" } + + // Write to file + await Bun.write(filepath, JSON.stringify({ [testProvider]: fileInfo })) + await fs.chmod(filepath, 0o600) + + // Write different value to keychain + await Keychain.set(testProvider, JSON.stringify(keychainInfo)) + + // Should get keychain value + const retrieved = await Auth.get(testProvider) + expect(retrieved).toEqual(keychainInfo) + }) + + test("handles invalid JSON in keychain gracefully", async () => { + await Keychain.set(testProvider, "not-json{") + const result = await Auth.get(testProvider) + expect(result).toBeUndefined() + }) + + test("handles invalid schema in file gracefully during migration", async () => { + const invalidData = { type: "unknown", data: "test" } + await Bun.write(filepath, JSON.stringify({ [testProvider]: invalidData })) + await fs.chmod(filepath, 0o600) + + const result = await Auth.get(testProvider) + expect(result).toBeUndefined() + + // Invalid entry should not be migrated + const fromKeychain = await Keychain.get(testProvider) + expect(fromKeychain).toBeNull() + }) +})