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}