Skip to main content

talon_core/config/
auth.rs

1//! Credential resolution for HTTP endpoint configuration.
2
3use std::collections::BTreeMap;
4use std::env;
5
6use serde::{Deserialize, Serialize};
7
8use crate::error::TalonError;
9
10/// Named API credential referenced by capability blocks.
11#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
12#[serde(deny_unknown_fields)]
13pub struct CredentialEntry {
14    /// Inline API key (discouraged; prefer `api_key_env`).
15    #[serde(default)]
16    pub api_key: Option<String>,
17    /// Environment variable holding the API key.
18    #[serde(default)]
19    pub api_key_env: Option<String>,
20}
21
22/// Named credential table from `[credentials.*]` config sections.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
24pub struct CredentialsConfig {
25    #[serde(flatten)]
26    pub entries: BTreeMap<String, CredentialEntry>,
27}
28
29/// Resolved authentication material for an HTTP client.
30#[derive(Debug, Clone, PartialEq, Eq, Default)]
31pub struct ResolvedAuth {
32    /// Bearer token, when configured.
33    pub api_key: Option<String>,
34    /// Provider-specific headers (for example `OpenRouter` attribution).
35    pub extra_headers: BTreeMap<String, String>,
36}
37
38/// Shared transport/auth fields for any HTTP capability block.
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
40#[serde(deny_unknown_fields)]
41pub struct EndpointAuthConfig {
42    #[serde(default)]
43    pub credential: Option<String>,
44    #[serde(default)]
45    pub api_key: Option<String>,
46    #[serde(default)]
47    pub api_key_env: Option<String>,
48    #[serde(default)]
49    pub extra_headers: BTreeMap<String, String>,
50}
51
52impl EndpointAuthConfig {
53    /// Resolves the API key and merges extra headers for this endpoint.
54    ///
55    /// # Errors
56    ///
57    /// Returns [`TalonError::Config`] when a referenced credential or env var
58    /// is missing.
59    pub fn resolve(&self, credentials: &CredentialsConfig) -> Result<ResolvedAuth, TalonError> {
60        let api_key = resolve_api_key(credentials, self)?;
61        Ok(ResolvedAuth {
62            api_key,
63            extra_headers: self.extra_headers.clone(),
64        })
65    }
66}
67
68/// Resolves an API key from inline fields and optional named credentials.
69///
70/// Precedence: inline `api_key` → inline `api_key_env` → credential `api_key` →
71/// credential `api_key_env`.
72///
73/// # Errors
74///
75/// Returns [`TalonError::Config`] when a referenced credential or env var is
76/// missing.
77pub fn resolve_api_key(
78    credentials: &CredentialsConfig,
79    auth: &EndpointAuthConfig,
80) -> Result<Option<String>, TalonError> {
81    if let Some(key) = non_empty(auth.api_key.as_deref()) {
82        return Ok(Some(key.to_owned()));
83    }
84    if let Some(env_name) = non_empty(auth.api_key_env.as_deref()) {
85        return read_env_key(env_name);
86    }
87    let Some(credential_name) = non_empty(auth.credential.as_deref()) else {
88        return Ok(None);
89    };
90    let entry = credentials
91        .entries
92        .get(credential_name)
93        .ok_or_else(|| TalonError::Config {
94            message: format!("unknown credential: {credential_name}"),
95        })?;
96    if let Some(key) = non_empty(entry.api_key.as_deref()) {
97        return Ok(Some(key.to_owned()));
98    }
99    if let Some(env_name) = non_empty(entry.api_key_env.as_deref()) {
100        // NotPresent falls through to keychain; only return if found or error.
101        if let Some(key) = try_env_key(env_name)? {
102            return Ok(Some(key));
103        }
104    }
105    match crate::config::keychain::get(credential_name) {
106        Ok(Some(key)) => Ok(Some(key)),
107        Ok(None) => Ok(None),
108        Err(error) => {
109            tracing::debug!(%credential_name, %error, "failed to read credential from keychain");
110            Ok(None)
111        }
112    }
113}
114
115/// Reads an env var, returning `None` if not set. Used for credential-table
116/// `api_key_env` so an absent var falls through to the keychain.
117fn try_env_key(env_name: &str) -> Result<Option<String>, TalonError> {
118    match env::var(env_name) {
119        Ok(value) if value.is_empty() => Err(TalonError::Config {
120            message: format!("environment variable {env_name} is empty"),
121        }),
122        Ok(value) => Ok(Some(value)),
123        Err(env::VarError::NotPresent) => Ok(None),
124        Err(env::VarError::NotUnicode(_)) => Err(TalonError::Config {
125            message: format!("environment variable {env_name} is not valid UTF-8"),
126        }),
127    }
128}
129
130fn read_env_key(env_name: &str) -> Result<Option<String>, TalonError> {
131    match env::var(env_name) {
132        Ok(value) if value.is_empty() => Err(TalonError::Config {
133            message: format!("environment variable {env_name} is empty"),
134        }),
135        Ok(value) => Ok(Some(value)),
136        Err(env::VarError::NotPresent) => Err(TalonError::Config {
137            message: format!("environment variable {env_name} is not set"),
138        }),
139        Err(env::VarError::NotUnicode(_)) => Err(TalonError::Config {
140            message: format!("environment variable {env_name} is not valid UTF-8"),
141        }),
142    }
143}
144
145fn non_empty(value: Option<&str>) -> Option<&str> {
146    value.filter(|s| !s.is_empty())
147}
148
149#[cfg(test)]
150#[allow(clippy::expect_used, clippy::unwrap_used)]
151mod tests {
152    use super::*;
153
154    fn creds() -> CredentialsConfig {
155        let mut entries = BTreeMap::new();
156        entries.insert(
157            "openrouter".to_owned(),
158            CredentialEntry {
159                api_key: None,
160                api_key_env: Some("OPENROUTER_API_KEY".to_owned()),
161            },
162        );
163        CredentialsConfig { entries }
164    }
165
166    #[test]
167    fn inline_api_key_wins() {
168        let auth = EndpointAuthConfig {
169            api_key: Some("inline".to_owned()),
170            api_key_env: Some("IGNORE".to_owned()),
171            ..EndpointAuthConfig::default()
172        };
173        assert_eq!(
174            resolve_api_key(&creds(), &auth).expect("resolve inline api key"),
175            Some("inline".to_owned())
176        );
177    }
178
179    #[test]
180    fn credential_entry_api_key_is_used_when_present() {
181        let mut entries = BTreeMap::new();
182        entries.insert(
183            "openrouter".to_string(),
184            CredentialEntry {
185                api_key: Some("from-table".to_owned()),
186                api_key_env: Some("OPENROUTER_API_KEY".to_owned()),
187            },
188        );
189        let creds = CredentialsConfig { entries };
190        let auth = EndpointAuthConfig {
191            credential: Some("openrouter".to_owned()),
192            ..EndpointAuthConfig::default()
193        };
194        assert_eq!(
195            resolve_api_key(&creds, &auth).expect("resolve credential api key"),
196            Some("from-table".to_owned())
197        );
198    }
199}