Skip to content

Commit 589066a

Browse files
Igor Yamolovclaude
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 615017f commit 589066a

File tree

25 files changed

+810
-95
lines changed

25 files changed

+810
-95
lines changed

crates/forge_api/src/forge_api.rs

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

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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,10 @@ 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+
/// Environment variable whose value is a shell command that produces an API
76+
/// key on stdout. Falls back to `{api_key_var}_HELPER` when absent.
77+
#[serde(default, skip_serializing_if = "Option::is_none")]
78+
pub api_key_helper_var: Option<String>,
7579
/// URL template for chat completions; may contain `{{VAR}}` placeholders
7680
/// that are substituted from the credential's url params.
7781
pub url: String,
@@ -353,4 +357,26 @@ mod tests {
353357

354358
assert_eq!(actual.temperature, fixture.temperature);
355359
}
360+
361+
#[test]
362+
fn test_provider_entry_api_key_helper_var_round_trip() {
363+
let fixture = ForgeConfig {
364+
providers: vec![ProviderEntry {
365+
id: "test_provider".to_string(),
366+
url: "https://api.example.com/v1/chat".to_string(),
367+
api_key_helper_var: Some("MY_AUTH_HELPER".to_string()),
368+
..Default::default()
369+
}],
370+
..Default::default()
371+
};
372+
373+
let toml = toml_edit::ser::to_string_pretty(&fixture).unwrap();
374+
let actual = ConfigReader::default().read_toml(&toml).build().unwrap();
375+
376+
assert_eq!(actual.providers.len(), 1);
377+
assert_eq!(
378+
actual.providers[0].api_key_helper_var,
379+
Some("MY_AUTH_HELPER".to_string())
380+
);
381+
}
356382
}

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)