Skip to main content

rpi_runtime/
credentials.rs

1//! Shared, provider-scoped credential resolution for requests and auth UI.
2//! Provider packages own their environment names and anonymous policy. The host
3//! owns persistence and precedence, and never guesses a key from a wire protocol.
4use crate::config::{AuthStore, Credential, ProviderConfig};
5use crate::ProviderDeclaration as ProviderManifest;
6use crate::ValueSource;
7
8#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
9pub enum CredentialSource {
10    /// A host-supplied override scoped to the selected provider.
11    Explicit,
12    StoredApiKey,
13    StoredOauth,
14    ModelsJson,
15    Environment(String),
16    Headers,
17    Anonymous,
18    Missing,
19}
20
21/// Local readiness only; this does not claim that a server accepted the key.
22#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize)]
23#[serde(rename_all = "camelCase")]
24pub enum AuthStatus {
25    Missing,
26    Ready,
27    Expired,
28    Anonymous,
29}
30
31/// Safe for UI snapshots and change notifications; contains no credential values.
32#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
33pub struct AuthState {
34    pub status: AuthStatus,
35    pub source: CredentialSource,
36}
37
38impl AuthState {
39    pub fn is_ready(&self) -> bool {
40        matches!(self.status, AuthStatus::Ready | AuthStatus::Anonymous)
41    }
42}
43
44pub(crate) fn state(
45    provider: &str,
46    config: Option<&ProviderConfig>,
47    store: &AuthStore,
48    manifest: Option<&ProviderManifest>,
49    values: &dyn ValueSource,
50    has_headers: impl FnOnce() -> bool,
51) -> AuthState {
52    let credential = resolve(provider, config, store, manifest, values);
53    let source = if credential.source.is_present() {
54        credential.source
55    } else if has_headers() {
56        CredentialSource::Headers
57    } else {
58        CredentialSource::Missing
59    };
60    let status = match source {
61        CredentialSource::Anonymous => AuthStatus::Anonymous,
62        CredentialSource::Missing => match store.get(provider) {
63            Some(Credential::Oauth { expires, .. })
64                if *expires != 0 && *expires <= values.now_unix_seconds() =>
65            {
66                AuthStatus::Expired
67            }
68            _ => AuthStatus::Missing,
69        },
70        _ => AuthStatus::Ready,
71    };
72    AuthState { status, source }
73}
74
75impl CredentialSource {
76    pub fn is_present(&self) -> bool {
77        !matches!(self, Self::Missing)
78    }
79
80    pub fn as_json_str(&self) -> &str {
81        match self {
82            Self::Explicit => "override",
83            Self::StoredApiKey => "auth.json",
84            Self::StoredOauth => "oauth",
85            Self::ModelsJson => "models.json",
86            Self::Environment(name) => name,
87            Self::Headers => "headers",
88            Self::Anonymous => "anonymous",
89            Self::Missing => "none",
90        }
91    }
92}
93
94// Deliberately no Debug: this object contains secret material.
95pub struct ResolvedCredential {
96    pub key: Option<String>,
97    pub source: CredentialSource,
98    pub bearer: bool,
99}
100
101/// Only these identities have a built-in environment fallback. Other providers
102/// need a loaded declaration or an explicit models.json apiKey reference.
103pub fn builtin_api_key_env(provider: &str) -> Option<&'static str> {
104    match provider {
105        "anthropic" => Some("ANTHROPIC_API_KEY"),
106        "openai" => Some("OPENAI_API_KEY"),
107        _ => None,
108    }
109}
110
111fn nonempty(value: Option<String>) -> Option<String> {
112    value.filter(|key| !key.trim().is_empty())
113}
114
115pub fn resolve(
116    provider: &str,
117    config: Option<&ProviderConfig>,
118    store: &AuthStore,
119    manifest: Option<&ProviderManifest>,
120    values: &dyn ValueSource,
121) -> ResolvedCredential {
122    let bearer = config
123        .and_then(|config| config.auth_header)
124        .unwrap_or(false);
125    let credential = |key, source, bearer| ResolvedCredential {
126        key: Some(key),
127        source,
128        bearer,
129    };
130    match store.get(provider) {
131        Some(Credential::ApiKey {
132            key: Some(key),
133            env,
134        }) => {
135            if let Some(key) = nonempty(crate::values::resolve_config_value(
136                key,
137                env.as_ref(),
138                values,
139                true,
140            )) {
141                return credential(key, CredentialSource::StoredApiKey, bearer);
142            }
143        }
144        Some(Credential::Oauth {
145            access, expires, ..
146        }) if !access.trim().is_empty() => {
147            let now = values.now_unix_seconds();
148            if *expires == 0 || *expires > now {
149                return credential(access.clone(), CredentialSource::StoredOauth, true);
150            }
151        }
152        _ => {}
153    }
154    if let Some(key) = config
155        .and_then(|config| config.api_key.as_deref())
156        .and_then(|raw| nonempty(crate::values::resolve_config_value(raw, None, values, true)))
157    {
158        return credential(key, CredentialSource::ModelsJson, bearer);
159    }
160    // A package declaration is authoritative even for a built-in identity.
161    if manifest.is_none() && provider == "anthropic" {
162        if let Some(key) = nonempty(values.env("ANTHROPIC_AUTH_TOKEN")) {
163            return credential(
164                key,
165                CredentialSource::Environment("ANTHROPIC_AUTH_TOKEN".into()),
166                true,
167            );
168        }
169    }
170    let env = match manifest {
171        Some(manifest) => manifest.api_key_env.as_deref(),
172        None => builtin_api_key_env(provider),
173    };
174    if let Some(name) = env {
175        if let Some(key) = nonempty(values.env(name)) {
176            return credential(key, CredentialSource::Environment(name.into()), bearer);
177        }
178    }
179    ResolvedCredential {
180        key: None,
181        source: if manifest.is_some_and(|manifest| !manifest.requires_auth) {
182            CredentialSource::Anonymous
183        } else {
184            CredentialSource::Missing
185        },
186        bearer,
187    }
188}
189
190pub fn has_header_auth(headers: Option<&std::collections::BTreeMap<String, String>>) -> bool {
191    headers.is_some_and(|headers| {
192        headers.iter().any(|(name, value)| {
193            ["authorization", "x-api-key", "cf-aig-authorization"]
194                .contains(&name.to_ascii_lowercase().as_str())
195                && !value.trim().is_empty()
196        })
197    })
198}