Skip to main content

oino_models/registry/
auth.rs

1//! Credential checks and resolution per provider
2//! `composeApiKeyAuth`/`composeOAuthAuth`, `envApiKeyAuth`).
3//!
4//! Order: runtime key (`--api-key`) > `auth.json` > `models.json`/override `apiKey` >
5//! registration `authEnv` variables. A stored credential owns the provider: there is no
6//! fallback to the environment after a failed OAuth refresh.
7
8use 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/// API-key method of a provider.
21#[derive(Clone, Debug, PartialEq, Eq)]
22pub(crate) struct ApiKeyPlan {
23    /// Registration env vars (`Some` when the registration itself offers API keys).
24    pub(crate) inherited_env: Option<Vec<String>>,
25    /// `models.json` / override `apiKey` config value.
26    pub(crate) raw_key: Option<String>,
27    /// `raw_key` came from an override (`fallback` status source).
28    pub(crate) raw_key_from_override: bool,
29}
30
31/// Auth methods of a composed provider.
32#[derive(Clone, Debug, PartialEq, Eq)]
33pub(crate) struct StandardAuth {
34    pub(crate) api_key: Option<ApiKeyPlan>,
35    pub(crate) oauth: bool,
36    /// Provider headers from `models.json` and overrides (unresolved).
37    pub(crate) raw_headers: Option<IndexMap<String, String>>,
38    /// Add `Authorization: Bearer <key>`.
39    pub(crate) auth_header: bool,
40}
41
42/// How a provider authenticates.
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub(crate) enum AuthPlan {
45    /// A provider listing only virtual models: always configured, no credentials.
46    Virtual,
47    /// Regular provider.
48    Standard(StandardAuth),
49}
50
51/// A provider has usable credentials.
52#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
53pub struct AuthCheck {
54    /// Credential kind.
55    #[serde(rename = "type")]
56    pub kind: CredentialType,
57    /// Where it comes from (`stored credential`, an env var name, `configured API key`,
58    /// `OAuth`, `virtual`).
59    pub source: String,
60}
61
62/// The auth status.source record.
63#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum AuthStatusSource {
66    /// `auth.json`.
67    Stored,
68    /// `--api-key`.
69    Runtime,
70    /// Environment variable(s).
71    Environment,
72    /// Override `apiKey` literal.
73    Fallback,
74    /// `models.json` literal key.
75    ModelsJsonKey,
76    /// `models.json` `!command`.
77    ModelsJsonCommand,
78}
79
80/// Auth status (for `/login` and status displays).
81#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
82pub struct AuthStatus {
83    /// Credentials are configured.
84    pub configured: bool,
85    /// Their source.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub source: Option<AuthStatusSource>,
88    /// Env var names or check source.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub label: Option<String>,
91}
92
93/// Credentials resolved for one provider.
94#[derive(Clone, PartialEq, Eq)]
95pub struct ResolvedAuth {
96    /// API key / access token.
97    pub api_key: Option<String>,
98    /// Provider headers (resolved; `Authorization` when `authHeader`).
99    pub headers: Option<IndexMap<String, String>>,
100    /// Variables from the stored credential's `env`.
101    pub env: Option<IndexMap<String, String>>,
102    /// Source label.
103    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/// Credential resolution failures.
120#[derive(Debug, thiserror::Error)]
121pub enum AuthResolveError {
122    /// The credential store could not be read.
123    #[error("Credential store read failed for {provider}")]
124    Store {
125        /// Provider id.
126        provider: String,
127        /// Underlying error.
128        #[source]
129        source: AuthStorageError,
130    },
131    /// An API key, header or `authHeader` could not be resolved.
132    #[error("API key auth failed for provider {provider}")]
133    ApiKey {
134        /// Provider id.
135        provider: String,
136        /// Underlying error.
137        #[source]
138        source: ApiKeyError,
139    },
140    /// OAuth refresh failed.
141    #[error(transparent)]
142    OAuth(#[from] OAuthError),
143    /// A model header could not be resolved.
144    #[error(transparent)]
145    ModelHeaders(ConfigValueError),
146}
147
148/// Causes of [`AuthResolveError::ApiKey`].
149#[derive(Debug, thiserror::Error)]
150pub enum ApiKeyError {
151    /// A config value did not resolve.
152    #[error(transparent)]
153    ConfigValue(#[from] ConfigValueError),
154    /// `authHeader` without a key.
155    #[error("authHeader requires a resolved API key")]
156    AuthHeaderWithoutKey,
157}
158
159impl AuthResolveError {
160    /// The message `getApiKeyAndHeaders` reports: the cause's message, with
161    /// `authHeader requires a resolved API key` shown as `No API key found for "<provider>"`.
162    #[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/// Per-request auth inputs.
181#[derive(Clone, Debug, Default, PartialEq, Eq)]
182pub struct AuthOverrides {
183    /// A request-scoped API key.
184    pub api_key: Option<String>,
185    /// Required remaining OAuth validity (default five minutes).
186    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
197/// Checks auth for a composed provider.
198pub(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
241/// Returns the provider auth status.
242pub(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
300/// W i t h   C o n f i g u r e d   A u t h.
301fn 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
328/// Inputs shared by one resolution.
329pub(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    /// Resolves provider auth + composition: `None` when nothing is configured.
339    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    /// C o m p o s e   A p i   K e y   A u t h ( ) . r e s o l v e.
426    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}