Skip to content
Open
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
87 changes: 86 additions & 1 deletion packages/opencode/src/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,12 @@ import path from "path"
import { Global } from "../global"
import fs from "fs/promises"
import { z } from "zod"
import { Log } from "../util/log"
import { App } from "../app/app"

export namespace Auth {
const log = Log.create({ service: "auth" })

export const Oauth = z
.object({
type: z.literal("oauth"),
Expand All @@ -28,11 +32,22 @@ export namespace Auth {
})
.openapi({ ref: "WellKnownAuth" })

export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown]).openapi({ ref: "Auth" })
export const Helper = z.object({
type: z.literal("helper"),
command: z.array(z.string()),
refreshInterval: z.number().default(3600), // 1 hour default
timeout: z.number().default(5000), // 5 seconds default
lastFetched: z.number().optional(),
cachedKey: z.string().optional(),
})

export const Info = z.discriminatedUnion("type", [Oauth, Api, WellKnown, Helper]).openapi({ ref: "Auth" })
export type Info = z.infer<typeof Info>

const filepath = path.join(Global.Path.data, "auth.json")

const helperCache = new Map<string, { key: string; expires: number }>()

export async function get(providerID: string) {
const file = Bun.file(filepath)
return file
Expand Down Expand Up @@ -60,4 +75,74 @@ export namespace Auth {
await Bun.write(file, JSON.stringify(data, null, 2))
await fs.chmod(file.name!, 0o600)
}

export async function executeHelper(providerID: string, helper: z.infer<typeof Helper>): Promise<string | undefined> {
const now = Date.now()
const cacheKey = `${providerID}-${JSON.stringify(helper.command)}`

const cached = helperCache.get(cacheKey)
if (cached && cached.expires > now) {
log.debug("using cached helper result", { providerID })
return cached.key
}

if (helper.cachedKey && helper.lastFetched && now - helper.lastFetched < helper.refreshInterval * 1000) {
log.debug("using stored helper result", { providerID })
helperCache.set(cacheKey, {
key: helper.cachedKey,
expires: helper.lastFetched + helper.refreshInterval * 1000,
})
return helper.cachedKey
}

try {
log.info("executing helper command", { providerID, command: helper.command })

const process = Bun.spawn({
cmd: helper.command,
cwd: App.info().path.cwd,
timeout: helper.timeout,
stdout: "pipe",
stderr: "pipe",
})

await process.exited

if (process.exitCode !== 0) {
const stderr = await new Response(process.stderr).text()
log.error("helper command failed", { providerID, exitCode: process.exitCode, stderr })
return undefined
}

const stdout = await new Response(process.stdout).text()
const apiKey = stdout.trim()

if (!apiKey) {
log.error("helper command returned empty result", { providerID })
return undefined
}

const updatedHelper: z.infer<typeof Helper> = {
...helper,
cachedKey: apiKey,
lastFetched: now,
}

await set(providerID, updatedHelper)

helperCache.set(cacheKey, {
key: apiKey,
expires: now + helper.refreshInterval * 1000,
})

log.info("helper command executed successfully", { providerID })
return apiKey
} catch (error) {
log.error("helper command execution failed", {
providerID,
error: error instanceof Error ? error.message : String(error),
})
return undefined
}
}
}
96 changes: 96 additions & 0 deletions packages/opencode/src/cli/cmd/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,42 @@ export const AuthCommand = cmd({
async handler() {},
})

/**
* Handle command parsing for helper auth allowing for quoted arguments, and spaces in arguments, example:
* `op read "op://<some-vault>/OpenAI API Key/credential"` will be parsed as `["op", "read", "op://<some-vault>/OpenAI API Key/credential"]`
*/
function parseCommand(commandStr: string): string[] {
const args: string[] = []
let current = ""
let inQuotes = false
let quoteChar = ""

for (let i = 0; i < commandStr.length; i++) {
const char = commandStr[i]

if (!inQuotes && (char === '"' || char === "'")) {
inQuotes = true
quoteChar = char
} else if (inQuotes && char === quoteChar) {
inQuotes = false
quoteChar = ""
} else if (!inQuotes && /\s/.test(char)) {
if (current) {
args.push(current)
current = ""
}
} else {
current += char
}
}

if (current) {
args.push(current)
}

return args
}

export const AuthListCommand = cmd({
command: "list",
aliases: ["ls"],
Expand Down Expand Up @@ -236,6 +272,66 @@ export const AuthLoginCommand = cmd({
prompts.log.info("You can create an api key in the dashboard")
}

const authMethod = await prompts.select({
message: "How would you like to authenticate?",
options: [
{
value: "api",
label: "API Key",
hint: "Enter a static API key",
},
{
value: "helper",
label: "Dynamic API Key (Helper Script)",
hint: "Use a script to generate API keys dynamically",
},
],
})
if (prompts.isCancel(authMethod)) throw new UI.CancelledError()

if (authMethod === "helper") {
prompts.log.info("Configure a script that outputs an API key to stdout")

const command = await prompts.text({
message: "Enter the command to execute (space-separated)",
placeholder: "./get-token.sh or python get-token.py",
validate: (x) => (x && x.trim().length > 0 ? undefined : "Required"),
})
if (prompts.isCancel(command)) throw new UI.CancelledError()

const refreshInterval = await prompts.text({
message: "Refresh interval in seconds",
initialValue: "3600",
validate: (x) => {
if (!x) return "Required"
const num = parseInt(x)
return num > 0 ? undefined : "Must be a positive number"
},
})
if (prompts.isCancel(refreshInterval)) throw new UI.CancelledError()

const timeout = await prompts.text({
message: "Command timeout in milliseconds",
initialValue: "5000",
validate: (x) => {
if (!x) return "Required"
const num = parseInt(x)
return num >= 100 && num <= 30000 ? undefined : "Must be between 100 and 30000"
},
})
if (prompts.isCancel(timeout)) throw new UI.CancelledError()

await Auth.set(provider, {
type: "helper",
command: parseCommand(command as string),
refreshInterval: parseInt(refreshInterval as string),
timeout: parseInt(timeout as string),
})

prompts.outro("Helper authentication configured")
return
}

const key = await prompts.password({
message: "Enter your API key",
validate: (x) => (x && x.length > 0 ? undefined : "Required"),
Expand Down
27 changes: 26 additions & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,28 @@ export namespace Config {
})
export type Layout = z.infer<typeof Layout>

export const ApiKeyHelper = z
.object({
command: z
.array(z.string())
.describe("Command to execute to retrieve the API key. Should output the key to stdout."),
refreshInterval: z
.number()
.min(1)
.default(3600)
.describe("How often to refresh the API key in seconds (default: 3600 = 1 hour)"),
timeout: z
.number()
.min(100)
.max(30000)
.default(5000)
.describe("Timeout for the helper command in milliseconds (default: 5000ms)"),
})
.describe(
"Configuration for dynamic API key generation via external script. The command should output the API key to stdout.",
)
export type ApiKeyHelper = z.infer<typeof ApiKeyHelper>

export const Info = z
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
Expand Down Expand Up @@ -351,7 +373,10 @@ export namespace Config {
models: z.record(ModelsDev.Model.partial()).optional(),
options: z
.object({
apiKey: z.string().optional(),
apiKey: z
.union([z.string().describe("Static API key"), ApiKeyHelper])
.optional()
.describe("API key configuration - either a static string or helper script configuration"),
baseURL: z.string().optional(),
})
.catchall(z.any())
Expand Down
47 changes: 45 additions & 2 deletions packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export namespace Provider {
options?: Record<string, any>
}>

type Source = "env" | "config" | "custom" | "api"
type Source = "env" | "config" | "custom" | "api" | "helper"

const CUSTOM_LOADERS: Record<string, CustomLoader> = {
async anthropic() {
Expand Down Expand Up @@ -248,6 +248,14 @@ export namespace Provider {
if (provider.type === "api") {
mergeProvider(providerID, { apiKey: provider.key }, "api")
}
if (provider.type === "helper") {
const apiKey = await Auth.executeHelper(providerID, provider)
if (apiKey) {
mergeProvider(providerID, { apiKey }, "helper")
} else {
log.warn("helper auth failed, skipping provider", { providerID })
}
}
}

// load custom
Expand All @@ -272,7 +280,38 @@ export namespace Provider {

// load config
for (const [providerID, provider] of configProviders) {
mergeProvider(providerID, provider.options ?? {}, "config")
const options = { ...provider.options }

if (isApiKeyHelper(options.apiKey)) {
const helperConfig = options.apiKey
try {
const helperAuth: z.infer<typeof Auth.Helper> = {
type: "helper",
command: helperConfig.command,
refreshInterval: helperConfig.refreshInterval ?? 3600,
timeout: helperConfig.timeout ?? 5000,
}

await Auth.set(providerID, helperAuth)

const apiKey = await Auth.executeHelper(providerID, helperAuth)
if (apiKey) {
options.apiKey = apiKey
} else {
log.warn("apiKey helper failed for provider, no API key available", { providerID })
delete options.apiKey
}
} catch (error) {
log.error("failed to process apiKey helper configuration", {
providerID,
error: error instanceof Error ? error.message : String(error),
command: helperConfig.command.join(" "),
})
delete options.apiKey
}
}

mergeProvider(providerID, options, "config")
}

for (const [providerID, provider] of Object.entries(providers)) {
Expand All @@ -290,6 +329,10 @@ export namespace Provider {
}
})

function isApiKeyHelper(apiKey: any): apiKey is Config.ApiKeyHelper {
return apiKey !== null && typeof apiKey === "object" && Array.isArray(apiKey.command) && apiKey.command.length > 0
}

export async function list() {
return state().then((state) => state.providers)
}
Expand Down
Loading
Loading