use std::sync::Arc; use http::{Extensions, StatusCode}; use url::Url; use crate::{ credentials::{Credentials, Username}, 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. /// /// Uses a cache to propagate credentials from previously seen requests and /// fetches credentials from a netrc file and the keyring. pub struct AuthMiddleware { netrc: Option, keyring: Option, cache: Option, } impl AuthMiddleware { pub fn new() -> Self { Self { netrc: Netrc::new().ok(), keyring: None, cache: None, } } /// Configure the [`Netrc`] credential file to use. /// /// `None` disables authentication via netrc. #[must_use] pub fn with_netrc(mut self, netrc: Option) -> Self { self.netrc = netrc; self } /// Configure the [`KeyringProvider`] to use. #[must_use] pub fn with_keyring(mut self, keyring: Option) -> Self { self.keyring = keyring; self } /// Configure the [`CredentialsCache`] to use. #[must_use] pub fn with_cache(mut self, cache: CredentialsCache) -> Self { self.cache = Some(cache); self } /// Get the configured authentication store. /// /// If not set, the global store is used. fn cache(&self) -> &CredentialsCache { self.cache.as_ref().unwrap_or(&CREDENTIALS_CACHE) } } impl Default for AuthMiddleware { fn default() -> Self { AuthMiddleware::new() } } #[async_trait::async_trait] 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( &self, mut request: Request, extensions: &mut Extensions, next: Next<'_>, ) -> reqwest_middleware::Result { // Check for credentials attached to the request already let credentials = Credentials::from_request(&request); // In the middleware, existing credentials are already moved from the URL // to the headers so for display purposes we restore some information let url = if tracing::enabled!(tracing::Level::DEBUG) { let mut url = request.url().clone(); if let Some(username) = credentials .as_ref() .and_then(|credentials| credentials.username()) { let _ = url.set_username(username); }; if credentials .as_ref() .and_then(|credentials| credentials.password()) .is_some() { let _ = url.set_password(Some("****")); }; url.to_string() } else { request.url().to_string() }; trace!("Handling request for {url}"); if let Some(credentials) = credentials { let credentials = Arc::new(credentials); // If there's a password, send the request and cache if credentials.password().is_some() { trace!("Request for {url} is already fully authenticated"); return self .complete_request(Some(credentials), request, extensions, next) .await; } trace!("Request for {url} is missing a password, looking for credentials"); // 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(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()); // Otherwise, attempt an anonymous request trace!("Attempting unauthenticated request for {url}"); // // 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(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>, request: Request, extensions: &mut Extensions, next: Next<'_>, ) -> reqwest_middleware::Result { 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> { // Fetches can be expensive, so we will only run them _once_ per realm and username combination // All other requests for the same realm will wait until the first one completes let key = ( Realm::from(url), Username::from( credentials .map(|credentials| credentials.username().unwrap_or_default().to_string()), ), ); if !self.cache().fetches.register(key.clone()) { let credentials = self .cache() .fetches .wait(&key) .await .expect("The key must exist after register is called"); if credentials.is_some() { trace!("Using credentials from previous fetch for {url}"); } else { trace!("Skipping fetch of credentials for {url}, previous attempt failed"); }; return credentials; } // Netrc support based on: . let credentials = if let Some(credentials) = self.netrc.as_ref().and_then(|netrc| { debug!("Checking netrc for credentials for {url}"); Credentials::from_netrc( netrc, url, credentials .as_ref() .and_then(|credentials| credentials.username()), ) }) { debug!("Found credentials in netrc file for {url}"); Some(credentials) // N.B. The keyring provider performs lookups for the exact URL then // falls back to the host, but we cache the result per realm so if a keyring // implementation returns different credentials for different URLs in the // same realm we will use the wrong credentials. } else if let Some(credentials) = match self.keyring { Some(ref keyring) => { if let Some(username) = credentials.and_then(|credentials| credentials.username()) { debug!("Checking keyring for credentials for {username}@{url}"); keyring.fetch(url, username).await } else { debug!("Skipping keyring lookup for {url} with no username"); None } } None => None, } { debug!("Found credentials in keyring for {url}"); Some(credentials) } else { None } .map(Arc::new); // Register the fetch for this key self.cache().fetches.done(key.clone(), credentials.clone()); credentials } } #[cfg(test)] mod tests { use std::io::Write; use reqwest::Client; use tempfile::NamedTempFile; use test_log::test; use url::Url; use wiremock::matchers::{basic_auth, method, path_regex}; use wiremock::{Mock, MockServer, ResponseTemplate}; use super::*; type Error = Box; async fn start_test_server(username: &'static str, password: &'static str) -> MockServer { let server = MockServer::start().await; Mock::given(method("GET")) .and(basic_auth(username, password)) .respond_with(ResponseTemplate::new(200)) .mount(&server) .await; Mock::given(method("GET")) .respond_with(ResponseTemplate::new(401)) .mount(&server) .await; server } fn test_client_builder() -> reqwest_middleware::ClientBuilder { reqwest_middleware::ClientBuilder::new( Client::builder() .build() .expect("Reqwest client should build"), ) } #[test(tokio::test)] async fn test_no_credentials() -> Result<(), Error> { let server = start_test_server("user", "password").await; let client = test_client_builder() .with(AuthMiddleware::new().with_cache(CredentialsCache::new())) .build(); assert_eq!( client .get(format!("{}/foo", server.uri())) .send() .await? .status(), 401 ); assert_eq!( client .get(format!("{}/bar", server.uri())) .send() .await? .status(), 401 ); Ok(()) } /// Without seeding the cache, authenticated requests are not cached #[test(tokio::test)] async fn test_credentials_in_url_no_seed() -> Result<(), Error> { let username = "user"; let password = "password"; 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 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 now assert_eq!( client.get(server.uri()).send().await?.status(), 200, "Subsequent 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(); 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(); 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_username_only() -> Result<(), Error> { let username = "user"; let 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()), None)), ); 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(None).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(); 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" ); assert_eq!( client.get(server.uri()).send().await?.status(), 200, "Subsequent requests should not use the invalid credentials" ); Ok(()) } #[test(tokio::test)] async fn test_netrc_file_default_host() -> Result<(), Error> { let username = "user"; let password = "password"; let mut netrc_file = NamedTempFile::new()?; writeln!(netrc_file, "default login {username} password {password}")?; let server = start_test_server(username, password).await; let client = test_client_builder() .with( AuthMiddleware::new() .with_cache(CredentialsCache::new()) .with_netrc(Netrc::from_file(netrc_file.path()).ok()), ) .build(); assert_eq!( client.get(server.uri()).send().await?.status(), 200, "Credentials should be pulled from the netrc file" ); let mut url = Url::parse(&server.uri())?; 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" ); assert_eq!( client.get(server.uri()).send().await?.status(), 200, "Subsequent requests should not use the invalid credentials" ); Ok(()) } #[test(tokio::test)] async fn test_netrc_file_matching_host() -> Result<(), Error> { let username = "user"; let password = "password"; let server = start_test_server(username, password).await; let base_url = Url::parse(&server.uri())?; let mut netrc_file = NamedTempFile::new()?; writeln!( netrc_file, r#"machine {} login {username} password {password}"#, base_url.host_str().unwrap() )?; let client = test_client_builder() .with( AuthMiddleware::new() .with_cache(CredentialsCache::new()) .with_netrc(Some( Netrc::from_file(netrc_file.path()).expect("Test has valid netrc file"), )), ) .build(); assert_eq!( client.get(server.uri()).send().await?.status(), 200, "Credentials should be pulled from the netrc file" ); 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" ); assert_eq!( client.get(server.uri()).send().await?.status(), 200, "Subsequent requests should not use the invalid credentials" ); Ok(()) } #[test(tokio::test)] async fn test_netrc_file_mismatched_host() -> Result<(), Error> { let username = "user"; let password = "password"; let server = start_test_server(username, password).await; let mut netrc_file = NamedTempFile::new()?; writeln!( netrc_file, r#"machine example.com login {username} password {password}"#, )?; let client = test_client_builder() .with( AuthMiddleware::new() .with_cache(CredentialsCache::new()) .with_netrc(Some( Netrc::from_file(netrc_file.path()).expect("Test has valid netrc file"), )), ) .build(); assert_eq!( client.get(server.uri()).send().await?.status(), 401, "Credentials should not be pulled from the netrc file due to host mismatch" ); let mut url = Url::parse(&server.uri())?; url.set_username(username).unwrap(); url.set_password(Some(password)).unwrap(); assert_eq!( client.get(url).send().await?.status(), 200, "Credentials in the URL should still work" ); Ok(()) } #[test(tokio::test)] async fn test_netrc_file_mismatched_username() -> Result<(), Error> { let username = "user"; let password = "password"; let server = start_test_server(username, password).await; let base_url = Url::parse(&server.uri())?; let mut netrc_file = NamedTempFile::new()?; writeln!( netrc_file, r#"machine {} login {username} password {password}"#, base_url.host_str().unwrap() )?; let client = test_client_builder() .with( AuthMiddleware::new() .with_cache(CredentialsCache::new()) .with_netrc(Some( Netrc::from_file(netrc_file.path()).expect("Test has valid netrc file"), )), ) .build(); let mut url = base_url.clone(); url.set_username("other-user").unwrap(); assert_eq!( client.get(url).send().await?.status(), 401, "The netrc password should not be used due to a username mismatch" ); let mut url = base_url.clone(); url.set_username("user").unwrap(); assert_eq!( client.get(url).send().await?.status(), 200, "The netrc password should be used for a matching user" ); Ok(()) } #[test(tokio::test)] async fn test_keyring() -> Result<(), Error> { let username = "user"; let password = "password"; let server = start_test_server(username, password).await; let base_url = Url::parse(&server.uri())?; let client = test_client_builder() .with( AuthMiddleware::new() .with_cache(CredentialsCache::new()) .with_keyring(Some(KeyringProvider::dummy([( ( format!( "{}:{}", base_url.host_str().unwrap(), base_url.port().unwrap() ), username, ), password, )]))), ) .build(); assert_eq!( client.get(server.uri()).send().await?.status(), 401, "Credentials are not pulled from the keyring without a username" ); let mut url = base_url.clone(); url.set_username(username).unwrap(); assert_eq!( client.get(url).send().await?.status(), 200, "Credentials for the username should be pulled from the keyring" ); 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, "Password in the URL should take precedence and fail" ); let mut url = base_url.clone(); url.set_username(username).unwrap(); assert_eq!( client.get(url.clone()).send().await?.status(), 200, "Subsequent requests should not use the invalid password" ); let mut url = base_url.clone(); url.set_username("other_user").unwrap(); assert_eq!( client.get(url).send().await?.status(), 401, "Credentials are not pulled from the keyring when given another username" ); Ok(()) } /// We include ports in keyring requests, e.g., `localhost:8000` should be distinct from `localhost`, /// unless the server is running on a default port, e.g., `localhost:80` is equivalent to `localhost`. /// We don't unit test the latter case because it's possible to collide with a server a developer is /// actually running. #[test(tokio::test)] async fn test_keyring_includes_non_standard_port() -> Result<(), Error> { let username = "user"; let password = "password"; let server = start_test_server(username, password).await; let base_url = Url::parse(&server.uri())?; let client = test_client_builder() .with( AuthMiddleware::new() .with_cache(CredentialsCache::new()) .with_keyring(Some(KeyringProvider::dummy([( // Omit the port from the keyring entry (base_url.host_str().unwrap(), username), password, )]))), ) .build(); let mut url = base_url.clone(); url.set_username(username).unwrap(); assert_eq!( client.get(url).send().await?.status(), 401, "We should fail because the port is not present in the keyring entry" ); Ok(()) } #[test(tokio::test)] async fn test_credentials_in_keyring_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(); // Seed _just_ the username. This cache entry should be ignored and we should // still find a password via the keyring. cache.insert( &base_url, Arc::new(Credentials::new(Some(username.to_string()), None)), ); let client = test_client_builder() .with(AuthMiddleware::new().with_cache(cache).with_keyring(Some( KeyringProvider::dummy([( ( format!( "{}:{}", base_url.host_str().unwrap(), base_url.port().unwrap() ), username, ), password, )]), ))) .build(); assert_eq!( client.get(server.uri()).send().await?.status(), 401, "Credentials are not pulled from the keyring without a username" ); let mut url = base_url.clone(); url.set_username(username).unwrap(); assert_eq!( client.get(url).send().await?.status(), 200, "Credentials for the username should be pulled from the keyring" ); 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([ ( ( format!( "{}:{}", base_url_1.host_str().unwrap(), base_url_1.port().unwrap() ), username_1, ), password_1, ), ( ( format!( "{}:{}", base_url_2.host_str().unwrap(), base_url_2.port().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!("{url_1}/foo")).send().await?.status(), 200, "Requests can be to different paths in the same realm" ); assert_eq!( client.get(format!("{url_2}/foo")).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 receives 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 receives 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([ ( ( format!( "{}:{}", base_url_1.host_str().unwrap(), base_url_1.port().unwrap() ), username_1, ), password_1, ), ( ( format!( "{}:{}", base_url_2.host_str().unwrap(), base_url_2.port().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(()) } /// Demonstrates "incorrect" behavior in our cache which avoids an expensive fetch of /// credentials for _every_ request URL at the cost of inconsistent behavior when /// credentials are not scoped to a realm. #[test(tokio::test)] async fn test_credentials_from_keyring_mixed_authentication_in_realm_same_username( ) -> Result<(), Error> { let username = "user"; let password_1 = "password1"; let password_2 = "password2"; let server = MockServer::start().await; Mock::given(method("GET")) .and(path_regex("/prefix_1.*")) .and(basic_auth(username, password_1)) .respond_with(ResponseTemplate::new(200)) .mount(&server) .await; Mock::given(method("GET")) .and(path_regex("/prefix_2.*")) .and(basic_auth(username, password_2)) .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 client = test_client_builder() .with( AuthMiddleware::new() .with_cache(CredentialsCache::new()) .with_keyring(Some(KeyringProvider::dummy([ ((base_url_1.clone(), username), password_1), ((base_url_2.clone(), username), 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).unwrap(); assert_eq!( client.get(url_1.clone()).send().await?.status(), 200, "The first request with a username will succeed" ); assert_eq!( client.get(base_url_2.clone()).send().await?.status(), 401, "Credentials should not be re-used for the second prefix" ); assert_eq!( client .get(base_url.join("prefix_1/foo")?) .send() .await? .status(), 200, "Subsequent requests can be to different paths in the same prefix" ); let mut url_2 = base_url_2.clone(); url_2.set_username(username).unwrap(); assert_eq!( client.get(url_2.clone()).send().await?.status(), 401, // INCORRECT BEHAVIOR "A request with the same username and realm for a URL that needs a different password will fail" ); assert_eq!( client .get(base_url.join("prefix_2/foo")?) .send() .await? .status(), 401, // INCORRECT BEHAVIOR "Requests to other paths in the failing prefix will also fail" ); Ok(()) } }