1use indexmap::IndexMap;
9use serde::{Deserialize, Serialize};
10
11use crate::auth::credential::{ApiKeyCredential, AuthCredential, CredentialType};
12use crate::auth::oauth::{OAuthError, OAuthRefresher, ensure_fresh_oauth};
13use crate::auth::storage::{AuthStorage, AuthStorageError};
14use crate::config_value::{
15 ConfigValueError, config_value_env_var_names, is_command_config_value,
16 is_config_value_configured, resolve_config_value_strict, resolve_headers_strict,
17};
18use crate::env::EnvSource;
19
20#[derive(Clone, Debug, PartialEq, Eq)]
22pub(crate) struct ApiKeyPlan {
23 pub(crate) inherited_env: Option<Vec<String>>,
25 pub(crate) raw_key: Option<String>,
27 pub(crate) raw_key_from_override: bool,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
33pub(crate) struct StandardAuth {
34 pub(crate) api_key: Option<ApiKeyPlan>,
35 pub(crate) oauth: bool,
36 pub(crate) raw_headers: Option<IndexMap<String, String>>,
38 pub(crate) auth_header: bool,
40}
41
42#[derive(Clone, Debug, PartialEq, Eq)]
44pub(crate) enum AuthPlan {
45 Virtual,
47 Standard(StandardAuth),
49}
50
51#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct AuthCheck {
54 #[serde(rename = "type")]
56 pub kind: CredentialType,
57 pub source: String,
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum AuthStatusSource {
66 Stored,
68 Runtime,
70 Environment,
72 Fallback,
74 ModelsJsonKey,
76 ModelsJsonCommand,
78}
79
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct AuthStatus {
83 pub configured: bool,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub source: Option<AuthStatusSource>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub label: Option<String>,
91}
92
93#[derive(Clone, PartialEq, Eq)]
95pub struct ResolvedAuth {
96 pub api_key: Option<String>,
98 pub headers: Option<IndexMap<String, String>>,
100 pub env: Option<IndexMap<String, String>>,
102 pub source: String,
104}
105
106impl std::fmt::Debug for ResolvedAuth {
107 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
108 f.debug_struct("ResolvedAuth")
109 .field("api_key", &self.api_key.as_ref().map(|_| "<redacted>"))
110 .field(
111 "headers",
112 &self.headers.as_ref().map(|h| h.keys().collect::<Vec<_>>()),
113 )
114 .field("source", &self.source)
115 .finish_non_exhaustive()
116 }
117}
118
119#[derive(Debug, thiserror::Error)]
121pub enum AuthResolveError {
122 #[error("Credential store read failed for {provider}")]
124 Store {
125 provider: String,
127 #[source]
129 source: AuthStorageError,
130 },
131 #[error("API key auth failed for provider {provider}")]
133 ApiKey {
134 provider: String,
136 #[source]
138 source: ApiKeyError,
139 },
140 #[error(transparent)]
142 OAuth(#[from] OAuthError),
143 #[error(transparent)]
145 ModelHeaders(ConfigValueError),
146}
147
148#[derive(Debug, thiserror::Error)]
150pub enum ApiKeyError {
151 #[error(transparent)]
153 ConfigValue(#[from] ConfigValueError),
154 #[error("authHeader requires a resolved API key")]
156 AuthHeaderWithoutKey,
157}
158
159impl AuthResolveError {
160 #[must_use]
163 pub fn request_message(&self, provider: &str) -> String {
164 match self {
165 Self::Store { source, .. } => source.to_string(),
166 Self::ApiKey {
167 source: ApiKeyError::AuthHeaderWithoutKey,
168 ..
169 } => format!("No API key found for \"{provider}\""),
170 Self::ApiKey {
171 source: ApiKeyError::ConfigValue(error),
172 ..
173 }
174 | Self::ModelHeaders(error) => error.to_string(),
175 Self::OAuth(error) => error.cause_message(),
176 }
177 }
178}
179
180#[derive(Clone, Debug, Default, PartialEq, Eq)]
182pub struct AuthOverrides {
183 pub api_key: Option<String>,
185 pub min_oauth_validity_ms: Option<i64>,
187}
188
189fn first_env_key(names: &[String], env: &dyn EnvSource) -> Option<(String, String)> {
190 names.iter().find_map(|name| {
191 env.var(name)
192 .filter(|value| !value.is_empty())
193 .map(|value| (value, name.clone()))
194 })
195}
196
197pub(crate) fn check(
199 plan: &AuthPlan,
200 credential: Option<&AuthCredential>,
201 env: &dyn EnvSource,
202) -> Option<AuthCheck> {
203 let AuthPlan::Standard(plan) = plan else {
204 return Some(AuthCheck {
205 kind: CredentialType::ApiKey,
206 source: "virtual".to_owned(),
207 });
208 };
209 let api_key_check = |source: String| AuthCheck {
210 kind: CredentialType::ApiKey,
211 source,
212 };
213 match credential {
214 Some(AuthCredential::Oauth(_)) => plan.oauth.then(|| AuthCheck {
215 kind: CredentialType::Oauth,
216 source: "OAuth".to_owned(),
217 }),
218 Some(AuthCredential::ApiKey(stored)) => {
219 let api_key = plan.api_key.as_ref()?;
220 if stored.key.as_deref().is_some_and(|k| !k.is_empty()) {
221 return Some(api_key_check("stored credential".to_owned()));
222 }
223 let (_, name) = first_env_key(api_key.inherited_env.as_deref()?, env)?;
224 Some(api_key_check(name))
225 }
226 None => {
227 let api_key = plan.api_key.as_ref()?;
228 if let Some(raw) = &api_key.raw_key {
229 let configured = is_command_config_value(raw)
230 || config_value_env_var_names(raw)
231 .iter()
232 .all(|name| env.var(name).is_some_and(|v| !v.is_empty()));
233 return configured.then(|| api_key_check("configured API key".to_owned()));
234 }
235 let (_, name) = first_env_key(api_key.inherited_env.as_deref()?, env)?;
236 Some(api_key_check(name))
237 }
238 }
239}
240
241pub(crate) fn status(
243 plan: &AuthPlan,
244 storage: &AuthStorage,
245 provider: &str,
246 check: Option<&AuthCheck>,
247 env: &dyn EnvSource,
248) -> AuthStatus {
249 let configured = |source: AuthStatusSource, label: Option<String>| AuthStatus {
250 configured: true,
251 source: Some(source),
252 label,
253 };
254 if storage.has_runtime_api_key(provider) {
255 return configured(AuthStatusSource::Runtime, None);
256 }
257 if storage.has_stored(provider) {
258 return configured(AuthStatusSource::Stored, None);
259 }
260 if let AuthPlan::Standard(StandardAuth {
261 api_key: Some(api_key),
262 ..
263 }) = plan
264 && let Some(raw) = &api_key.raw_key
265 {
266 if is_command_config_value(raw) {
267 return configured(AuthStatusSource::ModelsJsonCommand, None);
268 }
269 let names = config_value_env_var_names(raw);
270 if !names.is_empty() {
271 return if is_config_value_configured(raw, None, env) {
272 configured(AuthStatusSource::Environment, Some(names.join(", ")))
273 } else {
274 AuthStatus {
275 configured: false,
276 source: None,
277 label: None,
278 }
279 };
280 }
281 return configured(
282 if api_key.raw_key_from_override {
283 AuthStatusSource::Fallback
284 } else {
285 AuthStatusSource::ModelsJsonKey
286 },
287 None,
288 );
289 }
290 match check {
291 Some(check) => configured(AuthStatusSource::Environment, Some(check.source.clone())),
292 None => AuthStatus {
293 configured: false,
294 source: None,
295 label: None,
296 },
297 }
298}
299
300fn with_configured_auth(
302 mut auth: ResolvedAuth,
303 headers: Option<IndexMap<String, String>>,
304 auth_header: bool,
305) -> Result<ResolvedAuth, ApiKeyError> {
306 let mut merged = match (auth.headers.take(), headers) {
307 (None, None) => None,
308 (base, over) => {
309 let mut merged = base.unwrap_or_default();
310 merged.extend(over.unwrap_or_default());
311 Some(merged)
312 }
313 };
314 if auth_header {
315 let key = auth
316 .api_key
317 .as_deref()
318 .filter(|k| !k.is_empty())
319 .ok_or(ApiKeyError::AuthHeaderWithoutKey)?;
320 merged
321 .get_or_insert_with(IndexMap::new)
322 .insert("Authorization".to_owned(), format!("Bearer {key}"));
323 }
324 auth.headers = merged;
325 Ok(auth)
326}
327
328pub(crate) struct Resolver<'a> {
330 pub(crate) provider: &'a str,
331 pub(crate) plan: &'a AuthPlan,
332 pub(crate) storage: &'a AuthStorage,
333 pub(crate) env: &'a dyn EnvSource,
334 pub(crate) refresher: Option<&'a dyn OAuthRefresher>,
335}
336
337impl Resolver<'_> {
338 pub(crate) async fn resolve(
340 &self,
341 overrides: &AuthOverrides,
342 ) -> Result<Option<ResolvedAuth>, AuthResolveError> {
343 let AuthPlan::Standard(plan) = self.plan else {
344 return Ok(Some(ResolvedAuth {
345 api_key: None,
346 headers: None,
347 env: None,
348 source: "virtual".to_owned(),
349 }));
350 };
351 if let (Some(key), Some(api_key)) = (&overrides.api_key, &plan.api_key) {
352 let credential = ApiKeyCredential {
353 key: Some(key.clone()),
354 ..ApiKeyCredential::default()
355 };
356 return self.resolve_api_key(plan, api_key, Some(&credential));
357 }
358 let credential =
359 self.storage
360 .get(self.provider)
361 .map_err(|source| AuthResolveError::Store {
362 provider: self.provider.to_owned(),
363 source,
364 })?;
365 match credential {
366 Some(AuthCredential::Oauth(stored)) if plan.oauth => {
367 let Some(fresh) = ensure_fresh_oauth(
368 self.storage,
369 self.provider,
370 self.refresher,
371 stored,
372 overrides.min_oauth_validity_ms,
373 )
374 .await?
375 else {
376 return Ok(None);
377 };
378 let env = fresh.extra.get("env").and_then(|env| {
379 serde_json::from_value::<IndexMap<String, String>>(env.clone()).ok()
380 });
381 let auth = ResolvedAuth {
382 api_key: Some(fresh.access),
383 headers: None,
384 env: None,
385 source: "OAuth".to_owned(),
386 };
387 let headers = self.resolve_provider_headers(plan, env.as_ref())?;
388 with_configured_auth(auth, headers, plan.auth_header)
389 .map(Some)
390 .map_err(|source| self.api_key_error(source))
391 }
392 Some(AuthCredential::ApiKey(stored)) => match &plan.api_key {
393 Some(api_key) => self.resolve_api_key(plan, api_key, Some(&stored)),
394 None => Ok(None),
395 },
396 Some(AuthCredential::Oauth(_)) => Ok(None),
397 None => match &plan.api_key {
398 Some(api_key) => self.resolve_api_key(plan, api_key, None),
399 None => Ok(None),
400 },
401 }
402 }
403
404 fn api_key_error(&self, source: ApiKeyError) -> AuthResolveError {
405 AuthResolveError::ApiKey {
406 provider: self.provider.to_owned(),
407 source,
408 }
409 }
410
411 fn resolve_provider_headers(
412 &self,
413 plan: &StandardAuth,
414 explicit: Option<&IndexMap<String, String>>,
415 ) -> Result<Option<IndexMap<String, String>>, AuthResolveError> {
416 resolve_headers_strict(
417 plan.raw_headers.as_ref(),
418 &format!("provider \"{}\"", self.provider),
419 explicit,
420 self.env,
421 )
422 .map_err(|error| self.api_key_error(error.into()))
423 }
424
425 fn resolve_api_key(
427 &self,
428 plan: &StandardAuth,
429 api_key: &ApiKeyPlan,
430 credential: Option<&ApiKeyCredential>,
431 ) -> Result<Option<ResolvedAuth>, AuthResolveError> {
432 let stored_key = |key: String, env: Option<IndexMap<String, String>>| ResolvedAuth {
433 api_key: Some(key),
434 headers: None,
435 env,
436 source: "stored credential".to_owned(),
437 };
438 let from_env = || {
439 api_key
440 .inherited_env
441 .as_deref()
442 .and_then(|names| first_env_key(names, self.env))
443 .map(|(key, name)| ResolvedAuth {
444 api_key: Some(key),
445 headers: None,
446 env: None,
447 source: name,
448 })
449 };
450 let result = if let Some(credential) = credential {
451 match credential.key.as_deref().filter(|k| !k.is_empty()) {
452 Some(key) => Some(stored_key(key.to_owned(), credential.env.clone())),
453 None if api_key.inherited_env.is_some() => from_env(),
454 None => None,
455 }
456 } else if let Some(raw) = &api_key.raw_key {
457 let description = format!("API key for provider \"{}\"", self.provider);
458 let key = resolve_config_value_strict(raw, &description, None, self.env)
459 .map_err(|error| self.api_key_error(error.into()))?;
460 Some(if api_key.inherited_env.is_some() {
461 stored_key(key, None)
462 } else {
463 ResolvedAuth {
464 api_key: Some(key),
465 headers: None,
466 env: None,
467 source: "configured API key".to_owned(),
468 }
469 })
470 } else {
471 from_env()
472 };
473 let Some(result) = result else {
474 return Ok(None);
475 };
476 let headers = self.resolve_provider_headers(plan, result.env.as_ref())?;
477 with_configured_auth(result, headers, plan.auth_header)
478 .map(Some)
479 .map_err(|source| self.api_key_error(source))
480 }
481}
482
483#[cfg(test)]
484mod tests {
485 use super::*;
486 use crate::env::MapEnv;
487
488 fn plan(raw_key: Option<&str>, inherited: Option<&[&str]>) -> AuthPlan {
489 AuthPlan::Standard(StandardAuth {
490 api_key: Some(ApiKeyPlan {
491 inherited_env: inherited
492 .map(|names| names.iter().map(|n| (*n).to_owned()).collect()),
493 raw_key: raw_key.map(str::to_owned),
494 raw_key_from_override: false,
495 }),
496 oauth: false,
497 raw_headers: None,
498 auth_header: false,
499 })
500 }
501
502 #[test]
503 fn check_sources() {
504 let env = MapEnv::new().with("K2", "v");
505 assert_eq!(
506 check(&plan(None, Some(&["K1", "K2"])), None, &env).map(|c| c.source),
507 Some("K2".to_owned())
508 );
509 assert_eq!(
510 check(&plan(Some("$MISSING"), Some(&["K2"])), None, &env),
511 None
512 );
513 assert_eq!(
514 check(&plan(Some("!cmd"), None), None, &env).map(|c| c.source),
515 Some("configured API key".to_owned())
516 );
517 let stored = AuthCredential::api_key("k");
518 assert_eq!(
519 check(&plan(None, None), Some(&stored), &env).map(|c| c.source),
520 Some("stored credential".to_owned())
521 );
522 }
523}