feat(auth): add API key helper command support#2918
Open
t3hk0d3 wants to merge 1 commit intotailcallhq:mainfrom
Open
feat(auth): add API key helper command support#2918t3hk0d3 wants to merge 1 commit intotailcallhq:mainfrom
t3hk0d3 wants to merge 1 commit intotailcallhq:mainfrom
Conversation
7091bca to
589066a
Compare
001b5fe to
0519d10
Compare
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>
0519d10 to
26419d7
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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):
2. Provider config (
forge.toml):3. Interactive UI — during
provider login, users now see:Helper command output format
Simple (refresh every request):
With TTL (cache for 1 hour):
With absolute expiry:
Architecture
Domain layer — why
ApiKeyProviderwrapsAuthDetails::ApiKeyThe simpler approach would have been adding optional
generatorandexpires_atfields directly onAuthCredential. However, this creates technical debt: those fields would only be meaningful for theApiKeyvariant, not forOAuth,GoogleAdc, orOAuthWithApiKey. 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::ApiKeynow wraps anApiKeyProviderenum that owns all key-source concerns — the same wayOAuthownsOAuthTokensandOAuthConfig. This keeps the data model honest:StaticKeyis a bare key,HelperCommandcarries the command + runtime state. Theneeds_refresh()logic lives onApiKeyProvideritself, not as a special case inAuthCredential.The tradeoff is ~35 mechanical match-site updates (
AuthDetails::ApiKey(key)→AuthDetails::ApiKey(provider)thenprovider.api_key()), but factory methods (AuthDetails::static_api_key()/AuthDetails::api_key_from_helper()) keep construction sites clean.ApiKeyProviderenum (new, incredentials.rs):AuthDetails::ApiKeynow wrapsApiKeyProviderinstead of bareApiKeyAuthDetails::static_api_key()andAuthDetails::api_key_from_helper()#[serde(untagged)]ensures backward compatibility — old"sk-123"format still deserializes asStaticKeycommandfield is persisted tocredentials.json;last_keyandexpires_atare#[serde(skip)]and re-obtained at runtime by executing the commandInfra layer
api_key_helpermodule (new, inforge_infra/src/auth/):execute(&ApiKeyProvider)— async, runssh -c <command>viatokio::process::CommandFORGE_API_KEY_HELPER_TIMEOUTenv var (default 30s)kill_on_drop(true)ensures child process is cleaned upparse_output()handles key-only, TTL, and Expires formatsRefresh flow
create_provider()loads credential from file → detectsHelperCommandwith emptylast_key→ callsapi_key_helper::execute()→ populates keyrefresh_provider_credential()checksneeds_refresh()→ if expired or no TTL, re-executes the commandmigrate_env_to_file()detects{API_KEY_VAR}_HELPERenv vars and createsHelperCommandcredentialsCredential persistence
[ { "id": "xai", "auth_details": { "api_key": { "command": "vault read -field=token secret/ai/xai" } } } ]Only the
commandis stored. The key is always obtained fresh by executing the command on load.Files changed (25 files, +799 -95)
credentials.rsApiKeyProviderenum,needs_refresh()delegation, serde untaggedauth_context.rshelper_command: Option<String>onApiKeyResponse,api_key_with_helper()factorynew_types.rsDefaultderive onApiKeyprovider.rs,node.rsApiKeyProviderconfig.rsapi_key_helper: Option<String>onProviderEntryapi_key_helper.rsstrategy.rsApiKeyStrategy::refresh()andcomplete()handleHelperCommandprovider_repo.rscreate_credential_from_env()detects helper command (config or env var), refresh on credential loadprovider_auth.rsApiKeyadded to refresh match armui.rshandle_api_key_input()AuthDetails::ApiKey(key)→ApiKey(provider)+provider.api_key()Test plan
ApiKeyProvider::StaticKey—api_key()returns key, serde round-trip, serializes as bare stringApiKeyProvider::HelperCommand—api_key()returnslast_key, serializes onlycommand, deserializes with emptylast_keyneeds_refresh()— helper without TTL → true, with future TTL → false, with past TTL → true, static → falseparse_output()— key only, key + TTL, key + Expires, CRLF, empty output, unknown metadataexecute()— static no-op, helper returns key, failing command returns errorApiKeyStrategy::refresh()— HelperCommand re-executes, StaticKey unchanged{"api_key": "sk-123"}JSON deserializes asStaticKeyapi_key_helperserializes/deserializes in TOMLcargo insta test --accept— all existing tests passprovider login→ "Helper Command" → command validated → provider configured → chat worksCloses #2888
🤖 Generated with Claude Code