feat(auth): add API key helper command support#2918
Conversation
7091bca to
589066a
Compare
0519d10 to
26419d7
Compare
|
@amitksingh1490 Any chance to check this PR pls? This is really a blocker for me |
|
Action required: PR inactive for 5 days. |
Thats stupid. |
|
@t3hk0d3 Apologies for delay can you rebase this once. I will review it asap |
20a4dd1 to
735abca
Compare
No worries. I've rebased PR. |
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>
735abca to
03cc279
Compare
amitksingh1490
left a comment
There was a problem hiding this comment.
Comprehensive Code Review by ForgeCode Agent
Overall Assessment: GOOD PR with recommended fixes
This PR adds API key helper command support — a well-structured feature with thorough testing and correct backward compatibility. The untagged serde enum design is elegant.
Summary of Findings
| Priority | Count | Key Items |
|---|---|---|
| Must-fix | 1 | Command strings leaked in error messages/logs (security) |
| Should-fix | 2 | No confirmation for config-file commands; undocumented startup re-execution |
| Nice-to-have | 4 | ApiKey::Default concern, TTL overflow, unnecessary async for static keys, process tree kill |
See inline comments for details on each finding.
| timeout, | ||
| tokio::process::Command::new("sh") | ||
| .arg("-c") | ||
| .arg(command) |
There was a problem hiding this comment.
[SECURITY — MEDIUM] No command sanitization or validation
The command string is passed directly to sh -c with zero validation. Commands come from:
forge.tomlconfig (checked-in to repos)- Environment variables
- Interactive user input
A malicious forge.toml in a cloned repository could execute arbitrary code when a user runs forge provider login.
Recommendation: Consider adding a confirmation prompt when the command originates from a config file rather than direct user input. At minimum, log a warning when executing helper commands.
There was a problem hiding this comment.
@amitksingh1490 Shall we add trusted flag (stored in credentials.json) to repository-provided configurations? This would be a bigger change, slightly outside of scope for this PR.
Warnings on every helper execution can be really annoying IMO.
There was a problem hiding this comment.
Another approach would be to have trusted flag that is stored as part of helper configuration. If helper is provided by UI or ENV variable - we set it to trusted. Otherwise we ask user prompt on first execution, and set it to trusted for further calls.
But seems like reaching out TUI (text UI) layer from auth provider repository could be a bit challenging.
@amitksingh1490 what would you suggest here?
There was a problem hiding this comment.
May be we should ask user to once for permission to run and persist it. And add a config to allow always? I am also thinking best way to solve this.
There was a problem hiding this comment.
@amitksingh1490
I need an architectural advise. Problem is that api helper executor is deep inside forge-infra library. Text UI is running on forge-main level. We can't just call UI prompt from inside forge-infra :(
There are several options:
- We pass a "prompter" trait/callback all the thru callstack.
We define trait onforge-infra/forge-corelevel and implement it onforge-mainlevel.
This could cause a huge blast radius of changes, but can be useful in the future. - Same as above but using
static mutglobal variable. This should reduce blast radius, but is a bit smelly. - We use a makeshift stdin prompter. Hacky workaround.
- Something else?
Avoid re-running the auth helper on every startup by persisting last_key and expires_at to credentials.json. The helper only re-executes when the cached key has expired or on first use. This is important for zsh mode where the binary is re-invoked per command — eliminating unnecessary helper execution on startup. Co-Authored-By: Claude Code <noreply@anthropic.com>
Truncate the command string to 40 characters in error messages to avoid leaking sensitive vault paths or secret names through logs and UI error output. Also fix potential u64 to i64 overflow in TTL parsing by using i64::try_from with a meaningful error. Co-Authored-By: Claude Code <noreply@anthropic.com>
Co-Authored-By: Claude Code <noreply@anthropic.com>
kill_on_drop only kills the direct child (sh), leaving grandchildren orphaned when the helper command uses pipes or subshells. Place the child in its own process group via process_group(0) and send SIGKILL to the entire group on timeout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Match on HelperCommand variant before calling execute() in refresh(), avoiding unnecessary async overhead for StaticKey credentials. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prevents FORGE_API_KEY_HELPER_TIMEOUT from being set to an unreasonably large value that would effectively disable the timeout. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
Action required: PR inactive for 5 days. |
not stale |
|
Action required: PR inactive for 5 days. |
Not stale. Waiting for maintainer's response |
|
Action required: PR inactive for 5 days. |
Still waiting for reply |
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