feat: add dynamic API key support via scripts

closes #1302

Signed-off-by: Yordis Prieto <yordis.prieto@gmail.com>
This commit is contained in:
Yordis Prieto 2025-08-17 15:31:53 -04:00
parent de1764841c
commit d78e84d618
No known key found for this signature in database
6 changed files with 463 additions and 4 deletions

View file

@ -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"),
@ -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
@ -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
}
}
}

View file

@ -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"],
@ -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"),

View file

@ -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"),
@ -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())

View file

@ -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() {
@ -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
@ -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)) {
@ -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)
}

View file

@ -0,0 +1,118 @@
import "zod-openapi/extend"
import { test, expect, beforeEach, afterEach } from "bun:test"
import { Auth } from "../../src/auth"
import { App } from "../../src/app/app"
import fs from "fs/promises"
import path from "path"
import { Global } from "../../src/global"
import { z } from "zod"
const testDir = path.join(__dirname, "temp")
let originalDataPath: string
beforeEach(async () => {
await fs.mkdir(testDir, { recursive: true })
// Mock Global.Path.data to isolate auth storage for testing
originalDataPath = Global.Path.data
;(Global.Path as any).data = testDir
})
afterEach(async () => {
// Restore original global path
;(Global.Path as any).data = originalDataPath
await fs.rm(testDir, { recursive: true, force: true })
})
test("helper auth executes command and caches result", async () => {
await App.provide({ cwd: testDir }, async () => {
const scriptPath = path.join(testDir, "get-key.sh")
await fs.writeFile(scriptPath, '#!/bin/bash\necho "test-api-key-123"', { mode: 0o755 })
const helperConfig: z.infer<typeof Auth.Helper> = {
type: "helper",
command: ["bash", scriptPath],
refreshInterval: 3600,
timeout: 5000,
}
const apiKey = await Auth.executeHelper("test-provider", helperConfig)
expect(apiKey).toBe("test-api-key-123")
const stored = await Auth.get("test-provider")
expect(stored?.type).toBe("helper")
if (stored?.type === "helper") {
expect(stored.cachedKey).toBe("test-api-key-123")
expect(stored.lastFetched).toBeGreaterThan(Date.now() - 1000)
}
})
})
test("helper auth uses cached result within refresh interval", async () => {
await App.provide({ cwd: testDir }, async () => {
const helperConfig: z.infer<typeof Auth.Helper> = {
type: "helper",
command: ["echo", "should-not-be-called"],
refreshInterval: 3600,
timeout: 5000,
cachedKey: "cached-api-key",
lastFetched: Date.now() - 1000, // 1 second ago, well within refresh interval
}
const apiKey = await Auth.executeHelper("cached-test-provider", helperConfig)
expect(apiKey).toBe("cached-api-key")
})
})
test("helper auth re-executes command when cache expires", async () => {
await App.provide({ cwd: testDir }, async () => {
const scriptPath = path.join(testDir, "fresh-key.sh")
await fs.writeFile(scriptPath, '#!/bin/bash\necho "fresh-api-key"', { mode: 0o755 })
const helperConfig: z.infer<typeof Auth.Helper> = {
type: "helper",
command: ["bash", scriptPath],
refreshInterval: 1,
timeout: 5000,
cachedKey: "old-api-key",
lastFetched: Date.now() - 2000, // 2 seconds ago, past refresh interval
}
const apiKey = await Auth.executeHelper("expired-test-provider", helperConfig)
expect(apiKey).toBe("fresh-api-key")
})
})
test("helper auth handles command failure gracefully", async () => {
await App.provide({ cwd: testDir }, async () => {
const helperConfig: z.infer<typeof Auth.Helper> = {
type: "helper",
command: ["false"],
refreshInterval: 3600,
timeout: 5000,
}
const apiKey = await Auth.executeHelper("test-provider", helperConfig)
expect(apiKey).toBeUndefined()
})
})
test("helper auth handles command timeout", async () => {
await App.provide({ cwd: testDir }, async () => {
const scriptPath = path.join(testDir, "slow-script.sh")
await fs.writeFile(scriptPath, '#!/bin/bash\nsleep 2\necho "too-slow"', { mode: 0o755 })
const helperConfig: z.infer<typeof Auth.Helper> = {
type: "helper",
command: ["bash", scriptPath],
refreshInterval: 3600,
timeout: 500,
}
const apiKey = await Auth.executeHelper("timeout-test-provider", helperConfig)
expect(apiKey).toBeUndefined()
})
}, 5000)

View file

@ -339,3 +339,95 @@ These are useful for:
- Keeping sensitive data like API keys in separate files.
- Including large instruction files without cluttering your config.
- Sharing common configuration snippets across multiple config files.
## Providers
You can configure providers and their models in the `provider` section:
```json
{
"provider": {
"anthropic": {
"options": {
"apiKey": "your-api-key",
"baseURL": "https://api.anthropic.com"
},
"models": {
"claude-3-5-sonnet-20241022": {
"name": "Claude 3.5 Sonnet (Custom)"
}
}
}
}
}
```
### Dynamic API Keys with Helper Scripts
You can configure dynamic API keys that are generated by external scripts by using an object configuration for the `apiKey` field. This is useful for integrating with systems that use rotating tokens or require authentication workflows:
```json
{
"provider": {
"litellm": {
"options": {
"baseURL": "https://your-litellm-proxy.com/v1",
"apiKey": {
"command": ["./scripts/get-token.sh"],
"refreshInterval": 3600,
"timeout": 5000
}
},
"models": {
"gpt-4": {}
}
}
}
}
```
The `apiKey` field supports two formats:
**Static API Key:**
```json
{
"apiKey": "your-static-api-key-here"
}
```
**Dynamic API Key (Helper Script):**
```json
{
"apiKey": {
"command": ["./scripts/get-token.sh"],
"refreshInterval": 3600,
"timeout": 5000
}
}
```
The helper configuration supports:
- **`command`** (required): Array of command and arguments to execute. The script should output the API key to stdout.
- **`refreshInterval`** (optional): How often to refresh the API key in seconds. Default: 3600 (1 hour).
- **`timeout`** (optional): Command timeout in milliseconds. Default: 5000ms.
Example helper script (`get-token.sh`):
```bash
#!/bin/bash
# Example script that fetches a token from an authentication service
curl -s -X POST https://auth.example.com/token \
-H "Content-Type: application/json" \
-d '{"client_id":"'$CLIENT_ID'","client_secret":"'$CLIENT_SECRET'"}' \
| jq -r '.access_token'
```
You can also configure helper authentication via the CLI:
```bash
opencode auth login
# Select your provider, then choose "Dynamic API Key (Helper Script)"
```