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    refreshable: bool,
51    has_headers: impl FnOnce() -> bool,
52) -> AuthState {
53    let credential = resolve_refreshable(provider, config, store, manifest, values, refreshable);
54    let source = if credential.source.is_present() {
55        credential.source
56    } else if has_headers() {
57        CredentialSource::Headers
58    } else {
59        CredentialSource::Missing
60    };
61    let status = match source {
62        CredentialSource::Anonymous => AuthStatus::Anonymous,
63        CredentialSource::Missing => match store.get(provider) {
64            Some(Credential::Oauth { expires, .. })
65                if *expires != 0 && *expires <= values.now_unix_seconds() =>
66            {
67                AuthStatus::Expired
68            }
69            _ => AuthStatus::Missing,
70        },
71        _ => AuthStatus::Ready,
72    };
73    AuthState { status, source }
74}
75
76impl CredentialSource {
77    pub fn is_present(&self) -> bool {
78        !matches!(self, Self::Missing)
79    }
80
81    pub fn as_json_str(&self) -> &str {
82        match self {
83            Self::Explicit => "override",
84            Self::StoredApiKey => "auth.json",
85            Self::StoredOauth => "oauth",
86            Self::ModelsJson => "models.json",
87            Self::Environment(name) => name,
88            Self::Headers => "headers",
89            Self::Anonymous => "anonymous",
90            Self::Missing => "none",
91        }
92    }
93}
94
95/// Seconds before expiry at which a stored OAuth credential is renewed, so a
96/// token cannot expire between the check and the provider's request.
97pub const OAUTH_REFRESH_MARGIN_SECS: i64 = 60;
98
99/// Whether a stored OAuth credential expiring at `expires` (Unix seconds, `0`
100/// for never) should be renewed before it is used at `now`.
101pub fn oauth_needs_refresh(expires: i64, now: i64) -> bool {
102    expires != 0 && expires <= now.saturating_add(OAUTH_REFRESH_MARGIN_SECS)
103}
104
105/// A renewed OAuth access token, already persisted by the host.
106// Deliberately no Debug: this object contains secret material.
107pub struct RefreshedOAuth {
108    pub access: String,
109    /// Unix seconds; `0` means the credential does not expire.
110    pub expires: i64,
111}
112
113/// Host-owned renewal of stored OAuth credentials, typically through the
114/// provider package that issued them. Requests call it before sending a stored
115/// OAuth credential that has expired or is about to.
116pub trait OAuthRefresher: Send + Sync {
117    /// Whether the host can renew `provider`'s stored OAuth credential.
118    fn supports(&self, provider: &str) -> bool;
119    /// Renew and persist `provider`'s stored credential. This may block, so the
120    /// runtime calls it off the async workers. The error is shown to the user
121    /// and must not contain credential material.
122    fn refresh(&self, provider: &str) -> Result<RefreshedOAuth, String>;
123}
124
125// Deliberately no Debug: this object contains secret material.
126pub struct ResolvedCredential {
127    pub key: Option<String>,
128    pub source: CredentialSource,
129    pub bearer: bool,
130}
131
132/// Only these identities have a built-in environment fallback. Other providers
133/// need a loaded declaration or an explicit models.json apiKey reference.
134pub fn builtin_api_key_env(provider: &str) -> Option<&'static str> {
135    match provider {
136        "anthropic" => Some("ANTHROPIC_API_KEY"),
137        "openai" => Some("OPENAI_API_KEY"),
138        _ => None,
139    }
140}
141
142fn nonempty(value: Option<String>) -> Option<String> {
143    value.filter(|key| !key.trim().is_empty())
144}
145
146pub fn resolve(
147    provider: &str,
148    config: Option<&ProviderConfig>,
149    store: &AuthStore,
150    manifest: Option<&ProviderManifest>,
151    values: &dyn ValueSource,
152) -> ResolvedCredential {
153    resolve_refreshable(provider, config, store, manifest, values, false)
154}
155
156/// As [`resolve`], except that an expired stored OAuth credential still counts
157/// when `refreshable`: the execution binding renews it before it is sent.
158pub(crate) fn resolve_refreshable(
159    provider: &str,
160    config: Option<&ProviderConfig>,
161    store: &AuthStore,
162    manifest: Option<&ProviderManifest>,
163    values: &dyn ValueSource,
164    refreshable: bool,
165) -> ResolvedCredential {
166    let bearer = config
167        .and_then(|config| config.auth_header)
168        .unwrap_or(false);
169    let credential = |key, source, bearer| ResolvedCredential {
170        key: Some(key),
171        source,
172        bearer,
173    };
174    match store.get(provider) {
175        Some(Credential::ApiKey {
176            key: Some(key),
177            env,
178        }) => {
179            if let Some(key) = nonempty(crate::values::resolve_config_value(
180                key,
181                env.as_ref(),
182                values,
183                true,
184            )) {
185                return credential(key, CredentialSource::StoredApiKey, bearer);
186            }
187        }
188        Some(Credential::Oauth {
189            access,
190            refresh,
191            expires,
192        }) if !access.trim().is_empty() => {
193            let now = values.now_unix_seconds();
194            if *expires == 0 || *expires > now || (refreshable && !refresh.trim().is_empty()) {
195                return credential(access.clone(), CredentialSource::StoredOauth, true);
196            }
197        }
198        _ => {}
199    }
200    if let Some(key) = config
201        .and_then(|config| config.api_key.as_deref())
202        .and_then(|raw| nonempty(crate::values::resolve_config_value(raw, None, values, true)))
203    {
204        return credential(key, CredentialSource::ModelsJson, bearer);
205    }
206    // A package declaration is authoritative even for a built-in identity.
207    if manifest.is_none() && provider == "anthropic" {
208        if let Some(key) = nonempty(values.env("ANTHROPIC_AUTH_TOKEN")) {
209            return credential(
210                key,
211                CredentialSource::Environment("ANTHROPIC_AUTH_TOKEN".into()),
212                true,
213            );
214        }
215    }
216    let env = match manifest {
217        Some(manifest) => manifest.api_key_env.as_deref(),
218        None => builtin_api_key_env(provider),
219    };
220    if let Some(name) = env {
221        if let Some(key) = nonempty(values.env(name)) {
222            return credential(key, CredentialSource::Environment(name.into()), bearer);
223        }
224    }
225    ResolvedCredential {
226        key: None,
227        source: if manifest.is_some_and(|manifest| !manifest.requires_auth) {
228            CredentialSource::Anonymous
229        } else {
230            CredentialSource::Missing
231        },
232        bearer,
233    }
234}
235
236pub fn has_header_auth(headers: Option<&std::collections::BTreeMap<String, String>>) -> bool {
237    headers.is_some_and(|headers| {
238        headers.iter().any(|(name, value)| {
239            ["authorization", "x-api-key", "cf-aig-authorization"]
240                .contains(&name.to_ascii_lowercase().as_str())
241                && !value.trim().is_empty()
242        })
243    })
244}