Skip to content

feat(auth): add API key helper command support#2918

Open
t3hk0d3 wants to merge 1 commit intotailcallhq:mainfrom
t3hk0d3:feat/api-key-helper
Open

feat(auth): add API key helper command support#2918
t3hk0d3 wants to merge 1 commit intotailcallhq:mainfrom
t3hk0d3:feat/api-key-helper

Conversation

@t3hk0d3
Copy link
Copy Markdown

@t3hk0d3 t3hk0d3 commented Apr 9, 2026

Problem

In many enterprise and security-conscious environments, API keys are not static secrets — they are ephemeral tokens generated by a secrets manager (e.g. HashiCorp Vault, AWS Secrets Manager), rotated on a schedule, or single-use. Currently, Forge only supports static API keys entered manually or read from environment variables. Users in these environments have to manually refresh keys whenever they expire, which breaks their workflow.

Solution

Add a helper command mechanism that allows users to specify a shell command whose stdout is used as the API key. The command is re-executed automatically when the key expires (or on every request if no TTL is specified).

Three ways to configure

1. Environment variable convention (zero config):

# Just append _HELPER to your provider's API key variable
export ANTHROPIC_API_KEY_HELPER="vault read -field=token secret/ai/anthropic"

2. Provider config (forge.toml):

[[providers]]
id = "my_provider"
url = "https://api.example.com/v1/chat"
api_key_helper = "vault read -field=token secret/ai/key"
response_type = "OpenAI"

3. Interactive UI — during provider login, users now see:

Authentication type:
> Static API Key
  Helper Command (script that generates a key)

Helper command output format

Simple (refresh every request):

sk-ephemeral-key-abc123

With TTL (cache for 1 hour):

sk-ephemeral-key-abc123
---
TTL: 3600

With absolute expiry:

sk-ephemeral-key-abc123
---
Expires: 1750000000

Architecture

Domain layer — why ApiKeyProvider wraps AuthDetails::ApiKey

The simpler approach would have been adding optional generator and expires_at fields directly on AuthCredential. However, this creates technical debt: those fields would only be meaningful for the ApiKey variant, not for OAuth, GoogleAdc, or OAuthWithApiKey. Optional fields that apply to one variant but sit on the parent struct are a code smell — it's exactly the pattern enums are supposed to eliminate. Future developers would wonder why generator exists on OAuth credentials.

Instead, AuthDetails::ApiKey now wraps an ApiKeyProvider enum that owns all key-source concerns — the same way OAuth owns OAuthTokens and OAuthConfig. This keeps the data model honest: StaticKey is a bare key, HelperCommand carries the command + runtime state. The needs_refresh() logic lives on ApiKeyProvider itself, not as a special case in AuthCredential.

The tradeoff is ~35 mechanical match-site updates (AuthDetails::ApiKey(key)AuthDetails::ApiKey(provider) then provider.api_key()), but factory methods (AuthDetails::static_api_key() / AuthDetails::api_key_from_helper()) keep construction sites clean.

ApiKeyProvider enum (new, in credentials.rs):

pub enum ApiKeyProvider {
    StaticKey(ApiKey),           // Existing behavior
    HelperCommand {
        command: String,         // Shell command to execute
        last_key: ApiKey,        // Runtime: last obtained key (not persisted)
        expires_at: Option<DateTime<Utc>>,  // Runtime: TTL/expiry (not persisted)
    },
}
  • AuthDetails::ApiKey now wraps ApiKeyProvider instead of bare ApiKey
  • Factory methods: AuthDetails::static_api_key() and AuthDetails::api_key_from_helper()
  • #[serde(untagged)] ensures backward compatibility — old "sk-123" format still deserializes as StaticKey
  • Only the command field is persisted to credentials.json; last_key and expires_at are #[serde(skip)] and re-obtained at runtime by executing the command

Infra layer

api_key_helper module (new, in forge_infra/src/auth/):

  • execute(&ApiKeyProvider) — async, runs sh -c <command> via tokio::process::Command
  • Configurable timeout via FORGE_API_KEY_HELPER_TIMEOUT env var (default 30s)
  • kill_on_drop(true) ensures child process is cleaned up
  • CRLF normalization for cross-platform compatibility
  • parse_output() handles key-only, TTL, and Expires formats

Refresh flow

  1. On startup: create_provider() loads credential from file → detects HelperCommand with empty last_key → calls api_key_helper::execute() → populates key
  2. Before requests: refresh_provider_credential() checks needs_refresh() → if expired or no TTL, re-executes the command
  3. Migration: migrate_env_to_file() detects {API_KEY_VAR}_HELPER env vars and creates HelperCommand credentials

Credential persistence

[
  {
    "id": "xai",
    "auth_details": {
      "api_key": {
        "command": "vault read -field=token secret/ai/xai"
      }
    }
  }
]

Only the command is stored. The key is always obtained fresh by executing the command on load.

Files changed (25 files, +799 -95)

Layer File Change
Domain credentials.rs ApiKeyProvider enum, needs_refresh() delegation, serde untagged
Domain auth_context.rs helper_command: Option<String> on ApiKeyResponse, api_key_with_helper() factory
Domain new_types.rs Default derive on ApiKey
Domain provider.rs, node.rs Match site updates for ApiKeyProvider
Config config.rs api_key_helper: Option<String> on ProviderEntry
Infra api_key_helper.rs New — async execute + parse + timeout
Infra strategy.rs ApiKeyStrategy::refresh() and complete() handle HelperCommand
Repo provider_repo.rs create_credential_from_env() detects helper command (config or env var), refresh on credential load
Services provider_auth.rs ApiKey added to refresh match arm
UI ui.rs Auth type selection ("Static API Key" / "Helper Command") in handle_api_key_input()
Various 12 files Mechanical: AuthDetails::ApiKey(key)ApiKey(provider) + provider.api_key()

Test plan

  • ApiKeyProvider::StaticKeyapi_key() returns key, serde round-trip, serializes as bare string
  • ApiKeyProvider::HelperCommandapi_key() returns last_key, serializes only command, deserializes with empty last_key
  • needs_refresh() — helper without TTL → true, with future TTL → false, with past TTL → true, static → false
  • parse_output() — key only, key + TTL, key + Expires, CRLF, empty output, unknown metadata
  • execute() — static no-op, helper returns key, failing command returns error
  • ApiKeyStrategy::refresh() — HelperCommand re-executes, StaticKey unchanged
  • Backward compat — legacy {"api_key": "sk-123"} JSON deserializes as StaticKey
  • Config round-trip — api_key_helper serializes/deserializes in TOML
  • Full cargo insta test --accept — all existing tests pass
  • Manual smoke test — provider login → "Helper Command" → command validated → provider configured → chat works

Closes #2888

🤖 Generated with Claude Code

@github-actions github-actions bot added the type: feature Brand new functionality, features, pages, workflows, endpoints, etc. label Apr 9, 2026
@t3hk0d3 t3hk0d3 force-pushed the feat/api-key-helper branch from 7091bca to 589066a Compare April 9, 2026 23:23
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Apr 9, 2026

CLA assistant check
All committers have signed the CLA.

@t3hk0d3 t3hk0d3 force-pushed the feat/api-key-helper branch 5 times, most recently from 001b5fe to 0519d10 Compare April 10, 2026 00:08
Allow users to specify a shell command that generates API keys
dynamically, for environments where keys are ephemeral, one-use, or
rotated periodically.

- Add `ApiKeyProvider` enum (`StaticKey` / `HelperCommand`) to model
  both static and command-based API key sources
- Helper commands are configured via env var (`{API_KEY_VAR}_HELPER`
  convention), `api_key_helper_var` in provider config, or interactively
  through the provider login UI
- Commands are executed asynchronously with configurable timeout
  (`FORGE_API_KEY_HELPER_TIMEOUT`, default 30s) and `kill_on_drop`
- Output format supports optional TTL: `<key>\n---\nTTL: <seconds>` or
  `Expires: <unix_timestamp>`
- Only the command is persisted to credentials file; the key is always
  obtained fresh by executing the command on load
- Backward-compatible serde: old `"sk-123"` format still deserializes
  correctly via `#[serde(untagged)]`

Co-Authored-By: Claude Code <noreply@anthropic.com>
@t3hk0d3 t3hk0d3 force-pushed the feat/api-key-helper branch from 0519d10 to 26419d7 Compare April 10, 2026 08:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type: feature Brand new functionality, features, pages, workflows, endpoints, etc.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Add support for API key helpers

2 participants