This commit is contained in:
@@ -57,6 +57,7 @@ export * from "./session-utils"
|
||||
export * from "./tmux"
|
||||
export * from "./model-suggestion-retry"
|
||||
export * from "./opencode-server-auth"
|
||||
export * from "./opencode-provider-auth"
|
||||
export * from "./opencode-http-api"
|
||||
export * from "./port-utils"
|
||||
export * from "./git-worktree"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test"
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
|
||||
import { tmpdir } from "node:os"
|
||||
import * as path from "node:path"
|
||||
|
||||
import {
|
||||
_resetProviderAuthCacheForTesting,
|
||||
getProviderAuthType,
|
||||
isProviderUsingOAuth,
|
||||
} from "./opencode-provider-auth"
|
||||
|
||||
describe("opencode-provider-auth", () => {
|
||||
let tempDataDir: string
|
||||
const originalXdgDataHome = process.env.XDG_DATA_HOME
|
||||
|
||||
function writeAuthFile(contents: string): void {
|
||||
const opencodeDir = path.join(tempDataDir, "opencode")
|
||||
mkdirSync(opencodeDir, { recursive: true })
|
||||
writeFileSync(path.join(opencodeDir, "auth.json"), contents, "utf-8")
|
||||
_resetProviderAuthCacheForTesting()
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
tempDataDir = path.join(tmpdir(), `opencode-provider-auth-${Date.now()}-${Math.random().toString(36).slice(2)}`)
|
||||
mkdirSync(tempDataDir, { recursive: true })
|
||||
process.env.XDG_DATA_HOME = tempDataDir
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (originalXdgDataHome === undefined) {
|
||||
delete process.env.XDG_DATA_HOME
|
||||
} else {
|
||||
process.env.XDG_DATA_HOME = originalXdgDataHome
|
||||
}
|
||||
rmSync(tempDataDir, { recursive: true, force: true })
|
||||
_resetProviderAuthCacheForTesting()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
_resetProviderAuthCacheForTesting()
|
||||
})
|
||||
|
||||
it("#given auth.json with oauth entry #then detects OAuth for that provider", () => {
|
||||
// given auth.json where anthropic is OAuth
|
||||
writeAuthFile(JSON.stringify({
|
||||
anthropic: { type: "oauth", refresh: "r", access: "a", expires: 1 },
|
||||
opencode: { type: "api", key: "sk-x" },
|
||||
}))
|
||||
|
||||
// when isProviderUsingOAuth queries each provider
|
||||
const anthropicOauth = isProviderUsingOAuth("anthropic")
|
||||
const opencodeOauth = isProviderUsingOAuth("opencode")
|
||||
|
||||
// then only OAuth providers return true
|
||||
expect(anthropicOauth).toBe(true)
|
||||
expect(opencodeOauth).toBe(false)
|
||||
})
|
||||
|
||||
it("#given api-key auth.json entry #then returns the api auth type", () => {
|
||||
// given auth.json with an API key for anthropic
|
||||
writeAuthFile(JSON.stringify({ anthropic: { type: "api", key: "sk-ant-xxx" } }))
|
||||
|
||||
// when getProviderAuthType queries the provider
|
||||
const authType = getProviderAuthType("anthropic")
|
||||
|
||||
// then the api type is returned
|
||||
expect(authType).toBe("api")
|
||||
expect(isProviderUsingOAuth("anthropic")).toBe(false)
|
||||
})
|
||||
|
||||
it("#given missing auth.json #then returns undefined with no throw", () => {
|
||||
// given no auth.json exists (XDG_DATA_HOME points to an empty dir)
|
||||
rmSync(path.join(tempDataDir, "opencode"), { recursive: true, force: true })
|
||||
_resetProviderAuthCacheForTesting()
|
||||
|
||||
// when isProviderUsingOAuth queries a provider
|
||||
const anthropicOauth = isProviderUsingOAuth("anthropic")
|
||||
const anthropicType = getProviderAuthType("anthropic")
|
||||
|
||||
// then callers get a safe undefined/false
|
||||
expect(anthropicOauth).toBe(false)
|
||||
expect(anthropicType).toBeUndefined()
|
||||
})
|
||||
|
||||
it("#given malformed auth.json #then does not throw and returns undefined", () => {
|
||||
// given a malformed JSON auth file
|
||||
writeAuthFile("not json at all")
|
||||
|
||||
// when isProviderUsingOAuth queries a provider
|
||||
const anthropicOauth = isProviderUsingOAuth("anthropic")
|
||||
|
||||
// then detection degrades safely
|
||||
expect(anthropicOauth).toBe(false)
|
||||
})
|
||||
|
||||
it("#given unknown provider #then returns undefined", () => {
|
||||
// given auth.json without an entry for the queried provider
|
||||
writeAuthFile(JSON.stringify({ anthropic: { type: "oauth", refresh: "r", access: "a", expires: 1 } }))
|
||||
|
||||
// when querying a provider that is not present
|
||||
const openai = getProviderAuthType("openai")
|
||||
|
||||
// then undefined
|
||||
expect(openai).toBeUndefined()
|
||||
expect(isProviderUsingOAuth("openai")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { readFileSync, statSync } from "node:fs"
|
||||
import * as path from "node:path"
|
||||
|
||||
import { getDataDir } from "./data-path"
|
||||
import { log } from "./logger"
|
||||
|
||||
/**
|
||||
* Reads OpenCode's auth.json to detect the auth type used by a provider.
|
||||
*
|
||||
* OpenCode stores auth credentials at `<dataDir>/opencode/auth.json` in the
|
||||
* shape `{ [providerID]: { type: "oauth" | "api" | "wellknown", ... } }`.
|
||||
*
|
||||
* The file is read with mtime-based caching so we do not stat/parse it on
|
||||
* every chat.params invocation.
|
||||
*/
|
||||
|
||||
type AuthRecord = {
|
||||
type?: unknown
|
||||
}
|
||||
|
||||
type AuthCacheEntry = {
|
||||
mtimeMs: number
|
||||
map: Map<string, string>
|
||||
}
|
||||
|
||||
let cached: AuthCacheEntry | null = null
|
||||
|
||||
function getAuthFilePath(): string {
|
||||
return path.join(getDataDir(), "opencode", "auth.json")
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null
|
||||
}
|
||||
|
||||
function loadAuthMap(): Map<string, string> {
|
||||
const filePath = getAuthFilePath()
|
||||
|
||||
let mtimeMs: number
|
||||
try {
|
||||
mtimeMs = statSync(filePath).mtimeMs
|
||||
} catch {
|
||||
cached = null
|
||||
return new Map()
|
||||
}
|
||||
|
||||
if (cached && cached.mtimeMs === mtimeMs) {
|
||||
return cached.map
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(filePath, "utf-8")
|
||||
const parsed: unknown = JSON.parse(raw)
|
||||
const map = new Map<string, string>()
|
||||
if (isRecord(parsed)) {
|
||||
for (const [providerID, entry] of Object.entries(parsed)) {
|
||||
if (!isRecord(entry)) continue
|
||||
const type = (entry as AuthRecord).type
|
||||
if (typeof type === "string") {
|
||||
map.set(providerID, type)
|
||||
}
|
||||
}
|
||||
}
|
||||
cached = { mtimeMs, map }
|
||||
return map
|
||||
} catch (error) {
|
||||
log("[opencode-provider-auth] Failed to read auth.json", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
return new Map()
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderAuthType(providerID: string): string | undefined {
|
||||
return loadAuthMap().get(providerID)
|
||||
}
|
||||
|
||||
export function isProviderUsingOAuth(providerID: string): boolean {
|
||||
return getProviderAuthType(providerID) === "oauth"
|
||||
}
|
||||
|
||||
export function _resetProviderAuthCacheForTesting(): void {
|
||||
cached = null
|
||||
}
|
||||
Reference in New Issue
Block a user