Skip to content

Commit 26419d7

Browse files
t3hk0d3claude
andcommitted
feat(auth): add API key helper command support
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>
1 parent 445d1ce commit 26419d7

File tree

25 files changed

+799
-95
lines changed

25 files changed

+799
-95
lines changed

crates/forge_api/src/forge_api.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,7 +274,7 @@ impl<
274274
.credential
275275
.as_ref()
276276
.and_then(|c| match &c.auth_details {
277-
forge_domain::AuthDetails::ApiKey(key) => Some(key.as_str()),
277+
forge_domain::AuthDetails::ApiKey(provider) => Some(provider.api_key().as_str()),
278278
_ => None,
279279
})
280280
{

crates/forge_app/src/command_generator.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ mod tests {
225225
url_params: vec![],
226226
credential: Some(AuthCredential {
227227
id: ProviderId::OPENAI,
228-
auth_details: AuthDetails::ApiKey("test-key".to_string().into()),
228+
auth_details: AuthDetails::static_api_key("test-key".to_string().into()),
229229
url_params: Default::default(),
230230
}),
231231
custom_headers: None,

crates/forge_app/src/dto/openai/transformers/pipeline.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@ mod tests {
128128
fn make_credential(provider_id: ProviderId, key: &str) -> Option<forge_domain::AuthCredential> {
129129
Some(forge_domain::AuthCredential {
130130
id: provider_id,
131-
auth_details: forge_domain::AuthDetails::ApiKey(forge_domain::ApiKey::from(
131+
auth_details: forge_domain::AuthDetails::static_api_key(forge_domain::ApiKey::from(
132132
key.to_string(),
133133
)),
134134
url_params: HashMap::new(),

crates/forge_config/src/config.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,11 @@ pub struct ProviderEntry {
7272
/// Environment variable holding the API key for this provider.
7373
#[serde(default, skip_serializing_if = "Option::is_none")]
7474
pub api_key_var: Option<String>,
75+
/// Shell command that produces an API key on stdout. When set, the
76+
/// command is executed instead of reading a static key from an environment
77+
/// variable. Falls back to `{api_key_var}_HELPER` env var when absent.
78+
#[serde(default, skip_serializing_if = "Option::is_none")]
79+
pub api_key_helper: Option<String>,
7580
/// URL template for chat completions; may contain `{{VAR}}` placeholders
7681
/// that are substituted from the credential's url params.
7782
pub url: String,
@@ -353,4 +358,26 @@ mod tests {
353358

354359
assert_eq!(actual.temperature, fixture.temperature);
355360
}
361+
362+
#[test]
363+
fn test_provider_entry_api_key_helper_round_trip() {
364+
let fixture = ForgeConfig {
365+
providers: vec![ProviderEntry {
366+
id: "test_provider".to_string(),
367+
url: "https://api.example.com/v1/chat".to_string(),
368+
api_key_helper: Some("vault read -field=token secret/key".to_string()),
369+
..Default::default()
370+
}],
371+
..Default::default()
372+
};
373+
374+
let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap();
375+
let actual = ConfigReader::default().read_toml(&toml).build().unwrap();
376+
377+
assert_eq!(actual.providers.len(), 1);
378+
assert_eq!(
379+
actual.providers[0].api_key_helper,
380+
Some("vault read -field=token secret/key".to_string())
381+
);
382+
}
356383
}

crates/forge_domain/src/auth/auth_context.rs

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ pub struct ApiKeyRequest {
2626
pub struct ApiKeyResponse {
2727
pub api_key: ApiKey,
2828
pub url_params: HashMap<URLParam, URLParamValue>,
29+
/// When set, the API key was produced by this shell command and the
30+
/// credential should be stored as a [`HelperCommand`](super::ApiKeyProvider::HelperCommand).
31+
pub helper_command: Option<String>,
2932
}
3033

3134
// Authorization Code Flow
@@ -95,7 +98,7 @@ pub enum AuthContextResponse {
9598
}
9699

97100
impl AuthContextResponse {
98-
/// Creates an API key authentication context
101+
/// Creates an API key authentication context with a static key.
99102
pub fn api_key(
100103
request: ApiKeyRequest,
101104
api_key: impl ToString,
@@ -109,6 +112,27 @@ impl AuthContextResponse {
109112
.into_iter()
110113
.map(|(k, v)| (k.into(), v.into()))
111114
.collect(),
115+
helper_command: None,
116+
},
117+
})
118+
}
119+
120+
/// Creates an API key authentication context backed by a helper command.
121+
pub fn api_key_with_helper(
122+
request: ApiKeyRequest,
123+
api_key: impl ToString,
124+
url_params: HashMap<String, String>,
125+
command: String,
126+
) -> Self {
127+
Self::ApiKey(AuthContext {
128+
request,
129+
response: ApiKeyResponse {
130+
api_key: api_key.to_string().into(),
131+
url_params: url_params
132+
.into_iter()
133+
.map(|(k, v)| (k.into(), v.into()))
134+
.collect(),
135+
helper_command: Some(command),
112136
},
113137
})
114138
}

0 commit comments

Comments
 (0)