use std::borrow::Cow;
use std::fmt;
use serde::de::Error as _;
use serde::ser::SerializeStruct as _;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[cfg(doc)]
use crate::HttpRequest;
#[derive(Clone)]
pub struct Credential {
kind: CredentialKind,
secret: String,
}
impl Credential {
#[must_use]
pub fn api_key(secret: impl Into<String>) -> Self {
Self {
kind: CredentialKind::ApiKey,
secret: secret.into(),
}
}
#[must_use]
pub fn header(name: impl Into<String>, secret: impl Into<String>) -> Self {
Self {
kind: CredentialKind::Header(name.into()),
secret: secret.into(),
}
}
#[must_use]
pub fn kind(&self) -> &CredentialKind {
&self.kind
}
#[must_use]
pub fn expose_secret(&self) -> &str {
&self.secret
}
#[must_use]
pub fn unsupported(&self, scope: &str) -> allama::Error {
allama::Error::configuration(format!(
"a {} credential addressed to credential scope `{scope}` cannot be sent by this \
provider",
self.kind.name()
))
.authored()
}
pub(crate) fn is_placed_by_adapter(&self) -> bool {
!matches!(self.kind, CredentialKind::Header(_))
}
}
impl fmt::Debug for Credential {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Credential")
.field("kind", &self.kind)
.field("secret", &format_args!("<redacted>"))
.finish()
}
}
impl Serialize for Credential {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
match &self.kind {
CredentialKind::ApiKey => {
let mut state = serializer.serialize_struct("Credential", 1)?;
state.serialize_field("kind", "api_key")?;
state.end()
}
CredentialKind::Header(name) => {
let mut state = serializer.serialize_struct("Credential", 2)?;
state.serialize_field("kind", "header")?;
state.serialize_field("name", name)?;
state.end()
}
}
}
}
impl<'de> Deserialize<'de> for Credential {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
#[derive(Deserialize)]
#[serde(rename_all = "snake_case")]
enum Kind {
ApiKey,
Header,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Wire {
kind: Kind,
#[serde(default)]
name: Option<String>,
#[serde(default)]
secret: Option<String>,
}
let wire = Wire::deserialize(deserializer)?;
let Some(secret) = wire.secret else {
return Err(D::Error::custom(
"a credential needs its `secret`; serialized credentials omit it",
));
};
match (wire.kind, wire.name) {
(Kind::ApiKey, None) => Ok(Self::api_key(secret)),
(Kind::ApiKey, Some(_)) => Err(D::Error::custom(
"an `api_key` credential has no `name`; the adapter places it",
)),
(Kind::Header, Some(name)) => Ok(Self::header(name, secret)),
(Kind::Header, None) => Err(D::Error::custom("a `header` credential needs a `name`")),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CredentialKind {
ApiKey,
Header(String),
}
impl CredentialKind {
fn name(&self) -> &'static str {
match self {
Self::ApiKey => "`api_key`",
Self::Header(_) => "`header`",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct CredentialSlot {
pub scope: Cow<'static, str>,
pub placement: CredentialPlacement,
pub replaces: Vec<Cow<'static, str>>,
pub required: bool,
}
impl CredentialSlot {
#[must_use]
pub fn new(scope: impl Into<Cow<'static, str>>, placement: CredentialPlacement) -> Self {
Self {
scope: scope.into(),
placement,
replaces: Vec::new(),
required: false,
}
}
#[must_use]
pub fn replaces(mut self, name: impl Into<Cow<'static, str>>) -> Self {
self.replaces.push(name.into());
self
}
#[must_use]
pub fn required(mut self, required: bool) -> Self {
self.required = required;
self
}
#[must_use]
pub fn header_name(&self) -> Option<&str> {
match &self.placement {
CredentialPlacement::Bearer => Some("authorization"),
CredentialPlacement::Header(name) => Some(name),
CredentialPlacement::Custom => None,
}
}
pub(crate) fn owns_header(&self, name: &str) -> bool {
self.header_name()
.is_some_and(|own| own.eq_ignore_ascii_case(name))
|| self
.replaces
.iter()
.any(|replaced| replaced.eq_ignore_ascii_case(name))
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum CredentialPlacement {
Bearer,
Header(Cow<'static, str>),
Custom,
}
#[must_use]
pub fn is_credential_header(name: &str) -> bool {
CREDENTIAL_HEADERS
.iter()
.any(|credential| credential.eq_ignore_ascii_case(name))
}
pub(crate) const CREDENTIAL_HEADERS: [&str; 9] = [
"authorization",
"proxy-authorization",
"x-api-key",
"api-key",
"x-goog-api-key",
"cookie",
"cf-aig-authorization",
"ocp-apim-subscription-key",
"x-amz-security-token",
];
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn debug_and_serialization_never_show_the_secret() {
for credential in [
Credential::api_key("sk-SECRET"),
Credential::header("cf-aig-authorization", "Bearer SECRET"),
] {
let debug = format!("{credential:?}");
assert!(!debug.contains("SECRET"), "{debug}");
assert!(debug.contains("<redacted>"), "{debug}");
let json = serde_json::to_string(&credential).unwrap();
assert!(!json.contains("SECRET"), "{json}");
let error = credential.unsupported("tenant").to_string();
assert!(!error.contains("SECRET"), "{error}");
assert!(error.contains("`tenant`"), "{error}");
}
assert_eq!(
serde_json::to_value(Credential::header("x-gw", "SECRET")).unwrap(),
json!({"kind": "header", "name": "x-gw"})
);
}
#[test]
fn deserialization_reads_the_secret_and_refuses_a_serialized_credential() {
let key: Credential =
serde_json::from_value(json!({"kind": "api_key", "secret": "k"})).unwrap();
assert_eq!(key.kind(), &CredentialKind::ApiKey);
assert_eq!(key.expose_secret(), "k");
let header: Credential =
serde_json::from_value(json!({"kind": "header", "name": "x-gw", "secret": "v"}))
.unwrap();
assert_eq!(header.kind(), &CredentialKind::Header("x-gw".into()));
assert_eq!(header.expose_secret(), "v");
let exported = serde_json::to_value(&key).unwrap();
let err = serde_json::from_value::<Credential>(exported).unwrap_err();
assert!(err.to_string().contains("`secret`"), "{err}");
for invalid in [
json!({"kind": "header", "secret": "v"}),
json!({"kind": "api_key", "name": "x", "secret": "v"}),
json!({"kind": "bearer", "secret": "v"}),
json!({"kind": "api_key", "secret": "v", "scope": "s"}),
] {
assert!(
serde_json::from_value::<Credential>(invalid.clone()).is_err(),
"{invalid}"
);
}
}
#[test]
fn slots_own_their_placement_header_and_replaced_headers() {
let bearer = CredentialSlot::new("openai", CredentialPlacement::Bearer);
assert_eq!(bearer.header_name(), Some("authorization"));
assert!(bearer.owns_header("Authorization"));
assert!(!bearer.owns_header("api-key"));
assert!(!bearer.required);
let azure = CredentialSlot::new("azure", CredentialPlacement::Header("api-key".into()))
.replaces("authorization")
.required(true);
assert!(azure.owns_header("API-KEY"));
assert!(azure.owns_header("authorization"));
assert!(azure.required);
let custom = CredentialSlot::new("signed", CredentialPlacement::Custom);
assert_eq!(custom.header_name(), None);
assert!(!custom.owns_header("authorization"));
}
#[test]
fn the_credential_list_is_exact() {
for name in CREDENTIAL_HEADERS {
assert!(is_credential_header(&name.to_ascii_uppercase()), "{name}");
assert!(crate::is_sensitive_header(name), "{name}");
}
for name in [
"idempotency-key",
"x-nimbus-key",
"x-gateway-token",
"set-cookie",
] {
assert!(!is_credential_header(name), "{name}");
}
}
}