use std::fmt;
use base64::prelude::{BASE64_STANDARD, Engine as _};
use reqwest::header::{AUTHORIZATION, HeaderMap, HeaderValue};
use crate::error::{Error, Result};
pub const API_KEY_ENV: &str = "APCA_API_KEY_ID";
pub const SECRET_KEY_ENV: &str = "APCA_API_SECRET_KEY";
const KEY_HEADER: &str = "APCA-API-KEY-ID";
const SECRET_HEADER: &str = "APCA-API-SECRET-KEY";
#[derive(Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum Credentials {
KeyPair {
api_key: String,
secret_key: String,
},
Basic {
api_key: String,
secret_key: String,
},
OAuth {
token: String,
},
}
impl Credentials {
pub fn new(api_key: impl Into<String>, secret_key: impl Into<String>) -> Result<Self> {
let (api_key, secret_key) = (api_key.into(), secret_key.into());
Self::reject_empty(&api_key, "api_key")?;
Self::reject_empty(&secret_key, "secret_key")?;
Ok(Self::KeyPair {
api_key,
secret_key,
})
}
pub fn basic(api_key: impl Into<String>, secret_key: impl Into<String>) -> Result<Self> {
let (api_key, secret_key) = (api_key.into(), secret_key.into());
Self::reject_empty(&api_key, "api_key")?;
Self::reject_empty(&secret_key, "secret_key")?;
Ok(Self::Basic {
api_key,
secret_key,
})
}
pub fn oauth(token: impl Into<String>) -> Result<Self> {
let token = token.into();
Self::reject_empty(&token, "oauth token")?;
Ok(Self::OAuth { token })
}
pub fn from_env() -> Result<Self> {
let api_key = std::env::var(API_KEY_ENV)
.map_err(|_| Error::Credentials(format!("{API_KEY_ENV} is not set")))?;
let secret_key = std::env::var(SECRET_KEY_ENV)
.map_err(|_| Error::Credentials(format!("{SECRET_KEY_ENV} is not set")))?;
Self::new(api_key, secret_key)
}
#[must_use]
pub fn into_basic(self) -> Self {
match self {
Self::KeyPair {
api_key,
secret_key,
} => Self::Basic {
api_key,
secret_key,
},
other => other,
}
}
pub(crate) fn apply(&self, headers: &mut HeaderMap) -> Result<()> {
match self {
Self::KeyPair {
api_key,
secret_key,
} => {
headers.insert(KEY_HEADER, Self::header_value(api_key, "api_key")?);
headers.insert(SECRET_HEADER, Self::header_value(secret_key, "secret_key")?);
}
Self::Basic {
api_key,
secret_key,
} => {
let encoded = BASE64_STANDARD.encode(format!("{api_key}:{secret_key}"));
headers.insert(
AUTHORIZATION,
Self::header_value(&format!("Basic {encoded}"), "basic auth")?,
);
}
Self::OAuth { token } => {
headers.insert(
AUTHORIZATION,
Self::header_value(&format!("Bearer {token}"), "oauth token")?,
);
}
}
Ok(())
}
fn header_value(value: &str, name: &str) -> Result<HeaderValue> {
let mut header = HeaderValue::from_str(value)
.map_err(|_| Error::Credentials(format!("{name} contains invalid header bytes")))?;
header.set_sensitive(true);
Ok(header)
}
fn reject_empty(value: &str, name: &str) -> Result<()> {
if value.is_empty() {
return Err(Error::Credentials(format!("{name} must not be empty")));
}
Ok(())
}
}
impl fmt::Debug for Credentials {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let variant = match self {
Self::KeyPair { .. } => "KeyPair",
Self::Basic { .. } => "Basic",
Self::OAuth { .. } => "OAuth",
};
f.debug_struct("Credentials")
.field("kind", &variant)
.field("secrets", &"<redacted>")
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_pair_sets_apca_headers() {
let mut headers = HeaderMap::new();
Credentials::new("key", "secret")
.unwrap()
.apply(&mut headers)
.unwrap();
assert_eq!(headers[KEY_HEADER], "key");
assert_eq!(headers[SECRET_HEADER], "secret");
assert!(!headers.contains_key(AUTHORIZATION));
}
#[test]
fn basic_auth_is_the_base64_of_key_colon_secret() {
let mut headers = HeaderMap::new();
Credentials::basic("key", "secret")
.unwrap()
.apply(&mut headers)
.unwrap();
assert_eq!(headers[AUTHORIZATION], "Basic a2V5OnNlY3JldA==");
}
#[test]
fn oauth_sets_bearer_and_omits_key_headers() {
let mut headers = HeaderMap::new();
Credentials::oauth("tok")
.unwrap()
.apply(&mut headers)
.unwrap();
assert_eq!(headers[AUTHORIZATION], "Bearer tok");
assert!(!headers.contains_key(KEY_HEADER));
}
#[test]
fn empty_values_are_rejected() {
assert!(Credentials::new("", "secret").is_err());
assert!(Credentials::new("key", "").is_err());
assert!(Credentials::oauth("").is_err());
}
#[test]
fn debug_does_not_leak_secrets() {
let creds = Credentials::new("AKREALKEY", "supersecret").unwrap();
let rendered = format!("{creds:?}");
assert!(!rendered.contains("AKREALKEY"));
assert!(!rendered.contains("supersecret"));
assert!(rendered.contains("redacted"));
}
#[test]
fn auth_headers_are_marked_sensitive() {
let mut headers = HeaderMap::new();
Credentials::new("key", "secret")
.unwrap()
.apply(&mut headers)
.unwrap();
assert!(headers[KEY_HEADER].is_sensitive());
assert!(headers[SECRET_HEADER].is_sensitive());
}
#[test]
fn a_credential_carrying_header_bytes_is_refused_not_injected() {
let injected = "key\r\nX-Injected: 1";
for creds in [
Credentials::new(injected, "secret").unwrap(),
Credentials::new("key", injected).unwrap(),
Credentials::oauth(injected).unwrap(),
] {
let mut headers = HeaderMap::new();
let err = creds.apply(&mut headers).unwrap_err();
assert!(matches!(err, Error::Credentials(_)), "{err:?}");
assert!(
!headers.contains_key("x-injected"),
"the injected header reached the map"
);
}
}
#[test]
fn basic_auth_encodes_away_bytes_the_others_must_reject() {
let mut headers = HeaderMap::new();
Credentials::basic("key\r\nX-Injected: 1", "secret")
.unwrap()
.apply(&mut headers)
.unwrap();
assert!(!headers.contains_key("x-injected"));
assert!(
headers[AUTHORIZATION]
.to_str()
.unwrap()
.starts_with("Basic ")
);
}
#[test]
fn into_basic_converts_key_pair_only() {
let pair = Credentials::new("key", "secret").unwrap();
assert!(matches!(pair.into_basic(), Credentials::Basic { .. }));
let oauth = Credentials::oauth("tok").unwrap();
assert!(matches!(oauth.into_basic(), Credentials::OAuth { .. }));
}
}