#![deny(missing_docs)]
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
pub use authentication_storage::{authentication::Authentication, storage::AuthenticationStorage};
use reqwest::{Client, IntoUrl, Method, Url};
pub mod authentication_storage;
pub mod retry_policies;
mod redaction;
pub use redaction::{
redact_known_secrets_from_error, redact_known_secrets_from_url, DEFAULT_REDACTION_STR,
};
#[derive(Clone, Default)]
pub struct AuthenticatedClient {
client: Client,
auth_storage: AuthenticationStorage,
}
pub fn default_auth_store_fallback_directory() -> &'static Path {
static FALLBACK_AUTH_DIR: OnceLock<PathBuf> = OnceLock::new();
FALLBACK_AUTH_DIR.get_or_init(|| {
dirs::home_dir()
.map_or_else(|| {
tracing::warn!("using '/rattler' to store fallback authentication credentials because the home directory could not be found");
PathBuf::from("/rattler/")
}, |home| home.join(".rattler/"))
})
}
impl AuthenticatedClient {
pub fn from_client(client: Client, auth_storage: AuthenticationStorage) -> AuthenticatedClient {
AuthenticatedClient {
client,
auth_storage,
}
}
}
impl AuthenticatedClient {
pub fn get<U: IntoUrl>(&self, url: U) -> reqwest::RequestBuilder {
self.request(Method::GET, url)
}
pub fn post<U: IntoUrl>(&self, url: U) -> reqwest::RequestBuilder {
self.request(Method::POST, url)
}
pub fn head<U: IntoUrl>(&self, url: U) -> reqwest::RequestBuilder {
self.request(Method::HEAD, url)
}
pub fn request<U: IntoUrl>(&self, method: Method, url: U) -> reqwest::RequestBuilder {
let url_clone = url.as_str().to_string();
match self.auth_storage.get_by_url(url) {
Err(_) => {
self.client.request(method, url_clone)
}
Ok((url, auth)) => {
let url = Self::authenticate_url(url, &auth);
let request_builder = self.client.request(method, url);
Self::authenticate_request(request_builder, &auth)
}
}
}
fn authenticate_url(url: Url, auth: &Option<Authentication>) -> Url {
if let Some(credentials) = auth {
match credentials {
Authentication::CondaToken(token) => {
let path = url.path();
let mut new_path = String::new();
new_path.push_str(format!("/t/{token}").as_str());
new_path.push_str(path);
let mut url = url.clone();
url.set_path(&new_path);
url
}
_ => url,
}
} else {
url
}
}
fn authenticate_request(
builder: reqwest::RequestBuilder,
auth: &Option<Authentication>,
) -> reqwest::RequestBuilder {
if let Some(credentials) = auth {
match credentials {
Authentication::BearerToken(token) => builder.bearer_auth(token),
Authentication::BasicHTTP { username, password } => {
builder.basic_auth(username, Some(password))
}
Authentication::CondaToken(_) => builder,
}
} else {
builder
}
}
}
#[cfg(feature = "blocking")]
#[derive(Default)]
pub struct AuthenticatedClientBlocking {
client: reqwest::blocking::Client,
auth_storage: AuthenticationStorage,
}
#[cfg(feature = "blocking")]
impl AuthenticatedClientBlocking {
pub fn from_client(
client: reqwest::blocking::Client,
auth_storage: AuthenticationStorage,
) -> AuthenticatedClientBlocking {
AuthenticatedClientBlocking {
client,
auth_storage,
}
}
}
#[cfg(feature = "blocking")]
impl AuthenticatedClientBlocking {
pub fn get<U: IntoUrl>(&self, url: U) -> reqwest::blocking::RequestBuilder {
self.request(Method::GET, url)
}
pub fn post<U: IntoUrl>(&self, url: U) -> reqwest::blocking::RequestBuilder {
self.request(Method::POST, url)
}
pub fn head<U: IntoUrl>(&self, url: U) -> reqwest::blocking::RequestBuilder {
self.request(Method::HEAD, url)
}
pub fn request<U: IntoUrl>(&self, method: Method, url: U) -> reqwest::blocking::RequestBuilder {
let url_clone = url.as_str().to_string();
match self.auth_storage.get_by_url(url) {
Err(_) => {
self.client.request(method, url_clone)
}
Ok((url, auth)) => {
let url = Self::authenticate_url(url, &auth);
let request_builder = self.client.request(method, url);
Self::authenticate_request(request_builder, &auth)
}
}
}
fn authenticate_url(url: Url, auth: &Option<Authentication>) -> Url {
if let Some(credentials) = auth {
match credentials {
Authentication::CondaToken(token) => {
let path = url.path();
let mut new_path = String::new();
new_path.push_str(format!("/t/{token}").as_str());
new_path.push_str(path);
let mut url = url.clone();
url.set_path(&new_path);
url
}
_ => url,
}
} else {
url
}
}
fn authenticate_request(
builder: reqwest::blocking::RequestBuilder,
auth: &Option<Authentication>,
) -> reqwest::blocking::RequestBuilder {
if let Some(credentials) = auth {
match credentials {
Authentication::BearerToken(token) => builder.bearer_auth(token),
Authentication::BasicHTTP { username, password } => {
builder.basic_auth(username, Some(password))
}
Authentication::CondaToken(_) => builder,
}
} else {
builder
}
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::authentication_storage::backends::file::FileStorage;
use super::*;
use tempfile::tempdir;
#[test]
fn test_store_fallback() -> anyhow::Result<()> {
let tdir = tempdir()?;
let mut storage = AuthenticationStorage::new();
storage.add_backend(Arc::from(FileStorage::new(
tdir.path().to_path_buf().join("auth.json"),
)));
let host = "test.example.com";
let authentication = Authentication::CondaToken("testtoken".to_string());
storage.store(host, &authentication)?;
storage.delete(host)?;
Ok(())
}
#[test]
fn test_conda_token_storage() -> anyhow::Result<()> {
let tdir = tempdir()?;
let mut storage = AuthenticationStorage::new();
storage.add_backend(Arc::from(FileStorage::new(
tdir.path().to_path_buf().join("auth.json"),
)));
let host = "conda.example.com";
if let Ok(entry) = keyring::Entry::new("rattler_test", host) {
let _ = entry.delete_password();
}
let retrieved = storage.get(host);
if let Err(e) = retrieved.as_ref() {
println!("{e:?}");
}
assert!(retrieved.is_ok());
assert!(retrieved.unwrap().is_none());
let authentication = Authentication::CondaToken("testtoken".to_string());
insta::assert_json_snapshot!(authentication);
storage.store(host, &authentication)?;
let retrieved = storage.get(host);
assert!(retrieved.is_ok());
let retrieved = retrieved.unwrap();
assert!(retrieved.is_some());
let auth = retrieved.unwrap();
assert!(auth == authentication);
let client = AuthenticatedClient::from_client(reqwest::Client::default(), storage.clone());
let request = client.get("https://conda.example.com/conda-forge/noarch/testpkg.tar.bz2");
let request = request.build().unwrap();
let url = request.url();
assert!(url.path().starts_with("/t/testtoken"));
storage.delete(host)?;
Ok(())
}
#[test]
fn test_bearer_storage() -> anyhow::Result<()> {
let tdir = tempdir()?;
let mut storage = AuthenticationStorage::new();
storage.add_backend(Arc::from(FileStorage::new(
tdir.path().to_path_buf().join("auth.json"),
)));
let host = "bearer.example.com";
if let Ok(entry) = keyring::Entry::new("rattler_test", host) {
let _ = entry.delete_password();
}
let retrieved = storage.get(host);
if let Err(e) = retrieved.as_ref() {
println!("{e:?}");
}
assert!(retrieved.is_ok());
assert!(retrieved.unwrap().is_none());
let authentication = Authentication::BearerToken("xyztokytoken".to_string());
insta::assert_json_snapshot!(authentication);
storage.store(host, &authentication)?;
let retrieved = storage.get(host);
assert!(retrieved.is_ok());
let retrieved = retrieved.unwrap();
assert!(retrieved.is_some());
let auth = retrieved.unwrap();
assert!(auth == authentication);
let client = AuthenticatedClient::from_client(reqwest::Client::default(), storage.clone());
let request = client.get("https://bearer.example.com/conda-forge/noarch/testpkg.tar.bz2");
let request = request.build().unwrap();
let url = request.url();
assert!(url.to_string() == "https://bearer.example.com/conda-forge/noarch/testpkg.tar.bz2");
assert_eq!(
request.headers().get("Authorization").unwrap(),
"Bearer xyztokytoken"
);
storage.delete(host)?;
Ok(())
}
#[test]
fn test_basic_auth_storage() -> anyhow::Result<()> {
let tdir = tempdir()?;
let mut storage = AuthenticationStorage::new();
storage.add_backend(Arc::from(FileStorage::new(
tdir.path().to_path_buf().join("auth.json"),
)));
let host = "basic.example.com";
if let Ok(entry) = keyring::Entry::new("rattler_test", host) {
let _ = entry.delete_password();
}
let retrieved = storage.get(host);
if let Err(e) = retrieved.as_ref() {
println!("{e:?}");
}
assert!(retrieved.is_ok());
assert!(retrieved.unwrap().is_none());
let authentication = Authentication::BasicHTTP {
username: "testuser".to_string(),
password: "testpassword".to_string(),
};
insta::assert_json_snapshot!(authentication);
storage.store(host, &authentication)?;
let retrieved = storage.get(host);
assert!(retrieved.is_ok());
let retrieved = retrieved.unwrap();
assert!(retrieved.is_some());
let auth = retrieved.unwrap();
assert!(auth == authentication);
let client = AuthenticatedClient::from_client(reqwest::Client::default(), storage.clone());
let request = client.get("https://basic.example.com/conda-forge/noarch/testpkg.tar.bz2");
let request = request.build().unwrap();
let url = request.url();
assert!(url.to_string() == "https://basic.example.com/conda-forge/noarch/testpkg.tar.bz2");
assert_eq!(
request.headers().get("Authorization").unwrap(),
"Basic dGVzdHVzZXI6dGVzdHBhc3N3b3Jk"
);
storage.delete(host)?;
Ok(())
}
#[test]
fn test_host_wildcard_expansion() -> anyhow::Result<()> {
for (host, should_succeed) in [
("repo.prefix.dev", true),
("*.repo.prefix.dev", true),
("*.prefix.dev", true),
("*.dev", true),
("repo.notprefix.dev", false),
("*.repo.notprefix.dev", false),
("*.notprefix.dev", false),
("*.com", false),
] {
let tdir = tempdir()?;
let mut storage = AuthenticationStorage::new();
storage.add_backend(Arc::from(FileStorage::new(
tdir.path().to_path_buf().join("auth.json"),
)));
let authentication = Authentication::BearerToken("testtoken".to_string());
storage.store(host, &authentication)?;
let retrieved =
storage.get_by_url("https://repo.prefix.dev/conda-forge/noarch/repodata.json")?;
if should_succeed {
assert_eq!(retrieved.1, Some(authentication));
} else {
assert_eq!(retrieved.1, None);
}
}
Ok(())
}
}