Skip to main content

kunobi_jev/
credentials.rs

1//! Credentials: a static API key, or a provider that supplies a bearer token per attempt.
2//!
3//! Applications that run on user machines should not embed a TypeSafe API key:
4//! anyone with the binary can extract it. Point the client at your own backend
5//! instead, and use a [`CredentialProvider`] that returns a short-lived token for
6//! that backend, for example one issued by kunobi-auth.
7
8use std::fmt;
9use std::future::Future;
10use std::pin::Pin;
11use std::time::Duration;
12
13use reqwest::header::HeaderValue;
14pub use secrecy::{ExposeSecret, SecretString};
15use zeroize::Zeroizing;
16
17use crate::error::{Error, Result};
18
19/// A boxed error from a credential provider.
20pub type BoxError = Box<dyn std::error::Error + Send + Sync + 'static>;
21
22/// The future returned by [`CredentialProvider::bearer_token`].
23pub type TokenFuture<'a> =
24    Pin<Box<dyn Future<Output = std::result::Result<SecretString, BoxError>> + Send + 'a>>;
25
26/// Supplies the bearer token sent with each attempt.
27///
28/// The client asks for a token before every attempt, including retries, and
29/// does not cache it. Cache and refresh tokens inside the provider.
30///
31/// For a closure, use [`ClientBuilder::credentials_fn`](crate::ClientBuilder::credentials_fn).
32pub trait CredentialProvider: Send + Sync + 'static {
33    /// The token to send as `Authorization: Bearer <token>`.
34    fn bearer_token(&self) -> TokenFuture<'_>;
35}
36
37/// Adapts an async closure into a [`CredentialProvider`].
38pub(crate) struct FnProvider<F>(pub(crate) F);
39
40impl<F, Fut, T, E> CredentialProvider for FnProvider<F>
41where
42    F: Fn() -> Fut + Send + Sync + 'static,
43    Fut: Future<Output = std::result::Result<T, E>> + Send + 'static,
44    T: Into<SecretString>,
45    E: Into<BoxError>,
46{
47    fn bearer_token(&self) -> TokenFuture<'_> {
48        let token = (self.0)();
49        Box::pin(async move { token.await.map(Into::into).map_err(Into::into) })
50    }
51}
52
53/// Where the client gets its `Authorization` header.
54pub(crate) enum Credentials {
55    ApiKey(SecretString),
56    Provider(Box<dyn CredentialProvider>),
57}
58
59impl Credentials {
60    /// The `Authorization` header for one attempt.
61    ///
62    /// A provider that does not answer within `timeout` fails the call; provider
63    /// failures are not retried.
64    pub(crate) async fn authorization(&self, timeout: Duration) -> Result<HeaderValue> {
65        match self {
66            Credentials::ApiKey(key) => bearer_header(key)
67                .map_err(|message| Error::Config(format!("The API key {message}."))),
68            Credentials::Provider(provider) => {
69                let token = tokio::time::timeout(timeout, provider.bearer_token())
70                    .await
71                    .map_err(|_| Error::Credentials {
72                        source: format!(
73                            "the credential provider did not return a token within {}ms",
74                            timeout.as_millis()
75                        )
76                        .into(),
77                    })?
78                    .map_err(|source| Error::Credentials { source })?;
79                bearer_header(&token).map_err(|message| Error::Credentials {
80                    source: format!("the token {message}").into(),
81                })
82            }
83        }
84    }
85}
86
87/// `Bearer <token>`, marked sensitive. Errors describe what is wrong with the token.
88pub(crate) fn bearer_header(
89    token: &SecretString,
90) -> std::result::Result<HeaderValue, &'static str> {
91    let token = token.expose_secret().trim();
92    if token.is_empty() {
93        return Err("is blank");
94    }
95    // The formatted value is wiped on drop; the header keeps its own copy for the request.
96    let value = Zeroizing::new(format!("Bearer {token}"));
97    let mut header = HeaderValue::from_str(&value)
98        .map_err(|_| "contains characters that are not valid in an HTTP header")?;
99    header.set_sensitive(true);
100    Ok(header)
101}
102
103impl fmt::Debug for Credentials {
104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
105        match self {
106            Credentials::ApiKey(_) => f.write_str("ApiKey(***)"),
107            Credentials::Provider(_) => f.write_str("Provider"),
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn bearer_headers_are_sensitive_and_trimmed() {
118        let header = bearer_header(&SecretString::from(" sk-1 ")).unwrap();
119        assert!(header.is_sensitive());
120        assert_eq!(header.to_str().unwrap(), "Bearer sk-1");
121    }
122
123    #[test]
124    fn bearer_headers_reject_blank_and_invalid_tokens() {
125        assert_eq!(bearer_header(&SecretString::from("  ")), Err("is blank"));
126        assert!(bearer_header(&SecretString::from("a\nb")).is_err());
127    }
128
129    #[test]
130    fn debug_output_hides_the_key() {
131        let debug = format!("{:?}", Credentials::ApiKey(SecretString::from("sk-secret")));
132        assert_eq!(debug, "ApiKey(***)");
133    }
134
135    #[tokio::test]
136    async fn provider_failures_and_hangs_are_credential_errors() {
137        let failing = Credentials::Provider(Box::new(FnProvider(|| async {
138            Err::<String, _>(std::io::Error::other("refresh token expired"))
139        })));
140        let err = failing
141            .authorization(Duration::from_secs(1))
142            .await
143            .unwrap_err();
144        assert!(matches!(err, Error::Credentials { .. }));
145        assert!(err.to_string().contains("refresh token expired"), "{err}");
146
147        let hanging = Credentials::Provider(Box::new(FnProvider(|| async {
148            tokio::time::sleep(Duration::from_secs(60)).await;
149            Ok::<_, BoxError>("late".to_owned())
150        })));
151        let err = hanging
152            .authorization(Duration::from_millis(20))
153            .await
154            .unwrap_err();
155        assert!(
156            err.to_string()
157                .contains("did not return a token within 20ms"),
158            "{err}"
159        );
160
161        let invalid = Credentials::Provider(Box::new(FnProvider(|| async {
162            Ok::<_, BoxError>("bad\ntoken".to_owned())
163        })));
164        let err = invalid
165            .authorization(Duration::from_secs(1))
166            .await
167            .unwrap_err();
168        assert!(
169            err.to_string().contains("not valid in an HTTP header"),
170            "{err}"
171        );
172        assert!(
173            !err.to_string().contains("bad"),
174            "the token must not leak: {err}"
175        );
176    }
177}