mirror of https://github.com/astral-sh/uv
Fix authentication for URLs with a shared realm (#3130)
In #2976 I made some changes that led to regressions: - We stopped tracking URLs that we had not seen credentials for in the cache - This means the cache no longer returns a value to indicate we've seen a realm before - We stopped seeding the cache with URLs - Combined with the above, this means we no longer had a list of locations that we would never attempt to fetch credentials for - We added caching of credentials found on requests - Previously the cache was only populated from the seed or credentials found in the netrc or keyring - This meant that the cache was populated for locations that we previously did not cache, i.e. GitHub artifacts(?) Unfortunately this unveiled problems with the granularity of our cache. We cache credentials per realm (roughly the hostname) but some realms have mixed authentication modes i.e. different credentials per URL or URLs that do not require credentials. Applying credentials to a URL that does not require it can lead to a failed request, as seen in #3123 where GitHub throws a 401 when receiving credentials. To resolve this, the cache is expanded to supporting caching at two levels: - URL, cached URL must be a prefix of the request URL - Realm, exact match required When we don't have URL-level credentials cached, we attempt the request without authentication first. On failure, we'll search for realm-level credentials or fetch credentials from external services. This avoids providing credentials to new URLs unless we know we need them. Closes https://github.com/astral-sh/uv/issues/3123
This commit is contained in:
parent
41b29b2dc4
commit
f98eca8843
|
|
@ -4466,6 +4466,7 @@ dependencies = [
|
||||||
name = "uv-auth"
|
name = "uv-auth"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.0",
|
"base64 0.22.0",
|
||||||
"http",
|
"http",
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ version = "0.0.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
anyhow = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
http = { workspace = true }
|
http = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -1,43 +1,17 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::{collections::HashMap, sync::Mutex};
|
use std::{collections::HashMap, sync::Mutex};
|
||||||
|
|
||||||
use crate::credentials::Credentials;
|
use crate::credentials::{Credentials, Username};
|
||||||
use crate::NetLoc;
|
use crate::Realm;
|
||||||
|
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
type CacheKey = (NetLoc, Option<String>);
|
|
||||||
|
|
||||||
pub struct CredentialsCache {
|
pub struct CredentialsCache {
|
||||||
store: Mutex<HashMap<CacheKey, Arc<Credentials>>>,
|
/// A cache per realm and username
|
||||||
}
|
realms: Mutex<HashMap<(Realm, Username), Arc<Credentials>>>,
|
||||||
|
/// A cache per URL, uses a trie for efficient prefix queries.
|
||||||
#[derive(Debug, Clone)]
|
urls: Mutex<UrlTrie>,
|
||||||
pub enum CheckResponse {
|
|
||||||
/// The given credentials should be used and are not present in the cache.
|
|
||||||
Uncached(Arc<Credentials>),
|
|
||||||
/// Credentials were found in the cache.
|
|
||||||
Cached(Arc<Credentials>),
|
|
||||||
// Credentials were not found in the cache and none were provided.
|
|
||||||
None,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CheckResponse {
|
|
||||||
/// Retrieve the credentials, if any.
|
|
||||||
pub fn get(&self) -> Option<&Credentials> {
|
|
||||||
match self {
|
|
||||||
Self::Cached(credentials) => Some(credentials.as_ref()),
|
|
||||||
Self::Uncached(credentials) => Some(credentials.as_ref()),
|
|
||||||
Self::None => None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns true if there are credentials with a password.
|
|
||||||
pub fn is_authenticated(&self) -> bool {
|
|
||||||
self.get()
|
|
||||||
.is_some_and(|credentials| credentials.password().is_some())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for CredentialsCache {
|
impl Default for CredentialsCache {
|
||||||
|
|
@ -50,84 +24,48 @@ impl CredentialsCache {
|
||||||
/// Create a new cache.
|
/// Create a new cache.
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
store: Mutex::new(HashMap::new()),
|
realms: Mutex::new(HashMap::new()),
|
||||||
|
urls: Mutex::new(UrlTrie::new()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an owned cache key.
|
/// Return the credentials that should be used for a realm and username, if any.
|
||||||
fn key(url: &Url, username: Option<String>) -> CacheKey {
|
pub(crate) fn get_realm(&self, realm: Realm, username: Username) -> Option<Arc<Credentials>> {
|
||||||
(NetLoc::from(url), username)
|
let realms = self.realms.lock().unwrap();
|
||||||
}
|
let name = if let Some(username) = username.as_deref() {
|
||||||
|
format!("{username}@{realm}")
|
||||||
/// Return the credentials that should be used for a URL, if any.
|
|
||||||
///
|
|
||||||
/// The [`Url`] is not checked for credentials. Existing credentials should be extracted and passed
|
|
||||||
/// separately.
|
|
||||||
///
|
|
||||||
/// If complete credentials are provided, they will be returned as [`CheckResponse::Existing`]
|
|
||||||
/// If the credentials are partial, i.e. missing a password, the cache will be checked
|
|
||||||
/// for a corresponding entry.
|
|
||||||
pub(crate) fn check(&self, url: &Url, credentials: Option<Credentials>) -> CheckResponse {
|
|
||||||
let store = self.store.lock().unwrap();
|
|
||||||
|
|
||||||
let credentials = credentials.map(Arc::new);
|
|
||||||
let key = CredentialsCache::key(
|
|
||||||
url,
|
|
||||||
credentials
|
|
||||||
.as_ref()
|
|
||||||
.and_then(|credentials| credentials.username().map(str::to_string)),
|
|
||||||
);
|
|
||||||
|
|
||||||
if let Some(credentials) = credentials {
|
|
||||||
if credentials.password().is_some() {
|
|
||||||
trace!("Existing credentials include password, skipping cache");
|
|
||||||
// No need to look-up, we have a password already
|
|
||||||
return CheckResponse::Uncached(credentials);
|
|
||||||
}
|
|
||||||
trace!("Existing credentials missing password, checking cache");
|
|
||||||
let existing = store.get(&key);
|
|
||||||
existing
|
|
||||||
.cloned()
|
|
||||||
.map(CheckResponse::Cached)
|
|
||||||
.inspect(|_| trace!("Found cached credentials."))
|
|
||||||
.unwrap_or_else(|| {
|
|
||||||
trace!("No credentials in cache, using existing credentials");
|
|
||||||
CheckResponse::Uncached(credentials)
|
|
||||||
})
|
|
||||||
} else {
|
} else {
|
||||||
trace!("No credentials on request, checking cache...");
|
realm.to_string()
|
||||||
store
|
};
|
||||||
|
let key = (realm, username);
|
||||||
|
|
||||||
|
realms
|
||||||
.get(&key)
|
.get(&key)
|
||||||
.cloned()
|
.cloned()
|
||||||
.map(CheckResponse::Cached)
|
.map(Some)
|
||||||
.inspect(|_| trace!("Found cached credentials."))
|
.inspect(|_| trace!("Found cached credentials for realm {name}"))
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
trace!("No credentials in cache.");
|
trace!("No credentials in cache for realm {name}");
|
||||||
CheckResponse::None
|
None
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Update the cache with the given credentials if none exist.
|
/// Return the cached credentials for a URL and username, if any.
|
||||||
pub(crate) fn set_default(&self, url: &Url, credentials: Arc<Credentials>) {
|
///
|
||||||
// Do not cache empty credentials
|
/// Note we do not cache per username, but if a username is passed we will confirm that the
|
||||||
if credentials.is_empty() {
|
/// cached credentials have a username equal to the provided one — otherwise `None` is returned.
|
||||||
return;
|
/// If multiple usernames are used per URL, the realm cache should be queried instead.
|
||||||
}
|
pub(crate) fn get_url(&self, url: &Url, username: Username) -> Option<Arc<Credentials>> {
|
||||||
|
let urls = self.urls.lock().unwrap();
|
||||||
// Insert an entry for requests including the username
|
let credentials = urls.get(url);
|
||||||
if let Some(username) = credentials.username() {
|
if let Some(credentials) = credentials {
|
||||||
let key = CredentialsCache::key(url, Some(username.to_string()));
|
if username.is_none() || username.as_deref() == credentials.username() {
|
||||||
if !self.contains_key(&key) {
|
trace!("Found cached credentials for URL {url}");
|
||||||
self.insert_entry(key, credentials.clone());
|
return Some(credentials.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
trace!("No credentials in URL cache for {url}");
|
||||||
// Insert an entry for requests with no username
|
None
|
||||||
let key = CredentialsCache::key(url, None);
|
|
||||||
if !self.contains_key(&key) {
|
|
||||||
self.insert_entry(key, credentials.clone());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update the cache with the given credentials.
|
/// Update the cache with the given credentials.
|
||||||
|
|
@ -138,47 +76,197 @@ impl CredentialsCache {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert an entry for requests including the username
|
// Insert an entry for requests including the username
|
||||||
if let Some(username) = credentials.username() {
|
let username = credentials.to_username();
|
||||||
self.insert_entry(
|
if username.is_some() {
|
||||||
CredentialsCache::key(url, Some(username.to_string())),
|
let realm = (Realm::from(url), username.clone());
|
||||||
credentials.clone(),
|
self.insert_realm(realm, credentials.clone());
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Insert an entry for requests with no username
|
// Insert an entry for requests with no username
|
||||||
self.insert_entry(CredentialsCache::key(url, None), credentials.clone());
|
self.insert_realm((Realm::from(url), Username::none()), credentials.clone());
|
||||||
|
|
||||||
|
// Insert an entry for the URL
|
||||||
|
let mut urls = self.urls.lock().unwrap();
|
||||||
|
urls.insert(url.clone(), credentials.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Private interface to update a cache entry.
|
/// Private interface to update a realm cache entry.
|
||||||
fn insert_entry(&self, key: (NetLoc, Option<String>), credentials: Arc<Credentials>) -> bool {
|
///
|
||||||
|
/// Returns replaced credentials, if any.
|
||||||
|
fn insert_realm(
|
||||||
|
&self,
|
||||||
|
key: (Realm, Username),
|
||||||
|
credentials: Arc<Credentials>,
|
||||||
|
) -> Option<Arc<Credentials>> {
|
||||||
// Do not cache empty credentials
|
// Do not cache empty credentials
|
||||||
if credentials.is_empty() {
|
if credentials.is_empty() {
|
||||||
return false;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut store = self.store.lock().unwrap();
|
let mut realms = self.realms.lock().unwrap();
|
||||||
|
|
||||||
// Always replace existing entries if we have a password
|
// Always replace existing entries if we have a password
|
||||||
if credentials.password().is_some() {
|
if credentials.password().is_some() {
|
||||||
store.insert(key, credentials.clone());
|
return realms.insert(key, credentials.clone());
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we only have a username, add a new entry or replace an existing entry if it doesn't have a password
|
// If we only have a username, add a new entry or replace an existing entry if it doesn't have a password
|
||||||
let existing = store.get(&key);
|
let existing = realms.get(&key);
|
||||||
if existing.is_none()
|
if existing.is_none()
|
||||||
|| existing.is_some_and(|credentials| credentials.password().is_none())
|
|| existing.is_some_and(|credentials| credentials.password().is_none())
|
||||||
{
|
{
|
||||||
store.insert(key, credentials.clone());
|
return realms.insert(key, credentials.clone());
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
false
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if a key is in the cache.
|
#[derive(Debug)]
|
||||||
fn contains_key(&self, key: &(NetLoc, Option<String>)) -> bool {
|
struct UrlTrie {
|
||||||
let store = self.store.lock().unwrap();
|
states: Vec<TrieState>,
|
||||||
store.contains_key(key)
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct TrieState {
|
||||||
|
children: Vec<(String, usize)>,
|
||||||
|
value: Option<Arc<Credentials>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UrlTrie {
|
||||||
|
fn new() -> UrlTrie {
|
||||||
|
let mut trie = UrlTrie { states: vec![] };
|
||||||
|
trie.alloc();
|
||||||
|
trie
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get(&self, url: &Url) -> Option<&Arc<Credentials>> {
|
||||||
|
let mut state = 0;
|
||||||
|
let realm = Realm::from(url).to_string();
|
||||||
|
for component in [realm.as_str()]
|
||||||
|
.into_iter()
|
||||||
|
.chain(url.path_segments().unwrap().filter(|item| !item.is_empty()))
|
||||||
|
{
|
||||||
|
state = self.states[state].get(component)?;
|
||||||
|
if let Some(ref value) = self.states[state].value {
|
||||||
|
return Some(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.states[state].value.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn insert(&mut self, url: Url, value: Arc<Credentials>) {
|
||||||
|
let mut state = 0;
|
||||||
|
let realm = Realm::from(&url).to_string();
|
||||||
|
for component in [realm.as_str()]
|
||||||
|
.into_iter()
|
||||||
|
.chain(url.path_segments().unwrap().filter(|item| !item.is_empty()))
|
||||||
|
{
|
||||||
|
match self.states[state].index(component) {
|
||||||
|
Ok(i) => state = self.states[state].children[i].1,
|
||||||
|
Err(i) => {
|
||||||
|
let new_state = self.alloc();
|
||||||
|
self.states[state]
|
||||||
|
.children
|
||||||
|
.insert(i, (component.to_string(), new_state));
|
||||||
|
state = new_state;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.states[state].value = Some(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn alloc(&mut self) -> usize {
|
||||||
|
let id = self.states.len();
|
||||||
|
self.states.push(TrieState::default());
|
||||||
|
id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrieState {
|
||||||
|
fn get(&self, component: &str) -> Option<usize> {
|
||||||
|
let i = self.index(component).ok()?;
|
||||||
|
Some(self.children[i].1)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn index(&self, component: &str) -> Result<usize, usize> {
|
||||||
|
self.children
|
||||||
|
.binary_search_by(|(label, _)| label.as_str().cmp(component))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_trie() {
|
||||||
|
let credentials1 = Arc::new(Credentials::new(
|
||||||
|
Some("username1".to_string()),
|
||||||
|
Some("password1".to_string()),
|
||||||
|
));
|
||||||
|
let credentials2 = Arc::new(Credentials::new(
|
||||||
|
Some("username2".to_string()),
|
||||||
|
Some("password2".to_string()),
|
||||||
|
));
|
||||||
|
let credentials3 = Arc::new(Credentials::new(
|
||||||
|
Some("username3".to_string()),
|
||||||
|
Some("password3".to_string()),
|
||||||
|
));
|
||||||
|
let credentials4 = Arc::new(Credentials::new(
|
||||||
|
Some("username4".to_string()),
|
||||||
|
Some("password4".to_string()),
|
||||||
|
));
|
||||||
|
|
||||||
|
let mut trie = UrlTrie::new();
|
||||||
|
trie.insert(
|
||||||
|
Url::parse("https://burntsushi.net").unwrap(),
|
||||||
|
credentials1.clone(),
|
||||||
|
);
|
||||||
|
trie.insert(
|
||||||
|
Url::parse("https://astral.sh").unwrap(),
|
||||||
|
credentials2.clone(),
|
||||||
|
);
|
||||||
|
trie.insert(
|
||||||
|
Url::parse("https://example.com/foo").unwrap(),
|
||||||
|
credentials3.clone(),
|
||||||
|
);
|
||||||
|
trie.insert(
|
||||||
|
Url::parse("https://example.com/bar").unwrap(),
|
||||||
|
credentials4.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let url = Url::parse("https://burntsushi.net/regex-internals").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), Some(&credentials1));
|
||||||
|
|
||||||
|
let url = Url::parse("https://burntsushi.net/").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), Some(&credentials1));
|
||||||
|
|
||||||
|
let url = Url::parse("https://astral.sh/about").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), Some(&credentials2));
|
||||||
|
|
||||||
|
let url = Url::parse("https://example.com/foo").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), Some(&credentials3));
|
||||||
|
|
||||||
|
let url = Url::parse("https://example.com/foo/").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), Some(&credentials3));
|
||||||
|
|
||||||
|
let url = Url::parse("https://example.com/foo/bar").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), Some(&credentials3));
|
||||||
|
|
||||||
|
let url = Url::parse("https://example.com/bar").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), Some(&credentials4));
|
||||||
|
|
||||||
|
let url = Url::parse("https://example.com/bar/").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), Some(&credentials4));
|
||||||
|
|
||||||
|
let url = Url::parse("https://example.com/bar/foo").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), Some(&credentials4));
|
||||||
|
|
||||||
|
let url = Url::parse("https://example.com/about").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), None);
|
||||||
|
|
||||||
|
let url = Url::parse("https://example.com/foobar").unwrap();
|
||||||
|
assert_eq!(trie.get(&url), None);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,28 +12,76 @@ use url::Url;
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub(crate) struct Credentials {
|
pub(crate) struct Credentials {
|
||||||
/// The name of the user for authentication.
|
/// The name of the user for authentication.
|
||||||
///
|
username: Username,
|
||||||
/// Unlike `reqwest`, empty usernames should be encoded as `None` instead of an empty string.
|
|
||||||
username: Option<String>,
|
|
||||||
/// The password to use for authentication.
|
/// The password to use for authentication.
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash)]
|
||||||
|
pub(crate) struct Username(Option<String>);
|
||||||
|
|
||||||
|
impl Username {
|
||||||
|
/// Create a new username.
|
||||||
|
///
|
||||||
|
/// Unlike `reqwest`, empty usernames are be encoded as `None` instead of an empty string.
|
||||||
|
pub fn new(value: Option<String>) -> Self {
|
||||||
|
// Ensure empty strings are `None`
|
||||||
|
if let Some(value) = value {
|
||||||
|
if value.is_empty() {
|
||||||
|
Self(None)
|
||||||
|
} else {
|
||||||
|
Self(Some(value))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn none() -> Self {
|
||||||
|
Self::new(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_none(&self) -> bool {
|
||||||
|
self.0.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_some(&self) -> bool {
|
||||||
|
self.0.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_deref(&self) -> Option<&str> {
|
||||||
|
self.0.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for Username {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Self::new(Some(value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<Option<String>> for Username {
|
||||||
|
fn from(value: Option<String>) -> Self {
|
||||||
|
Self::new(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Credentials {
|
impl Credentials {
|
||||||
pub fn new(username: Option<String>, password: Option<String>) -> Self {
|
pub fn new(username: Option<String>, password: Option<String>) -> Self {
|
||||||
debug_assert!(
|
Self {
|
||||||
username.is_none()
|
username: Username::new(username),
|
||||||
|| username
|
password,
|
||||||
.as_ref()
|
}
|
||||||
.is_some_and(|username| !username.is_empty())
|
|
||||||
);
|
|
||||||
Self { username, password }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn username(&self) -> Option<&str> {
|
pub fn username(&self) -> Option<&str> {
|
||||||
self.username.as_deref()
|
self.username.as_deref()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn to_username(&self) -> Username {
|
||||||
|
self.username.clone()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn password(&self) -> Option<&str> {
|
pub fn password(&self) -> Option<&str> {
|
||||||
self.password.as_deref()
|
self.password.as_deref()
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +106,7 @@ impl Credentials {
|
||||||
};
|
};
|
||||||
|
|
||||||
Some(Credentials {
|
Some(Credentials {
|
||||||
username: Some(entry.login.clone()),
|
username: Username::new(Some(entry.login.clone())),
|
||||||
password: Some(entry.password.clone()),
|
password: Some(entry.password.clone()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +129,8 @@ impl Credentials {
|
||||||
.expect("An encoded username should always decode")
|
.expect("An encoded username should always decode")
|
||||||
.into_owned(),
|
.into_owned(),
|
||||||
)
|
)
|
||||||
},
|
}
|
||||||
|
.into(),
|
||||||
password: url.password().map(|password| {
|
password: url.password().map(|password| {
|
||||||
urlencoding::decode(password)
|
urlencoding::decode(password)
|
||||||
.expect("An encoded password should always decode")
|
.expect("An encoded password should always decode")
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ mod cache;
|
||||||
mod credentials;
|
mod credentials;
|
||||||
mod keyring;
|
mod keyring;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod netloc;
|
mod realm;
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
|
@ -11,8 +11,9 @@ use credentials::Credentials;
|
||||||
|
|
||||||
pub use keyring::KeyringProvider;
|
pub use keyring::KeyringProvider;
|
||||||
pub use middleware::AuthMiddleware;
|
pub use middleware::AuthMiddleware;
|
||||||
use netloc::NetLoc;
|
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
use realm::Realm;
|
||||||
|
use tracing::trace;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
// TODO(zanieb): Consider passing a cache explicitly throughout
|
// TODO(zanieb): Consider passing a cache explicitly throughout
|
||||||
|
|
@ -27,6 +28,7 @@ pub(crate) static CREDENTIALS_CACHE: Lazy<CredentialsCache> = Lazy::new(Credenti
|
||||||
/// Returns `true` if the store was updated.
|
/// Returns `true` if the store was updated.
|
||||||
pub fn store_credentials_from_url(url: &Url) -> bool {
|
pub fn store_credentials_from_url(url: &Url) -> bool {
|
||||||
if let Some(credentials) = Credentials::from_url(url) {
|
if let Some(credentials) = Credentials::from_url(url) {
|
||||||
|
trace!("Caching credentials for {url}");
|
||||||
CREDENTIALS_CACHE.insert(url, Arc::new(credentials));
|
CREDENTIALS_CACHE.insert(url, Arc::new(credentials));
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use http::Extensions;
|
use http::{Extensions, StatusCode};
|
||||||
|
use url::Url;
|
||||||
use netrc::Netrc;
|
|
||||||
use reqwest::{Request, Response};
|
|
||||||
use reqwest_middleware::{Middleware, Next};
|
|
||||||
use tracing::{debug, trace};
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cache::CheckResponse, credentials::Credentials, CredentialsCache, KeyringProvider,
|
credentials::{Credentials, Username},
|
||||||
CREDENTIALS_CACHE,
|
realm::Realm,
|
||||||
|
CredentialsCache, KeyringProvider, CREDENTIALS_CACHE,
|
||||||
};
|
};
|
||||||
|
use anyhow::anyhow;
|
||||||
|
use netrc::Netrc;
|
||||||
|
use reqwest::{Request, Response};
|
||||||
|
use reqwest_middleware::{Error, Middleware, Next};
|
||||||
|
use tracing::{debug, trace};
|
||||||
|
|
||||||
/// A middleware that adds basic authentication to requests based on the netrc file and the keyring.
|
/// A middleware that adds basic authentication to requests.
|
||||||
///
|
///
|
||||||
/// Netrc support Based on: <https://github.com/gribouille/netrc>.
|
/// Uses a cache to propagate credentials from previously seen requests and
|
||||||
|
/// fetches credentials from a netrc file and the keyring.
|
||||||
pub struct AuthMiddleware {
|
pub struct AuthMiddleware {
|
||||||
netrc: Option<Netrc>,
|
netrc: Option<Netrc>,
|
||||||
keyring: Option<KeyringProvider>,
|
keyring: Option<KeyringProvider>,
|
||||||
|
|
@ -69,14 +72,51 @@ impl Default for AuthMiddleware {
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
impl Middleware for AuthMiddleware {
|
impl Middleware for AuthMiddleware {
|
||||||
|
/// Handle authentication for a request.
|
||||||
|
///
|
||||||
|
/// ## If the request has a username and password
|
||||||
|
///
|
||||||
|
/// We already have a fully authenticated request and we don't need to perform a look-up.
|
||||||
|
///
|
||||||
|
/// - Perform the request
|
||||||
|
/// - Add the username and password to the cache if successful
|
||||||
|
///
|
||||||
|
/// ## If the request only has a username
|
||||||
|
///
|
||||||
|
/// We probably need additional authentication, because a username is provided.
|
||||||
|
/// We'll avoid making a request we expect to fail and look for a password.
|
||||||
|
/// The discovered credentials must have the requested username to be used.
|
||||||
|
///
|
||||||
|
/// - Check the cache (realm key) for a password
|
||||||
|
/// - Check the netrc for a password
|
||||||
|
/// - Check the keyring for a password
|
||||||
|
/// - Perform the request
|
||||||
|
/// - Add the username and password to the cache if successful
|
||||||
|
///
|
||||||
|
/// ## If the request has no authentication
|
||||||
|
///
|
||||||
|
/// We may or may not need authentication. We'll check for cached credentials for the URL,
|
||||||
|
/// which is relatively specific and can save us an expensive failed request. Otherwise,
|
||||||
|
/// we'll make the request and look for less-specific credentials on failure i.e. if the
|
||||||
|
/// server tells us authorization is needed. This pattern avoids attaching credentials to
|
||||||
|
/// requests that do not need them, which can cause some servers to deny the request.
|
||||||
|
///
|
||||||
|
/// - Check the cache (url key)
|
||||||
|
/// - Perform the request
|
||||||
|
/// - On 401, 403, or 404 check for authentication if there was a cache miss
|
||||||
|
/// - Check the cache (realm key) for the username and password
|
||||||
|
/// - Check the netrc for a username and password
|
||||||
|
/// - Perform the request again if found
|
||||||
|
/// - Add the username and password to the cache if successful
|
||||||
async fn handle(
|
async fn handle(
|
||||||
&self,
|
&self,
|
||||||
mut request: Request,
|
mut request: Request,
|
||||||
extensions: &mut Extensions,
|
extensions: &mut Extensions,
|
||||||
next: Next<'_>,
|
next: Next<'_>,
|
||||||
) -> reqwest_middleware::Result<Response> {
|
) -> reqwest_middleware::Result<Response> {
|
||||||
// Check for credentials attached to (1) the request itself
|
// Check for credentials attached to the request already
|
||||||
let credentials = Credentials::from_request(&request);
|
let credentials = Credentials::from_request(&request);
|
||||||
|
|
||||||
// In the middleware, existing credentials are already moved from the URL
|
// In the middleware, existing credentials are already moved from the URL
|
||||||
// to the headers so for display purposes we restore some information
|
// to the headers so for display purposes we restore some information
|
||||||
let url = if tracing::enabled!(tracing::Level::DEBUG) {
|
let url = if tracing::enabled!(tracing::Level::DEBUG) {
|
||||||
|
|
@ -100,88 +140,200 @@ impl Middleware for AuthMiddleware {
|
||||||
};
|
};
|
||||||
trace!("Handling request for {url}");
|
trace!("Handling request for {url}");
|
||||||
|
|
||||||
// Then check for credentials in (2) the cache
|
if let Some(credentials) = credentials {
|
||||||
let credentials = self.cache().check(request.url(), credentials);
|
let credentials = Arc::new(credentials);
|
||||||
|
|
||||||
// Track credentials that we might want to insert into the cache
|
// If there's a password, send the request and cache
|
||||||
let mut new_credentials = None;
|
if credentials.password().is_some() {
|
||||||
|
trace!("Request for {url} is already fully authenticated");
|
||||||
// If already authenticated (including a password), don't query other services
|
return self
|
||||||
if credentials.is_authenticated() {
|
.complete_request(Some(credentials), request, extensions, next)
|
||||||
match credentials {
|
.await;
|
||||||
// If we get credentials from the cache, update the request
|
|
||||||
CheckResponse::Cached(credentials) => request = credentials.authenticate(request),
|
|
||||||
// If we get credentials from the request, we should update the cache
|
|
||||||
// but don't need to update the request
|
|
||||||
CheckResponse::Uncached(credentials) => new_credentials = Some(credentials.clone()),
|
|
||||||
CheckResponse::None => unreachable!("No credentials cannot be authenticated"),
|
|
||||||
}
|
}
|
||||||
// Otherwise, look for complete credentials in:
|
|
||||||
// (3) The netrc file
|
trace!("Request for {url} is missing a password, looking for credentials");
|
||||||
} else if let Some(credentials) = self.netrc.as_ref().and_then(|netrc| {
|
// There's just a username, try to find a password
|
||||||
|
let credentials = if let Some(credentials) = self
|
||||||
|
.cache()
|
||||||
|
.get_realm(Realm::from(request.url()), credentials.to_username())
|
||||||
|
{
|
||||||
|
request = credentials.authenticate(request);
|
||||||
|
// Do not insert already-cached credentials
|
||||||
|
None
|
||||||
|
} else if let Some(credentials) = self
|
||||||
|
.fetch_credentials(Some(&credentials), request.url())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
request = credentials.authenticate(request);
|
||||||
|
Some(Arc::new(credentials))
|
||||||
|
} else {
|
||||||
|
// If we don't find a password, we'll still attempt the request with the existing credentials
|
||||||
|
Some(credentials)
|
||||||
|
};
|
||||||
|
|
||||||
|
return self
|
||||||
|
.complete_request(credentials, request, extensions, next)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have no credentials
|
||||||
|
trace!("Request for {url} is unauthenticated, checking cache");
|
||||||
|
|
||||||
|
// Check the cache for a URL match
|
||||||
|
let credentials = self.cache().get_url(request.url(), Username::none());
|
||||||
|
if let Some(credentials) = credentials.as_ref() {
|
||||||
|
request = credentials.authenticate(request);
|
||||||
|
if credentials.password().is_some() {
|
||||||
|
return self.complete_request(None, request, extensions, next).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let attempt_has_username = credentials
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|credentials| credentials.username().is_some());
|
||||||
|
|
||||||
|
// Otherise, attempt an anonymous request
|
||||||
|
trace!("Attempting unauthenticated request for {url}");
|
||||||
|
|
||||||
|
// <https://github.com/TrueLayer/reqwest-middleware/blob/abdf1844c37092d323683c2396b7eefda1418d3c/reqwest-retry/src/middleware.rs#L141-L149>
|
||||||
|
// Clone the request so we can retry it on authentication failure
|
||||||
|
let mut retry_request = request.try_clone().ok_or_else(|| {
|
||||||
|
Error::Middleware(anyhow!(
|
||||||
|
"Request object is not clonable. Are you passing a streaming body?".to_string()
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let response = next.clone().run(request, extensions).await?;
|
||||||
|
|
||||||
|
// If we don't fail with authorization related codes, return the response
|
||||||
|
if !matches!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::FORBIDDEN | StatusCode::NOT_FOUND | StatusCode::UNAUTHORIZED
|
||||||
|
) {
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, search for credentials
|
||||||
|
trace!(
|
||||||
|
"Request for {url} failed with {}, checking for credentials",
|
||||||
|
response.status()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check in the cache first
|
||||||
|
let credentials = self.cache().get_realm(
|
||||||
|
Realm::from(retry_request.url()),
|
||||||
|
credentials
|
||||||
|
.map(|credentials| credentials.to_username())
|
||||||
|
.unwrap_or(Username::none()),
|
||||||
|
);
|
||||||
|
if let Some(credentials) = credentials.as_ref() {
|
||||||
|
if credentials.password().is_some() {
|
||||||
|
trace!("Retrying request for {url} with credentials from cache {credentials:?}");
|
||||||
|
retry_request = credentials.authenticate(retry_request);
|
||||||
|
return self
|
||||||
|
.complete_request(None, retry_request, extensions, next)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Then, fetch from external services.
|
||||||
|
// Here we use the username from the cache if present.
|
||||||
|
if let Some(credentials) = self
|
||||||
|
.fetch_credentials(credentials.as_deref(), retry_request.url())
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
retry_request = credentials.authenticate(retry_request);
|
||||||
|
trace!("Retrying request for {url} with {credentials:?}");
|
||||||
|
return self
|
||||||
|
.complete_request(Some(Arc::new(credentials)), retry_request, extensions, next)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(credentials) = credentials.as_ref() {
|
||||||
|
if !attempt_has_username {
|
||||||
|
trace!("Retrying request for {url} with username from cache {credentials:?}");
|
||||||
|
retry_request = credentials.authenticate(retry_request);
|
||||||
|
return self
|
||||||
|
.complete_request(None, retry_request, extensions, next)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AuthMiddleware {
|
||||||
|
/// Run a request to completion.
|
||||||
|
///
|
||||||
|
/// If credentials are present, insert them into the cache on success.
|
||||||
|
async fn complete_request(
|
||||||
|
&self,
|
||||||
|
credentials: Option<Arc<Credentials>>,
|
||||||
|
request: Request,
|
||||||
|
extensions: &mut Extensions,
|
||||||
|
next: Next<'_>,
|
||||||
|
) -> reqwest_middleware::Result<Response> {
|
||||||
|
let Some(credentials) = credentials else {
|
||||||
|
// Nothing to insert into the cache if we don't have credentials
|
||||||
|
return next.run(request, extensions).await;
|
||||||
|
};
|
||||||
|
|
||||||
|
let url = request.url().clone();
|
||||||
|
let result = next.run(request, extensions).await;
|
||||||
|
|
||||||
|
// Update the cache with new credentials on a successful request
|
||||||
|
if result
|
||||||
|
.as_ref()
|
||||||
|
.is_ok_and(|response| response.error_for_status_ref().is_ok())
|
||||||
|
{
|
||||||
|
trace!("Updating cached credentials for {url} to {credentials:?}");
|
||||||
|
self.cache().insert(&url, credentials)
|
||||||
|
};
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch credentials for a URL.
|
||||||
|
///
|
||||||
|
/// Supports netrc file and keyring lookups.
|
||||||
|
async fn fetch_credentials(
|
||||||
|
&self,
|
||||||
|
credentials: Option<&Credentials>,
|
||||||
|
url: &Url,
|
||||||
|
) -> Option<Credentials> {
|
||||||
|
// Netrc support based on: <https://github.com/gribouille/netrc>.
|
||||||
|
if let Some(credentials) = self.netrc.as_ref().and_then(|netrc| {
|
||||||
trace!("Checking netrc for credentials for {url}");
|
trace!("Checking netrc for credentials for {url}");
|
||||||
Credentials::from_netrc(
|
Credentials::from_netrc(
|
||||||
netrc,
|
netrc,
|
||||||
request.url(),
|
url,
|
||||||
credentials
|
credentials
|
||||||
.get()
|
.as_ref()
|
||||||
.and_then(|credentials| credentials.username()),
|
.and_then(|credentials| credentials.username()),
|
||||||
)
|
)
|
||||||
}) {
|
}) {
|
||||||
debug!("Found credentials in netrc file for {url}");
|
debug!("Found credentials in netrc file for {url}");
|
||||||
request = credentials.authenticate(request);
|
Some(credentials)
|
||||||
new_credentials = Some(Arc::new(credentials));
|
|
||||||
// (4) The keyring
|
|
||||||
// N.B. The keyring provider performs lookups for the exact URL then
|
// N.B. The keyring provider performs lookups for the exact URL then
|
||||||
// falls back to the host, but we cache the result per host so if a keyring
|
// falls back to the host, but we cache the result per host so if a keyring
|
||||||
// implementation returns different credentials for different URLs in the
|
// implementation returns different credentials for different URLs in the
|
||||||
// same realm we will use the wrong credentials.
|
// same realm we will use the wrong credentials.
|
||||||
} else if let Some(credentials) = self.keyring.as_ref().and_then(|keyring| {
|
} else if let Some(credentials) = self.keyring.as_ref().and_then(|keyring| {
|
||||||
if let Some(username) = credentials
|
if let Some(username) = credentials
|
||||||
.get()
|
.as_ref()
|
||||||
.and_then(|credentials| credentials.username())
|
.and_then(|credentials| credentials.username())
|
||||||
{
|
{
|
||||||
debug!("Checking keyring for credentials for {url}");
|
debug!("Checking keyring for credentials for {username}@{url}");
|
||||||
keyring.fetch(request.url(), username)
|
keyring.fetch(url, username)
|
||||||
} else {
|
} else {
|
||||||
trace!("Skipping keyring lookup for {url} with no username");
|
trace!("Skipping keyring lookup for {url} with no username");
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}) {
|
}) {
|
||||||
debug!("Found credentials in keyring for {url}");
|
debug!("Found credentials in keyring for {url}");
|
||||||
request = credentials.authenticate(request);
|
Some(credentials)
|
||||||
new_credentials = Some(Arc::new(credentials));
|
|
||||||
// No additional credentials were found
|
|
||||||
} else {
|
} else {
|
||||||
match credentials {
|
None
|
||||||
CheckResponse::Cached(credentials) => request = credentials.authenticate(request),
|
|
||||||
CheckResponse::Uncached(credentials) => new_credentials = Some(credentials.clone()),
|
|
||||||
CheckResponse::None => {
|
|
||||||
debug!("No credentials found for {url}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(credentials) = new_credentials {
|
|
||||||
let url = request.url().clone();
|
|
||||||
|
|
||||||
// Update the default credentials eagerly since requests are made concurrently
|
|
||||||
// and we want to avoid expensive credential lookups
|
|
||||||
self.cache().set_default(&url, credentials.clone());
|
|
||||||
|
|
||||||
let result = next.run(request, extensions).await;
|
|
||||||
|
|
||||||
// Only update the cache with new credentials on a successful request
|
|
||||||
if result
|
|
||||||
.as_ref()
|
|
||||||
.is_ok_and(|response| response.error_for_status_ref().is_ok())
|
|
||||||
{
|
|
||||||
trace!("Updating cached credentials for {url}");
|
|
||||||
self.cache().insert(&url, credentials)
|
|
||||||
};
|
|
||||||
result
|
|
||||||
} else {
|
|
||||||
next.run(request, extensions).await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -196,7 +348,7 @@ mod tests {
|
||||||
use test_log::test;
|
use test_log::test;
|
||||||
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
use wiremock::matchers::{basic_auth, method};
|
use wiremock::matchers::{basic_auth, method, path_regex};
|
||||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
@ -256,8 +408,9 @@ mod tests {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Without seeding the cache, authenticated requests are not cached
|
||||||
#[test(tokio::test)]
|
#[test(tokio::test)]
|
||||||
async fn test_credentials_in_url() -> Result<(), Error> {
|
async fn test_credentials_in_url_no_seed() -> Result<(), Error> {
|
||||||
let username = "user";
|
let username = "user";
|
||||||
let password = "password";
|
let password = "password";
|
||||||
|
|
||||||
|
|
@ -279,6 +432,7 @@ mod tests {
|
||||||
200,
|
200,
|
||||||
"Subsequent requests should not require credentials"
|
"Subsequent requests should not require credentials"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
.get(format!("{}/foo", server.uri()))
|
.get(format!("{}/foo", server.uri()))
|
||||||
|
|
@ -286,7 +440,61 @@ mod tests {
|
||||||
.await?
|
.await?
|
||||||
.status(),
|
.status(),
|
||||||
200,
|
200,
|
||||||
"Subsequent requests can be to different paths in the same realm"
|
"Requests can be to different paths in the same realm"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut url = base_url.clone();
|
||||||
|
url.set_username(username).unwrap();
|
||||||
|
url.set_password(Some("invalid")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.get(url).send().await?.status(),
|
||||||
|
401,
|
||||||
|
"Credentials in the URL should take precedence and fail"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test(tokio::test)]
|
||||||
|
async fn test_credentials_in_url_seed() -> Result<(), Error> {
|
||||||
|
let username = "user";
|
||||||
|
let password = "password";
|
||||||
|
|
||||||
|
let server = start_test_server(username, password).await;
|
||||||
|
let base_url = Url::parse(&server.uri())?;
|
||||||
|
let cache = CredentialsCache::new();
|
||||||
|
cache.insert(
|
||||||
|
&base_url,
|
||||||
|
Arc::new(Credentials::new(
|
||||||
|
Some(username.to_string()),
|
||||||
|
Some(password.to_string()),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = test_client_builder()
|
||||||
|
.with(AuthMiddleware::new().with_cache(cache))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let mut url = base_url.clone();
|
||||||
|
url.set_username(username).unwrap();
|
||||||
|
url.set_password(Some(password)).unwrap();
|
||||||
|
assert_eq!(client.get(url).send().await?.status(), 200);
|
||||||
|
|
||||||
|
// Works for a URL without credentials too
|
||||||
|
assert_eq!(
|
||||||
|
client.get(server.uri()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests should not require credentials"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.get(format!("{}/foo", server.uri()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status(),
|
||||||
|
200,
|
||||||
|
"Requests can be to different paths in the same realm"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut url = base_url.clone();
|
let mut url = base_url.clone();
|
||||||
|
|
@ -307,23 +515,29 @@ mod tests {
|
||||||
let password = "";
|
let password = "";
|
||||||
|
|
||||||
let server = start_test_server(username, password).await;
|
let server = start_test_server(username, password).await;
|
||||||
let client = test_client_builder()
|
|
||||||
.with(AuthMiddleware::new().with_cache(CredentialsCache::new()))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
let base_url = Url::parse(&server.uri())?;
|
let base_url = Url::parse(&server.uri())?;
|
||||||
|
let cache = CredentialsCache::new();
|
||||||
|
cache.insert(
|
||||||
|
&base_url,
|
||||||
|
Arc::new(Credentials::new(Some(username.to_string()), None)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = test_client_builder()
|
||||||
|
.with(AuthMiddleware::new().with_cache(cache))
|
||||||
|
.build();
|
||||||
|
|
||||||
let mut url = base_url.clone();
|
let mut url = base_url.clone();
|
||||||
url.set_username(username).unwrap();
|
url.set_username(username).unwrap();
|
||||||
url.set_password(None).unwrap();
|
url.set_password(None).unwrap();
|
||||||
assert_eq!(client.get(url).send().await?.status(), 200);
|
assert_eq!(client.get(url).send().await?.status(), 200);
|
||||||
|
|
||||||
// Works for a URL without credentials now
|
// Works for a URL without credentials too
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client.get(server.uri()).send().await?.status(),
|
client.get(server.uri()).send().await?.status(),
|
||||||
200,
|
200,
|
||||||
"Subsequent requests should not require credentials"
|
"Requests should not require credentials"
|
||||||
);
|
);
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
client
|
client
|
||||||
.get(format!("{}/foo", server.uri()))
|
.get(format!("{}/foo", server.uri()))
|
||||||
|
|
@ -331,7 +545,7 @@ mod tests {
|
||||||
.await?
|
.await?
|
||||||
.status(),
|
.status(),
|
||||||
200,
|
200,
|
||||||
"Subsequent requests can be to different paths in the same realm"
|
"Requests can be to different paths in the same realm"
|
||||||
);
|
);
|
||||||
|
|
||||||
let mut url = base_url.clone();
|
let mut url = base_url.clone();
|
||||||
|
|
@ -583,4 +797,397 @@ mod tests {
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test(tokio::test)]
|
||||||
|
async fn test_credentials_in_url_multiple_realms() -> Result<(), Error> {
|
||||||
|
let username_1 = "user1";
|
||||||
|
let password_1 = "password1";
|
||||||
|
let server_1 = start_test_server(username_1, password_1).await;
|
||||||
|
let base_url_1 = Url::parse(&server_1.uri())?;
|
||||||
|
|
||||||
|
let username_2 = "user2";
|
||||||
|
let password_2 = "password2";
|
||||||
|
let server_2 = start_test_server(username_2, password_2).await;
|
||||||
|
let base_url_2 = Url::parse(&server_2.uri())?;
|
||||||
|
|
||||||
|
let cache = CredentialsCache::new();
|
||||||
|
// Seed the cache with our credentials
|
||||||
|
cache.insert(
|
||||||
|
&base_url_1,
|
||||||
|
Arc::new(Credentials::new(
|
||||||
|
Some(username_1.to_string()),
|
||||||
|
Some(password_1.to_string()),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
cache.insert(
|
||||||
|
&base_url_2,
|
||||||
|
Arc::new(Credentials::new(
|
||||||
|
Some(username_2.to_string()),
|
||||||
|
Some(password_2.to_string()),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = test_client_builder()
|
||||||
|
.with(AuthMiddleware::new().with_cache(cache))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Both servers should work
|
||||||
|
assert_eq!(
|
||||||
|
client.get(server_1.uri()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests should not require credentials"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.get(server_2.uri()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests should not require credentials"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.get(format!("{}/foo", server_1.uri()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status(),
|
||||||
|
200,
|
||||||
|
"Requests can be to different paths in the same realm"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.get(format!("{}/foo", server_2.uri()))
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status(),
|
||||||
|
200,
|
||||||
|
"Requests can be to different paths in the same realm"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test(tokio::test)]
|
||||||
|
async fn test_credentials_from_keyring_multiple_realms() -> Result<(), Error> {
|
||||||
|
let username_1 = "user1";
|
||||||
|
let password_1 = "password1";
|
||||||
|
let server_1 = start_test_server(username_1, password_1).await;
|
||||||
|
let base_url_1 = Url::parse(&server_1.uri())?;
|
||||||
|
|
||||||
|
let username_2 = "user2";
|
||||||
|
let password_2 = "password2";
|
||||||
|
let server_2 = start_test_server(username_2, password_2).await;
|
||||||
|
let base_url_2 = Url::parse(&server_2.uri())?;
|
||||||
|
|
||||||
|
let client = test_client_builder()
|
||||||
|
.with(
|
||||||
|
AuthMiddleware::new()
|
||||||
|
.with_cache(CredentialsCache::new())
|
||||||
|
.with_keyring(Some(KeyringProvider::dummy([
|
||||||
|
((base_url_1.host_str().unwrap(), username_1), password_1),
|
||||||
|
((base_url_2.host_str().unwrap(), username_2), password_2),
|
||||||
|
]))),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Both servers do not work without a username
|
||||||
|
assert_eq!(
|
||||||
|
client.get(server_1.uri()).send().await?.status(),
|
||||||
|
401,
|
||||||
|
"Requests should require a username"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.get(server_2.uri()).send().await?.status(),
|
||||||
|
401,
|
||||||
|
"Requests should require a username"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut url_1 = base_url_1.clone();
|
||||||
|
url_1.set_username(username_1).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.get(url_1.clone()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests with a username should succeed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.get(server_2.uri()).send().await?.status(),
|
||||||
|
401,
|
||||||
|
"Credentials should not be re-used for the second server"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut url_2 = base_url_2.clone();
|
||||||
|
url_2.set_username(username_2).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.get(url_2.clone()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests with a username should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client.get(format!("{}/foo", url_1)).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests can be to different paths in the same realm"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.get(format!("{}/foo", url_2)).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests can be to different paths in the same realm"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test(tokio::test)]
|
||||||
|
async fn test_credentials_in_url_mixed_authentication_in_realm() -> Result<(), Error> {
|
||||||
|
let username_1 = "user1";
|
||||||
|
let password_1 = "password1";
|
||||||
|
let username_2 = "user2";
|
||||||
|
let password_2 = "password2";
|
||||||
|
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex("/prefix_1.*"))
|
||||||
|
.and(basic_auth(username_1, password_1))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex("/prefix_2.*"))
|
||||||
|
.and(basic_auth(username_2, password_2))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create a third, public prefix
|
||||||
|
// It will throw a 401 if it recieves credentials
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex("/prefix_3.*"))
|
||||||
|
.and(basic_auth(username_1, password_1))
|
||||||
|
.respond_with(ResponseTemplate::new(401))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex("/prefix_3.*"))
|
||||||
|
.and(basic_auth(username_2, password_2))
|
||||||
|
.respond_with(ResponseTemplate::new(401))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex("/prefix_3.*"))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.respond_with(ResponseTemplate::new(401))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let base_url = Url::parse(&server.uri())?;
|
||||||
|
let base_url_1 = base_url.join("prefix_1")?;
|
||||||
|
let base_url_2 = base_url.join("prefix_2")?;
|
||||||
|
let base_url_3 = base_url.join("prefix_3")?;
|
||||||
|
|
||||||
|
let cache = CredentialsCache::new();
|
||||||
|
|
||||||
|
// Seed the cache with our credentials
|
||||||
|
cache.insert(
|
||||||
|
&base_url_1,
|
||||||
|
Arc::new(Credentials::new(
|
||||||
|
Some(username_1.to_string()),
|
||||||
|
Some(password_1.to_string()),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
cache.insert(
|
||||||
|
&base_url_2,
|
||||||
|
Arc::new(Credentials::new(
|
||||||
|
Some(username_2.to_string()),
|
||||||
|
Some(password_2.to_string()),
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
|
let client = test_client_builder()
|
||||||
|
.with(AuthMiddleware::new().with_cache(cache))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Both servers should work
|
||||||
|
assert_eq!(
|
||||||
|
client.get(base_url_1.clone()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests should not require credentials"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.get(base_url_2.clone()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests should not require credentials"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.get(base_url.join("prefix_1/foo")?)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status(),
|
||||||
|
200,
|
||||||
|
"Requests can be to different paths in the same realm"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.get(base_url.join("prefix_2/foo")?)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status(),
|
||||||
|
200,
|
||||||
|
"Requests can be to different paths in the same realm"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.get(base_url.join("prefix_1_foo")?)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status(),
|
||||||
|
401,
|
||||||
|
"Requests to paths with a matching prefix but different resource segments should fail"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client.get(base_url_3.clone()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests to the 'public' prefix should not use credentials"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test(tokio::test)]
|
||||||
|
async fn test_credentials_from_keyring_mixed_authentication_in_realm() -> Result<(), Error> {
|
||||||
|
let username_1 = "user1";
|
||||||
|
let password_1 = "password1";
|
||||||
|
let username_2 = "user2";
|
||||||
|
let password_2 = "password2";
|
||||||
|
|
||||||
|
let server = MockServer::start().await;
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex("/prefix_1.*"))
|
||||||
|
.and(basic_auth(username_1, password_1))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex("/prefix_2.*"))
|
||||||
|
.and(basic_auth(username_2, password_2))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Create a third, public prefix
|
||||||
|
// It will throw a 401 if it recieves credentials
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex("/prefix_3.*"))
|
||||||
|
.and(basic_auth(username_1, password_1))
|
||||||
|
.respond_with(ResponseTemplate::new(401))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex("/prefix_3.*"))
|
||||||
|
.and(basic_auth(username_2, password_2))
|
||||||
|
.respond_with(ResponseTemplate::new(401))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.and(path_regex("/prefix_3.*"))
|
||||||
|
.respond_with(ResponseTemplate::new(200))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
Mock::given(method("GET"))
|
||||||
|
.respond_with(ResponseTemplate::new(401))
|
||||||
|
.mount(&server)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let base_url = Url::parse(&server.uri())?;
|
||||||
|
let base_url_1 = base_url.join("prefix_1")?;
|
||||||
|
let base_url_2 = base_url.join("prefix_2")?;
|
||||||
|
let base_url_3 = base_url.join("prefix_3")?;
|
||||||
|
|
||||||
|
let client = test_client_builder()
|
||||||
|
.with(
|
||||||
|
AuthMiddleware::new()
|
||||||
|
.with_cache(CredentialsCache::new())
|
||||||
|
.with_keyring(Some(KeyringProvider::dummy([
|
||||||
|
((base_url_1.host_str().unwrap(), username_1), password_1),
|
||||||
|
((base_url_2.host_str().unwrap(), username_2), password_2),
|
||||||
|
]))),
|
||||||
|
)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Both servers do not work without a username
|
||||||
|
assert_eq!(
|
||||||
|
client.get(base_url_1.clone()).send().await?.status(),
|
||||||
|
401,
|
||||||
|
"Requests should require a username"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.get(base_url_2.clone()).send().await?.status(),
|
||||||
|
401,
|
||||||
|
"Requests should require a username"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut url_1 = base_url_1.clone();
|
||||||
|
url_1.set_username(username_1).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.get(url_1.clone()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests with a username should succeed"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.get(base_url_2.clone()).send().await?.status(),
|
||||||
|
401,
|
||||||
|
"Credentials should not be re-used for the second prefix"
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut url_2 = base_url_2.clone();
|
||||||
|
url_2.set_username(username_2).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
client.get(url_2.clone()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests with a username should succeed"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.get(base_url.join("prefix_1/foo")?)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status(),
|
||||||
|
200,
|
||||||
|
"Requests can be to different paths in the same prefix"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.get(base_url.join("prefix_2/foo")?)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status(),
|
||||||
|
200,
|
||||||
|
"Requests can be to different paths in the same prefix"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client
|
||||||
|
.get(base_url.join("prefix_1_foo")?)
|
||||||
|
.send()
|
||||||
|
.await?
|
||||||
|
.status(),
|
||||||
|
401,
|
||||||
|
"Requests to paths with a matching prefix but different resource segments should fail"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
client.get(base_url_3.clone()).send().await?.status(),
|
||||||
|
200,
|
||||||
|
"Requests to the 'public' prefix should not use credentials"
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
use std::{fmt::Display, fmt::Formatter};
|
||||||
|
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
/// Used to determine if authentication information should be retained on a new URL.
|
/// Used to determine if authentication information should be retained on a new URL.
|
||||||
|
|
@ -20,13 +22,13 @@ use url::Url;
|
||||||
// However, `url` (and therefore `reqwest`) sets the `port` to `None` if it matches the default port
|
// However, `url` (and therefore `reqwest`) sets the `port` to `None` if it matches the default port
|
||||||
// so we do not need any special handling here.
|
// so we do not need any special handling here.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub(crate) struct NetLoc {
|
pub(crate) struct Realm {
|
||||||
scheme: String,
|
scheme: String,
|
||||||
host: Option<String>,
|
host: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<&Url> for NetLoc {
|
impl From<&Url> for Realm {
|
||||||
fn from(url: &Url) -> Self {
|
fn from(url: &Url) -> Self {
|
||||||
Self {
|
Self {
|
||||||
scheme: url.scheme().to_string(),
|
scheme: url.scheme().to_string(),
|
||||||
|
|
@ -36,88 +38,108 @@ impl From<&Url> for NetLoc {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Display for Realm {
|
||||||
|
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||||
|
if let Some(port) = self.port {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}://{}:{port}",
|
||||||
|
self.scheme,
|
||||||
|
self.host.as_deref().unwrap_or_default()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
write!(
|
||||||
|
f,
|
||||||
|
"{}://{}",
|
||||||
|
self.scheme,
|
||||||
|
self.host.as_deref().unwrap_or_default()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use url::{ParseError, Url};
|
use url::{ParseError, Url};
|
||||||
|
|
||||||
use crate::NetLoc;
|
use crate::Realm;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_retain_auth() -> Result<(), ParseError> {
|
fn test_should_retain_auth() -> Result<(), ParseError> {
|
||||||
// Exact match (https)
|
// Exact match (https)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
NetLoc::from(&Url::parse("https://example.com")?),
|
Realm::from(&Url::parse("https://example.com")?),
|
||||||
NetLoc::from(&Url::parse("https://example.com")?)
|
Realm::from(&Url::parse("https://example.com")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Exact match (with port)
|
// Exact match (with port)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
NetLoc::from(&Url::parse("https://example.com:1234")?),
|
Realm::from(&Url::parse("https://example.com:1234")?),
|
||||||
NetLoc::from(&Url::parse("https://example.com:1234")?)
|
Realm::from(&Url::parse("https://example.com:1234")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Exact match (http)
|
// Exact match (http)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
NetLoc::from(&Url::parse("http://example.com")?),
|
Realm::from(&Url::parse("http://example.com")?),
|
||||||
NetLoc::from(&Url::parse("http://example.com")?)
|
Realm::from(&Url::parse("http://example.com")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Okay, path differs
|
// Okay, path differs
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
NetLoc::from(&Url::parse("http://example.com/foo")?),
|
Realm::from(&Url::parse("http://example.com/foo")?),
|
||||||
NetLoc::from(&Url::parse("http://example.com/bar")?)
|
Realm::from(&Url::parse("http://example.com/bar")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Okay, default port differs (https)
|
// Okay, default port differs (https)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
NetLoc::from(&Url::parse("https://example.com:443")?),
|
Realm::from(&Url::parse("https://example.com:443")?),
|
||||||
NetLoc::from(&Url::parse("https://example.com")?)
|
Realm::from(&Url::parse("https://example.com")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Okay, default port differs (http)
|
// Okay, default port differs (http)
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
NetLoc::from(&Url::parse("http://example.com:80")?),
|
Realm::from(&Url::parse("http://example.com:80")?),
|
||||||
NetLoc::from(&Url::parse("http://example.com")?)
|
Realm::from(&Url::parse("http://example.com")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mismatched scheme
|
// Mismatched scheme
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
NetLoc::from(&Url::parse("https://example.com")?),
|
Realm::from(&Url::parse("https://example.com")?),
|
||||||
NetLoc::from(&Url::parse("http://example.com")?)
|
Realm::from(&Url::parse("http://example.com")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mismatched scheme, we explicitly do not allow upgrade to https
|
// Mismatched scheme, we explicitly do not allow upgrade to https
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
NetLoc::from(&Url::parse("http://example.com")?),
|
Realm::from(&Url::parse("http://example.com")?),
|
||||||
NetLoc::from(&Url::parse("https://example.com")?)
|
Realm::from(&Url::parse("https://example.com")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mismatched host
|
// Mismatched host
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
NetLoc::from(&Url::parse("https://foo.com")?),
|
Realm::from(&Url::parse("https://foo.com")?),
|
||||||
NetLoc::from(&Url::parse("https://bar.com")?)
|
Realm::from(&Url::parse("https://bar.com")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mismatched port
|
// Mismatched port
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
NetLoc::from(&Url::parse("https://example.com:1234")?),
|
Realm::from(&Url::parse("https://example.com:1234")?),
|
||||||
NetLoc::from(&Url::parse("https://example.com:5678")?)
|
Realm::from(&Url::parse("https://example.com:5678")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mismatched port, with one as default for scheme
|
// Mismatched port, with one as default for scheme
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
NetLoc::from(&Url::parse("https://example.com:443")?),
|
Realm::from(&Url::parse("https://example.com:443")?),
|
||||||
NetLoc::from(&Url::parse("https://example.com:5678")?)
|
Realm::from(&Url::parse("https://example.com:5678")?)
|
||||||
);
|
);
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
NetLoc::from(&Url::parse("https://example.com:1234")?),
|
Realm::from(&Url::parse("https://example.com:1234")?),
|
||||||
NetLoc::from(&Url::parse("https://example.com:443")?)
|
Realm::from(&Url::parse("https://example.com:443")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mismatched port, with default for a different scheme
|
// Mismatched port, with default for a different scheme
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
NetLoc::from(&Url::parse("https://example.com:80")?),
|
Realm::from(&Url::parse("https://example.com:80")?),
|
||||||
NetLoc::from(&Url::parse("https://example.com")?)
|
Realm::from(&Url::parse("https://example.com")?)
|
||||||
);
|
);
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|
@ -1,73 +0,0 @@
|
||||||
use std::env;
|
|
||||||
use std::io::Write;
|
|
||||||
|
|
||||||
use anyhow::Result;
|
|
||||||
use futures::future;
|
|
||||||
use http::header::AUTHORIZATION;
|
|
||||||
use http_body_util::Full;
|
|
||||||
use hyper::body::Bytes;
|
|
||||||
use hyper::server::conn::http1;
|
|
||||||
use hyper::service::service_fn;
|
|
||||||
use hyper::{Request, Response};
|
|
||||||
use hyper_util::rt::TokioIo;
|
|
||||||
use tempfile::NamedTempFile;
|
|
||||||
use tokio::net::TcpListener;
|
|
||||||
|
|
||||||
use uv_cache::Cache;
|
|
||||||
use uv_client::RegistryClientBuilder;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_client_with_netrc_credentials() -> Result<()> {
|
|
||||||
// Set up the TCP listener on a random available port
|
|
||||||
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
||||||
let addr = listener.local_addr()?;
|
|
||||||
|
|
||||||
// Spawn the server loop in a background task
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let svc = service_fn(move |req: Request<hyper::body::Incoming>| {
|
|
||||||
// Get User Agent Header and send it back in the response
|
|
||||||
let auth = req
|
|
||||||
.headers()
|
|
||||||
.get(AUTHORIZATION)
|
|
||||||
.and_then(|v| v.to_str().ok())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
.unwrap_or_default(); // Empty Default
|
|
||||||
future::ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from(auth))))
|
|
||||||
});
|
|
||||||
// Start Server (not wrapped in loop {} since we want a single response server)
|
|
||||||
// If you want server to accept multiple connections, wrap it in loop {}
|
|
||||||
let (socket, _) = listener.accept().await.unwrap();
|
|
||||||
let socket = TokioIo::new(socket);
|
|
||||||
tokio::task::spawn(async move {
|
|
||||||
http1::Builder::new()
|
|
||||||
.serve_connection(socket, svc)
|
|
||||||
.with_upgrades()
|
|
||||||
.await
|
|
||||||
.expect("Server Started");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create a netrc file
|
|
||||||
let mut netrc_file = NamedTempFile::new()?;
|
|
||||||
env::set_var("NETRC", netrc_file.path());
|
|
||||||
writeln!(netrc_file, "machine 127.0.0.1 login user password 1234")?;
|
|
||||||
|
|
||||||
// Initialize uv-client
|
|
||||||
let cache = Cache::temp()?;
|
|
||||||
let client = RegistryClientBuilder::new(cache).build();
|
|
||||||
|
|
||||||
// Send request to our dummy server
|
|
||||||
let res = client
|
|
||||||
.uncached_client()
|
|
||||||
.get(format!("http://{addr}"))
|
|
||||||
.send()
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Check the HTTP status
|
|
||||||
assert!(res.status().is_success());
|
|
||||||
|
|
||||||
// Verify auth header
|
|
||||||
assert_eq!(res.text().await?, "Basic dXNlcjoxMjM0");
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
@ -23,13 +23,26 @@ const READ_ONLY_GITHUB_TOKEN: &[&str] = &[
|
||||||
"NVZMaExzZmtFMHZ1ZEVNd0pPZXZkV040WUdTcmk2WXREeFB4TFlybGlwRTZONEpHV01FMnFZQWJVUm4=",
|
"NVZMaExzZmtFMHZ1ZEVNd0pPZXZkV040WUdTcmk2WXREeFB4TFlybGlwRTZONEpHV01FMnFZQWJVUm4=",
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// This is a fine-grained token that only has read-only access to the `uv-private-pypackage-2` repository
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
const READ_ONLY_GITHUB_TOKEN_2: &[&str] = &[
|
||||||
|
"Z2l0aHViX3BhdA==",
|
||||||
|
"MTFCR0laQTdRMHV1MEpwaFp4dFFyRwo=",
|
||||||
|
"cnNmNXJwMHk2WWpteVZvb2ZFc0c5WUs5b2NPcFY1aVpYTnNmdE05eEhaM0lGSExSSktDWTcxeVBVZXkK",
|
||||||
|
];
|
||||||
|
|
||||||
/// Decode a split, base64 encoded authentication token.
|
/// Decode a split, base64 encoded authentication token.
|
||||||
/// We split and encode the token to bypass revoke by GitHub's secret scanning
|
/// We split and encode the token to bypass revoke by GitHub's secret scanning
|
||||||
fn decode_token(content: &[&str]) -> String {
|
fn decode_token(content: &[&str]) -> String {
|
||||||
let token = content
|
let token = content
|
||||||
.iter()
|
.iter()
|
||||||
.map(|part| base64.decode(part).unwrap())
|
.map(|part| base64.decode(part).unwrap())
|
||||||
.map(|decoded| std::str::from_utf8(decoded.as_slice()).unwrap().to_string())
|
.map(|decoded| {
|
||||||
|
std::str::from_utf8(decoded.as_slice())
|
||||||
|
.unwrap()
|
||||||
|
.trim_end()
|
||||||
|
.to_string()
|
||||||
|
})
|
||||||
.join("_");
|
.join("_");
|
||||||
token
|
token
|
||||||
}
|
}
|
||||||
|
|
@ -1138,6 +1151,77 @@ fn install_git_private_https_pat() {
|
||||||
context.assert_installed("uv_private_pypackage", "0.1.0");
|
context.assert_installed("uv_private_pypackage", "0.1.0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install a package from a private GitHub repository using a PAT
|
||||||
|
/// Include a public GitHub repository too, to ensure that the authentication is not erroneously copied over.
|
||||||
|
#[test]
|
||||||
|
#[cfg(all(not(windows), feature = "git"))]
|
||||||
|
fn install_git_private_https_pat_mixed_with_public() {
|
||||||
|
let context = TestContext::new("3.8");
|
||||||
|
let token = decode_token(READ_ONLY_GITHUB_TOKEN);
|
||||||
|
|
||||||
|
let filters: Vec<_> = [(token.as_str(), "***")]
|
||||||
|
.into_iter()
|
||||||
|
.chain(context.filters())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let package = format!(
|
||||||
|
"uv-private-pypackage @ git+https://{token}@github.com/astral-test/uv-private-pypackage"
|
||||||
|
);
|
||||||
|
|
||||||
|
uv_snapshot!(filters, context.install().arg(package).arg("uv-public-pypackage @ git+https://github.com/astral-test/uv-public-pypackage"),
|
||||||
|
@r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Resolved 2 packages in [TIME]
|
||||||
|
Downloaded 2 packages in [TIME]
|
||||||
|
Installed 2 packages in [TIME]
|
||||||
|
+ uv-private-pypackage==0.1.0 (from git+https://***@github.com/astral-test/uv-private-pypackage@d780faf0ac91257d4d5a4f0c5a0e4509608c0071)
|
||||||
|
+ uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389)
|
||||||
|
"###);
|
||||||
|
|
||||||
|
context.assert_installed("uv_private_pypackage", "0.1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install packages from multiple private GitHub repositories with separate PATS
|
||||||
|
#[test]
|
||||||
|
#[cfg(all(not(windows), feature = "git"))]
|
||||||
|
fn install_git_private_https_multiple_pat() {
|
||||||
|
let context = TestContext::new("3.8");
|
||||||
|
let token_1 = decode_token(READ_ONLY_GITHUB_TOKEN);
|
||||||
|
let token_2 = decode_token(READ_ONLY_GITHUB_TOKEN_2);
|
||||||
|
|
||||||
|
let filters: Vec<_> = [(token_1.as_str(), "***_1"), (token_2.as_str(), "***_2")]
|
||||||
|
.into_iter()
|
||||||
|
.chain(context.filters())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let package_1 = format!(
|
||||||
|
"uv-private-pypackage @ git+https://{token_1}@github.com/astral-test/uv-private-pypackage"
|
||||||
|
);
|
||||||
|
let package_2 = format!(
|
||||||
|
"uv-private-pypackage-2 @ git+https://{token_2}@github.com/astral-test/uv-private-pypackage-2"
|
||||||
|
);
|
||||||
|
|
||||||
|
uv_snapshot!(filters, context.install().arg(package_1).arg(package_2)
|
||||||
|
, @r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Resolved 2 packages in [TIME]
|
||||||
|
Downloaded 2 packages in [TIME]
|
||||||
|
Installed 2 packages in [TIME]
|
||||||
|
+ uv-private-pypackage==0.1.0 (from git+https://***_1@github.com/astral-test/uv-private-pypackage@d780faf0ac91257d4d5a4f0c5a0e4509608c0071)
|
||||||
|
+ uv-private-pypackage-2==0.1.0 (from git+https://***_2@github.com/astral-test/uv-private-pypackage-2@45c0bec7365710f09b1f4dbca61c86dde9537e4e)
|
||||||
|
"###);
|
||||||
|
|
||||||
|
context.assert_installed("uv_private_pypackage", "0.1.0");
|
||||||
|
}
|
||||||
|
|
||||||
/// Install a package from a private GitHub repository at a specific commit using a PAT
|
/// Install a package from a private GitHub repository at a specific commit using a PAT
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(feature = "git")]
|
#[cfg(feature = "git")]
|
||||||
|
|
@ -1245,6 +1329,80 @@ fn install_git_private_https_pat_not_authorized() {
|
||||||
"###);
|
"###);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Install a package from a private GitHub repository using a PAT
|
||||||
|
/// Does not use `git`, instead installs a distribution artifact.
|
||||||
|
/// Include a public GitHub repository too, to ensure that the authentication is not erroneously copied over.
|
||||||
|
#[test]
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn install_github_artifact_private_https_pat_mixed_with_public() {
|
||||||
|
let context = TestContext::new("3.8");
|
||||||
|
let token = decode_token(READ_ONLY_GITHUB_TOKEN);
|
||||||
|
|
||||||
|
let filters: Vec<_> = [(token.as_str(), "***")]
|
||||||
|
.into_iter()
|
||||||
|
.chain(context.filters())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let private_package = format!(
|
||||||
|
"uv-private-pypackage @ https://{token}@raw.githubusercontent.com/astral-test/uv-private-pypackage/main/dist/uv_private_pypackage-0.1.0-py3-none-any.whl"
|
||||||
|
);
|
||||||
|
let public_package = "uv-public-pypackage @ https://raw.githubusercontent.com/astral-test/uv-public-pypackage/main/dist/uv_public_pypackage-0.1.0-py3-none-any.whl";
|
||||||
|
|
||||||
|
uv_snapshot!(filters, context.install().arg(private_package).arg(public_package),
|
||||||
|
@r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Resolved 2 packages in [TIME]
|
||||||
|
Downloaded 2 packages in [TIME]
|
||||||
|
Installed 2 packages in [TIME]
|
||||||
|
+ uv-private-pypackage==0.1.0 (from https://***@raw.githubusercontent.com/astral-test/uv-private-pypackage/main/dist/uv_private_pypackage-0.1.0-py3-none-any.whl)
|
||||||
|
+ uv-public-pypackage==0.1.0 (from https://raw.githubusercontent.com/astral-test/uv-public-pypackage/main/dist/uv_public_pypackage-0.1.0-py3-none-any.whl)
|
||||||
|
"###);
|
||||||
|
|
||||||
|
context.assert_installed("uv_private_pypackage", "0.1.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Install packages from multiple private GitHub repositories with separate PATS
|
||||||
|
/// Does not use `git`, instead installs a distribution artifact.
|
||||||
|
#[test]
|
||||||
|
#[cfg(not(windows))]
|
||||||
|
fn install_github_artifact_private_https_multiple_pat() {
|
||||||
|
let context = TestContext::new("3.8");
|
||||||
|
let token_1 = decode_token(READ_ONLY_GITHUB_TOKEN);
|
||||||
|
let token_2 = decode_token(READ_ONLY_GITHUB_TOKEN_2);
|
||||||
|
|
||||||
|
let filters: Vec<_> = [(token_1.as_str(), "***_1"), (token_2.as_str(), "***_2")]
|
||||||
|
.into_iter()
|
||||||
|
.chain(context.filters())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let package_1 = format!(
|
||||||
|
"uv-private-pypackage @ https://astral-test-bot:{token_1}@raw.githubusercontent.com/astral-test/uv-private-pypackage/main/dist/uv_private_pypackage-0.1.0-py3-none-any.whl"
|
||||||
|
);
|
||||||
|
let package_2 = format!(
|
||||||
|
"uv-private-pypackage-2 @ https://astral-test-bot:{token_2}@raw.githubusercontent.com/astral-test/uv-private-pypackage-2/main/dist/uv_private_pypackage_2-0.1.0-py3-none-any.whl"
|
||||||
|
);
|
||||||
|
|
||||||
|
uv_snapshot!(filters, context.install().arg(package_1).arg(package_2)
|
||||||
|
, @r###"
|
||||||
|
success: true
|
||||||
|
exit_code: 0
|
||||||
|
----- stdout -----
|
||||||
|
|
||||||
|
----- stderr -----
|
||||||
|
Resolved 2 packages in [TIME]
|
||||||
|
Downloaded 2 packages in [TIME]
|
||||||
|
Installed 2 packages in [TIME]
|
||||||
|
+ uv-private-pypackage==0.1.0 (from https://astral-test-bot:***_1@raw.githubusercontent.com/astral-test/uv-private-pypackage/main/dist/uv_private_pypackage-0.1.0-py3-none-any.whl)
|
||||||
|
+ uv-private-pypackage-2==0.1.0 (from https://astral-test-bot:***_2@raw.githubusercontent.com/astral-test/uv-private-pypackage-2/main/dist/uv_private_pypackage_2-0.1.0-py3-none-any.whl)
|
||||||
|
"###);
|
||||||
|
|
||||||
|
context.assert_installed("uv_private_pypackage", "0.1.0");
|
||||||
|
}
|
||||||
|
|
||||||
/// Install a package without using pre-built wheels.
|
/// Install a package without using pre-built wheels.
|
||||||
#[test]
|
#[test]
|
||||||
fn reinstall_no_binary() {
|
fn reinstall_no_binary() {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue