Skip to main content

allama_http/
credential.rs

1//! Per-request credentials: what a caller sends, and where an adapter puts
2//! it.
3//!
4//! A [`Credential`] is a typed secret a caller attaches to one request with
5//! [`HttpRequestOptions::credential`](crate::HttpRequestOptions::credential),
6//! addressed to a **credential scope**: the account it belongs to. A
7//! [`CredentialSlot`] is the adapter's side: its scope, and where its
8//! credential goes on the wire. [`HttpRequest::apply_options`] matches the
9//! two, so a key meant for one account never reaches another provider, even
10//! when a fallback sends the same request to both.
11
12use std::borrow::Cow;
13use std::fmt;
14
15use serde::de::Error as _;
16use serde::ser::SerializeStruct as _;
17use serde::{Deserialize, Deserializer, Serialize, Serializer};
18
19#[cfg(doc)]
20use crate::HttpRequest;
21
22/// A credential for one request: a typed, redacted secret.
23///
24/// Attach it with
25/// [`HttpRequestOptions::credential`](crate::HttpRequestOptions::credential),
26/// addressed to a credential scope. Only the adapter whose
27/// [`CredentialSlot`] has that scope sends it, placed where that adapter's
28/// API expects it:
29///
30/// - [`Credential::api_key`] is the provider's own key. It replaces the key
31///   the provider was configured with.
32/// - [`Credential::header`] is any other authentication header, such as a
33///   gateway token (`cf-aig-authorization`, `ocp-apim-subscription-key`) or
34///   a third-party adapter's own header. The secret is the whole header
35///   value.
36///
37/// ```
38/// use allama::ChatRequest;
39/// use allama_http::{Credential, HttpRequestOptions};
40///
41/// let request = ChatRequest::new("gpt-5-mini").user("Hi").with_option(
42///     HttpRequestOptions::new()
43///         .credential("openai", Credential::api_key("sk-tenant-key"))
44///         .credential("openai", Credential::header("cf-aig-authorization", "Bearer gw-token")),
45/// );
46/// assert!(!format!("{request:?}").contains("sk-tenant-key"));
47/// ```
48///
49/// # Secrets stay in the process
50///
51/// `Debug` shows the kind and never the secret, and so does every error
52/// allama-http writes about a credential. Serializing a credential writes
53/// its kind and omits the secret, while deserializing one reads the secret:
54/// the serde impl is asymmetric on purpose, so a credential can come from a
55/// configuration file but is never written back out. The JSON form of
56/// [`HttpRequestOptions`](crate::HttpRequestOptions) does not take
57/// credentials, because JSON options are exported and printed as given;
58/// deserialize the credential and attach it typed.
59///
60/// ```
61/// use allama_http::Credential;
62///
63/// let credential: Credential =
64///     serde_json::from_str(r#"{"kind": "api_key", "secret": "sk-SECRET"}"#).unwrap();
65/// assert_eq!(credential.expose_secret(), "sk-SECRET");
66/// assert_eq!(serde_json::to_string(&credential).unwrap(), r#"{"kind":"api_key"}"#);
67/// ```
68///
69/// # No equality, no zeroing
70///
71/// A credential is not `PartialEq`: comparing
72/// secrets is not something a request option needs, and a derived
73/// comparison would short-circuit on the first differing byte. Compare
74/// [`kind`](Self::kind)s, or compare [`expose_secret`](Self::expose_secret)
75/// yourself, in constant time if the comparison guards anything.
76/// [`HttpRequestOptions`](crate::HttpRequestOptions), which holds
77/// credentials, is not `PartialEq` either.
78///
79/// ```compile_fail
80/// use allama_http::Credential;
81///
82/// let _ = Credential::api_key("a") == Credential::api_key("b");
83/// ```
84///
85/// The secret is a plain `String`: it is not zeroed when dropped. Copies of
86/// it already live in the request headers, the HTTP client and the TLS
87/// buffers, which a `Credential` cannot reach, so zeroing only its own copy
88/// would promise more than it delivers. Zeroing can be added later without
89/// changing this API.
90#[derive(Clone)]
91pub struct Credential {
92    kind: CredentialKind,
93    secret: String,
94}
95
96impl Credential {
97    /// The provider's own API key.
98    ///
99    /// The adapter places it as its API expects (a bearer token, an
100    /// `x-api-key` header, ...), replacing the key it was configured with.
101    #[must_use]
102    pub fn api_key(secret: impl Into<String>) -> Self {
103        Self {
104            kind: CredentialKind::ApiKey,
105            secret: secret.into(),
106        }
107    }
108
109    /// An authentication header named `name`, whose whole value is
110    /// `secret`.
111    ///
112    /// For gateway tokens and third-party adapters' own headers. A header
113    /// credential replaces every header of the same name the adapter set,
114    /// so it is sent once. Include any scheme in the secret: Cloudflare's
115    /// gateway token is `Bearer <token>`.
116    #[must_use]
117    pub fn header(name: impl Into<String>, secret: impl Into<String>) -> Self {
118        Self {
119            kind: CredentialKind::Header(name.into()),
120            secret: secret.into(),
121        }
122    }
123
124    /// What kind of credential this is.
125    #[must_use]
126    pub fn kind(&self) -> &CredentialKind {
127        &self.kind
128    }
129
130    /// The secret, for an adapter that places the credential itself
131    /// ([`CredentialPlacement::Custom`]), for example to sign a request.
132    ///
133    /// Never log or record it.
134    #[must_use]
135    pub fn expose_secret(&self) -> &str {
136        &self.secret
137    }
138
139    /// The error for a credential an adapter cannot place: an authored
140    /// [`allama::Error::Configuration`] naming the kind and the `scope`,
141    /// never the secret.
142    ///
143    /// An adapter that places its credential itself returns it for a kind
144    /// it does not understand, addressed to its scope, instead of falling
145    /// back to its configured key, which would bill the wrong account.
146    #[must_use]
147    pub fn unsupported(&self, scope: &str) -> allama::Error {
148        allama::Error::configuration(format!(
149            "a {} credential addressed to credential scope `{scope}` cannot be sent by this \
150             provider",
151            self.kind.name()
152        ))
153        .authored()
154    }
155
156    /// Whether this is the scope's own credential, which the adapter
157    /// places, rather than a header it adds as given.
158    pub(crate) fn is_placed_by_adapter(&self) -> bool {
159        !matches!(self.kind, CredentialKind::Header(_))
160    }
161}
162
163impl fmt::Debug for Credential {
164    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
165        f.debug_struct("Credential")
166            .field("kind", &self.kind)
167            .field("secret", &format_args!("<redacted>"))
168            .finish()
169    }
170}
171
172/// Writes the kind, never the secret (see [Secrets stay in the
173/// process](Credential#secrets-stay-in-the-process)).
174impl Serialize for Credential {
175    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
176        match &self.kind {
177            CredentialKind::ApiKey => {
178                let mut state = serializer.serialize_struct("Credential", 1)?;
179                state.serialize_field("kind", "api_key")?;
180                state.end()
181            }
182            CredentialKind::Header(name) => {
183                let mut state = serializer.serialize_struct("Credential", 2)?;
184                state.serialize_field("kind", "header")?;
185                state.serialize_field("name", name)?;
186                state.end()
187            }
188        }
189    }
190}
191
192/// Reads `{"kind": "api_key", "secret": ".."}` or
193/// `{"kind": "header", "name": "..", "secret": ".."}`. A serialized
194/// credential has no secret, so it does not deserialize back.
195impl<'de> Deserialize<'de> for Credential {
196    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
197        #[derive(Deserialize)]
198        #[serde(rename_all = "snake_case")]
199        enum Kind {
200            ApiKey,
201            Header,
202        }
203
204        #[derive(Deserialize)]
205        #[serde(deny_unknown_fields)]
206        struct Wire {
207            kind: Kind,
208            #[serde(default)]
209            name: Option<String>,
210            #[serde(default)]
211            secret: Option<String>,
212        }
213
214        let wire = Wire::deserialize(deserializer)?;
215        let Some(secret) = wire.secret else {
216            return Err(D::Error::custom(
217                "a credential needs its `secret`; serialized credentials omit it",
218            ));
219        };
220        match (wire.kind, wire.name) {
221            (Kind::ApiKey, None) => Ok(Self::api_key(secret)),
222            (Kind::ApiKey, Some(_)) => Err(D::Error::custom(
223                "an `api_key` credential has no `name`; the adapter places it",
224            )),
225            (Kind::Header, Some(name)) => Ok(Self::header(name, secret)),
226            (Kind::Header, None) => Err(D::Error::custom("a `header` credential needs a `name`")),
227        }
228    }
229}
230
231/// The kind of a [`Credential`].
232///
233/// More kinds may follow (a bearer token, request signing keys). An adapter
234/// that places credentials itself refuses a kind it does not know with
235/// [`Credential::unsupported`].
236#[derive(Debug, Clone, PartialEq, Eq)]
237#[non_exhaustive]
238pub enum CredentialKind {
239    /// The provider's own API key ([`Credential::api_key`]).
240    ApiKey,
241    /// An authentication header with this name ([`Credential::header`]).
242    Header(String),
243}
244
245impl CredentialKind {
246    /// A short name for errors.
247    fn name(&self) -> &'static str {
248        match self {
249            Self::ApiKey => "`api_key`",
250            Self::Header(_) => "`header`",
251        }
252    }
253}
254
255/// Where an adapter takes its per-request credential from, and where it
256/// puts it: the adapter's side of a [`Credential`].
257///
258/// Every HTTP adapter declares one and passes it to
259/// [`HttpRequest::apply_options`], which then:
260///
261/// - places the [`Credential::api_key`] addressed to [`scope`](Self::scope)
262///   as [`placement`](Self::placement) says, removing the configured
263///   credential and every header in [`replaces`](Self::replaces);
264/// - adds the [`Credential::header`] values addressed to the scope;
265/// - ignores credentials addressed to any other scope;
266/// - refuses plain headers that would set a credential (see
267///   [`HttpRequest::apply_options`]).
268///
269/// The scope names an account, not a wire format: it is independent of the
270/// provider name and the namespaces, so telemetry identity never changes
271/// with it. Bundled adapters default it to their provider name and publish
272/// that default as `DEFAULT_CREDENTIAL_SCOPE`; their builders change it with
273/// `credential_scope`. Two accounts of one provider in a fallback chain need
274/// two scopes, and providers behind one gateway share one.
275///
276/// ```
277/// use allama_http::{CredentialPlacement, CredentialSlot};
278///
279/// // An API that takes its key in `x-nimbus-key`, and also accepts an
280/// // `Authorization` header that a tenant key must replace.
281/// let slot = CredentialSlot::new("nimbus", CredentialPlacement::Header("x-nimbus-key".into()))
282///     .replaces("authorization");
283/// assert_eq!(slot.header_name(), Some("x-nimbus-key"));
284/// ```
285#[derive(Debug, Clone, PartialEq, Eq)]
286#[non_exhaustive]
287pub struct CredentialSlot {
288    /// The credential scope: credentials addressed to it are this adapter's.
289    pub scope: Cow<'static, str>,
290    /// Where the scope's [`Credential::api_key`] goes.
291    pub placement: CredentialPlacement,
292    /// Configured authentication headers the credential replaces, besides
293    /// the placement's own header: for example Entra ID's `authorization`
294    /// on an endpoint whose keys go in `api-key`. Plain headers with these
295    /// names are refused, like the placement's own header.
296    pub replaces: Vec<Cow<'static, str>>,
297    /// Whether every request must carry a credential for the scope: set
298    /// when the adapter has no configured credential (bring your own key).
299    /// A request without one fails with an authored
300    /// [`allama::Error::Configuration`] before anything is sent.
301    pub required: bool,
302}
303
304impl CredentialSlot {
305    /// A slot for `scope`, placed as `placement`, replacing nothing else and
306    /// not required.
307    #[must_use]
308    pub fn new(scope: impl Into<Cow<'static, str>>, placement: CredentialPlacement) -> Self {
309        Self {
310            scope: scope.into(),
311            placement,
312            replaces: Vec::new(),
313            required: false,
314        }
315    }
316
317    /// Also replace the configured header `name` (see
318    /// [`replaces`](Self::replaces)).
319    #[must_use]
320    pub fn replaces(mut self, name: impl Into<Cow<'static, str>>) -> Self {
321        self.replaces.push(name.into());
322        self
323    }
324
325    /// Require a credential for the scope on every request (see
326    /// [`required`](Self::required)).
327    #[must_use]
328    pub fn required(mut self, required: bool) -> Self {
329        self.required = required;
330        self
331    }
332
333    /// The header the placement writes: `authorization` for
334    /// [`Bearer`](CredentialPlacement::Bearer), the name for
335    /// [`Header`](CredentialPlacement::Header), and `None` for
336    /// [`Custom`](CredentialPlacement::Custom).
337    #[must_use]
338    pub fn header_name(&self) -> Option<&str> {
339        match &self.placement {
340            CredentialPlacement::Bearer => Some("authorization"),
341            CredentialPlacement::Header(name) => Some(name),
342            CredentialPlacement::Custom => None,
343        }
344    }
345
346    /// Whether `name` is a header this slot owns: its placement's header or
347    /// one it replaces.
348    pub(crate) fn owns_header(&self, name: &str) -> bool {
349        self.header_name()
350            .is_some_and(|own| own.eq_ignore_ascii_case(name))
351            || self
352                .replaces
353                .iter()
354                .any(|replaced| replaced.eq_ignore_ascii_case(name))
355    }
356}
357
358/// Where an adapter puts its scope's [`Credential::api_key`].
359///
360/// There is no query-string placement: with `http-tracing`, the request URL,
361/// query included, is recorded on spans. An adapter whose API takes its key
362/// in the query uses [`Custom`](Self::Custom) and owns the redaction.
363#[derive(Debug, Clone, PartialEq, Eq)]
364#[non_exhaustive]
365pub enum CredentialPlacement {
366    /// `Authorization: Bearer <secret>`.
367    Bearer,
368    /// `<name>: <secret>`, such as `x-api-key`.
369    Header(Cow<'static, str>),
370    /// The adapter places the credential itself, for example to sign the
371    /// request. [`HttpRequest::apply_options`] leaves the scope's
372    /// [`Credential::api_key`] alone; the adapter reads it with
373    /// [`HttpRequestOptions::credential_for`](crate::HttpRequestOptions::credential_for)
374    /// and applies it after `apply_options`, which adds the plain headers a
375    /// signature covers. It refuses a kind it cannot place with
376    /// [`Credential::unsupported`].
377    Custom,
378}
379
380/// Returns `true` when `name` is a header that carries a credential by
381/// definition, so a plain header with that name is refused (see
382/// [`HttpRequest::apply_options`]).
383///
384/// The exact list, ignoring case: `authorization`, `proxy-authorization`,
385/// `x-api-key`, `api-key`, `x-goog-api-key`, `cookie`,
386/// `cf-aig-authorization`, `ocp-apim-subscription-key`,
387/// `x-amz-security-token`. Send these as a [`Credential`].
388///
389/// Redaction is broader: [`is_sensitive_header`](crate::is_sensitive_header)
390/// also matches names that merely contain `key`, `token`, `secret` or
391/// `auth`, which are redacted but not refused, so `Idempotency-Key` passes.
392///
393/// ```
394/// use allama_http::is_credential_header;
395///
396/// assert!(is_credential_header("Authorization"));
397/// assert!(is_credential_header("cf-aig-authorization"));
398/// assert!(!is_credential_header("Idempotency-Key"));
399/// ```
400#[must_use]
401pub fn is_credential_header(name: &str) -> bool {
402    CREDENTIAL_HEADERS
403        .iter()
404        .any(|credential| credential.eq_ignore_ascii_case(name))
405}
406
407/// The headers [`is_credential_header`] matches.
408pub(crate) const CREDENTIAL_HEADERS: [&str; 9] = [
409    "authorization",
410    "proxy-authorization",
411    "x-api-key",
412    "api-key",
413    "x-goog-api-key",
414    "cookie",
415    "cf-aig-authorization",
416    "ocp-apim-subscription-key",
417    "x-amz-security-token",
418];
419
420#[cfg(all(test, not(target_arch = "wasm32")))]
421mod tests {
422    use serde_json::json;
423
424    use super::*;
425
426    #[test]
427    fn debug_and_serialization_never_show_the_secret() {
428        for credential in [
429            Credential::api_key("sk-SECRET"),
430            Credential::header("cf-aig-authorization", "Bearer SECRET"),
431        ] {
432            let debug = format!("{credential:?}");
433            assert!(!debug.contains("SECRET"), "{debug}");
434            assert!(debug.contains("<redacted>"), "{debug}");
435            let json = serde_json::to_string(&credential).unwrap();
436            assert!(!json.contains("SECRET"), "{json}");
437            let error = credential.unsupported("tenant").to_string();
438            assert!(!error.contains("SECRET"), "{error}");
439            assert!(error.contains("`tenant`"), "{error}");
440        }
441        assert_eq!(
442            serde_json::to_value(Credential::header("x-gw", "SECRET")).unwrap(),
443            json!({"kind": "header", "name": "x-gw"})
444        );
445    }
446
447    #[test]
448    fn deserialization_reads_the_secret_and_refuses_a_serialized_credential() {
449        let key: Credential =
450            serde_json::from_value(json!({"kind": "api_key", "secret": "k"})).unwrap();
451        assert_eq!(key.kind(), &CredentialKind::ApiKey);
452        assert_eq!(key.expose_secret(), "k");
453        let header: Credential =
454            serde_json::from_value(json!({"kind": "header", "name": "x-gw", "secret": "v"}))
455                .unwrap();
456        assert_eq!(header.kind(), &CredentialKind::Header("x-gw".into()));
457        assert_eq!(header.expose_secret(), "v");
458
459        let exported = serde_json::to_value(&key).unwrap();
460        let err = serde_json::from_value::<Credential>(exported).unwrap_err();
461        assert!(err.to_string().contains("`secret`"), "{err}");
462        for invalid in [
463            json!({"kind": "header", "secret": "v"}),
464            json!({"kind": "api_key", "name": "x", "secret": "v"}),
465            json!({"kind": "bearer", "secret": "v"}),
466            json!({"kind": "api_key", "secret": "v", "scope": "s"}),
467        ] {
468            assert!(
469                serde_json::from_value::<Credential>(invalid.clone()).is_err(),
470                "{invalid}"
471            );
472        }
473    }
474
475    #[test]
476    fn slots_own_their_placement_header_and_replaced_headers() {
477        let bearer = CredentialSlot::new("openai", CredentialPlacement::Bearer);
478        assert_eq!(bearer.header_name(), Some("authorization"));
479        assert!(bearer.owns_header("Authorization"));
480        assert!(!bearer.owns_header("api-key"));
481        assert!(!bearer.required);
482
483        let azure = CredentialSlot::new("azure", CredentialPlacement::Header("api-key".into()))
484            .replaces("authorization")
485            .required(true);
486        assert!(azure.owns_header("API-KEY"));
487        assert!(azure.owns_header("authorization"));
488        assert!(azure.required);
489
490        let custom = CredentialSlot::new("signed", CredentialPlacement::Custom);
491        assert_eq!(custom.header_name(), None);
492        assert!(!custom.owns_header("authorization"));
493    }
494
495    #[test]
496    fn the_credential_list_is_exact() {
497        for name in CREDENTIAL_HEADERS {
498            assert!(is_credential_header(&name.to_ascii_uppercase()), "{name}");
499            assert!(crate::is_sensitive_header(name), "{name}");
500        }
501        for name in [
502            "idempotency-key",
503            "x-nimbus-key",
504            "x-gateway-token",
505            "set-cookie",
506        ] {
507            assert!(!is_credential_header(name), "{name}");
508        }
509    }
510}