Skip to main content

uv_auth/
credentials.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::io::Read;
4use std::io::Write;
5use std::str::{FromStr, Utf8Error};
6
7use base64::prelude::BASE64_STANDARD;
8use base64::read::DecoderReader;
9use base64::write::EncoderWriter;
10use http::Uri;
11use reqsign::aws::DefaultSigner as AwsDefaultSigner;
12use reqsign::azure::DefaultSigner as AzureDefaultSigner;
13use reqsign::google::DefaultSigner as GcsDefaultSigner;
14use reqwest::Request;
15use reqwest::header::{HeaderName, HeaderValue};
16use serde::{Deserialize, Serialize};
17use thiserror::Error;
18use url::Url;
19
20use uv_netrc::Netrc;
21use uv_redacted::DisplaySafeUrl;
22use uv_static::EnvVars;
23
24const AZURE_STORAGE_VERSION: &str = "2023-11-03";
25
26#[derive(Clone, Debug, PartialEq, Eq)]
27pub enum Credentials {
28    /// RFC 7617 HTTP Basic Authentication
29    Basic {
30        /// The username to use for authentication.
31        username: Username,
32        /// The password to use for authentication.
33        password: Option<Password>,
34    },
35    /// RFC 6750 Bearer Token Authentication
36    Bearer {
37        /// The token to use for authentication.
38        token: Token,
39    },
40}
41
42#[derive(Debug, Error)]
43pub enum CredentialsFromUrlError {
44    #[error("URL username contains invalid UTF-8")]
45    InvalidUsernameUtf8(#[source] Utf8Error),
46    #[error("URL password contains invalid UTF-8")]
47    InvalidPasswordUtf8(#[source] Utf8Error),
48}
49
50#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash, Default, Serialize, Deserialize)]
51#[serde(transparent)]
52pub struct Username(Option<String>);
53
54impl Username {
55    /// Create a new username.
56    ///
57    /// Unlike `reqwest`, empty usernames are be encoded as `None` instead of an empty string.
58    pub(crate) fn new(value: Option<String>) -> Self {
59        // Ensure empty strings are `None`
60        Self(value.filter(|s| !s.is_empty()))
61    }
62
63    pub(crate) fn none() -> Self {
64        Self::new(None)
65    }
66
67    pub(crate) fn is_none(&self) -> bool {
68        self.0.is_none()
69    }
70
71    pub(crate) fn is_some(&self) -> bool {
72        self.0.is_some()
73    }
74
75    pub(crate) fn as_deref(&self) -> Option<&str> {
76        self.0.as_deref()
77    }
78}
79
80impl From<String> for Username {
81    fn from(value: String) -> Self {
82        Self::new(Some(value))
83    }
84}
85
86impl From<Option<String>> for Username {
87    fn from(value: Option<String>) -> Self {
88        Self::new(value)
89    }
90}
91
92#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default, Serialize, Deserialize)]
93#[serde(transparent)]
94pub struct Password(String);
95
96impl Password {
97    pub fn new(password: String) -> Self {
98        Self(password)
99    }
100
101    /// Return the [`Password`] as a string slice.
102    fn as_str(&self) -> &str {
103        self.0.as_str()
104    }
105}
106
107impl fmt::Debug for Password {
108    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109        write!(f, "****")
110    }
111}
112
113#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default, Deserialize)]
114#[serde(transparent)]
115pub struct Token(Vec<u8>);
116
117impl Token {
118    pub(crate) fn new(token: Vec<u8>) -> Self {
119        Self(token)
120    }
121
122    /// Return the [`Token`] as a byte slice.
123    fn as_slice(&self) -> &[u8] {
124        self.0.as_slice()
125    }
126
127    /// Convert the [`Token`] into its underlying [`Vec<u8>`].
128    pub(crate) fn into_bytes(self) -> Vec<u8> {
129        self.0
130    }
131
132    /// Return whether the [`Token`] is empty.
133    fn is_empty(&self) -> bool {
134        self.0.is_empty()
135    }
136}
137
138impl fmt::Debug for Token {
139    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
140        write!(f, "****")
141    }
142}
143impl Credentials {
144    /// Create a set of HTTP Basic Authentication credentials.
145    #[allow(dead_code)]
146    pub fn basic(username: Option<String>, password: Option<String>) -> Self {
147        Self::Basic {
148            username: Username::new(username),
149            password: password.map(Password),
150        }
151    }
152
153    /// Create a set of Bearer Authentication credentials.
154    #[allow(dead_code)]
155    pub fn bearer(token: Vec<u8>) -> Self {
156        Self::Bearer {
157            token: Token::new(token),
158        }
159    }
160
161    pub fn username(&self) -> Option<&str> {
162        match self {
163            Self::Basic { username, .. } => username.as_deref(),
164            Self::Bearer { .. } => None,
165        }
166    }
167
168    fn to_username(&self) -> Username {
169        match self {
170            Self::Basic { username, .. } => username.clone(),
171            Self::Bearer { .. } => Username::none(),
172        }
173    }
174
175    fn as_username(&self) -> Cow<'_, Username> {
176        match self {
177            Self::Basic { username, .. } => Cow::Borrowed(username),
178            Self::Bearer { .. } => Cow::Owned(Username::none()),
179        }
180    }
181
182    pub fn password(&self) -> Option<&str> {
183        match self {
184            Self::Basic { password, .. } => password.as_ref().map(Password::as_str),
185            Self::Bearer { .. } => None,
186        }
187    }
188
189    fn is_authenticated(&self) -> bool {
190        match self {
191            Self::Basic {
192                username: _,
193                password,
194            } => password.is_some(),
195            Self::Bearer { token } => !token.is_empty(),
196        }
197    }
198
199    fn is_empty(&self) -> bool {
200        match self {
201            Self::Basic { username, password } => username.is_none() && password.is_none(),
202            Self::Bearer { token } => token.is_empty(),
203        }
204    }
205
206    /// Return [`Credentials`] for a [`Url`] from a [`Netrc`] file, if any.
207    ///
208    /// If a username is provided, it must match the login in the netrc file or [`None`] is returned.
209    pub(crate) fn from_netrc(
210        netrc: &Netrc,
211        url: &DisplaySafeUrl,
212        username: Option<&str>,
213    ) -> Option<Self> {
214        let host = url.host_str()?;
215        let entry = netrc
216            .hosts
217            .get(host)
218            .or_else(|| netrc.hosts.get("default"))?;
219
220        // Ensure the username matches if provided
221        if username.is_some_and(|username| username != entry.login) {
222            return None;
223        }
224
225        Some(Self::Basic {
226            username: Username::new(Some(entry.login.clone())),
227            password: Some(Password(entry.password.clone())),
228        })
229    }
230
231    /// Parse [`Credentials`] from a URL, if any.
232    ///
233    /// Returns [`None`] if both [`Url::username`] and [`Url::password`] are not populated.
234    pub fn from_url(url: &Url) -> Result<Option<Self>, CredentialsFromUrlError> {
235        if url.username().is_empty() && url.password().is_none() {
236            return Ok(None);
237        }
238
239        // Remove percent-encoding from URL credentials.
240        // See <https://github.com/pypa/pip/blob/06d21db4ff1ab69665c22a88718a4ea9757ca293/src/pip/_internal/utils/misc.py#L497-L499>
241        let username = if url.username().is_empty() {
242            None
243        } else {
244            Some(
245                percent_encoding::percent_decode_str(url.username())
246                    .decode_utf8()
247                    .map_err(CredentialsFromUrlError::InvalidUsernameUtf8)?
248                    .into_owned(),
249            )
250        };
251        let password = url
252            .password()
253            .map(|password| {
254                percent_encoding::percent_decode_str(password)
255                    .decode_utf8()
256                    .map(|password| Password(password.into_owned()))
257                    .map_err(CredentialsFromUrlError::InvalidPasswordUtf8)
258            })
259            .transpose()?;
260
261        Ok(Some(Self::Basic {
262            username: username.into(),
263            password,
264        }))
265    }
266
267    /// Extract the [`Credentials`] from the environment, given a named source.
268    ///
269    /// For example, given a name of `"pytorch"`, search for `UV_INDEX_PYTORCH_USERNAME` and
270    /// `UV_INDEX_PYTORCH_PASSWORD`.
271    pub fn from_env(name: impl AsRef<str>) -> Option<Self> {
272        let username = std::env::var(EnvVars::index_username(name.as_ref())).ok();
273        let password = std::env::var(EnvVars::index_password(name.as_ref())).ok();
274        if username.is_none() && password.is_none() {
275            None
276        } else {
277            Some(Self::basic(username, password))
278        }
279    }
280
281    /// Parse [`Credentials`] from an HTTP request, if any.
282    ///
283    /// Only HTTP Basic Authentication is supported.
284    pub(crate) fn from_request(request: &Request) -> Result<Option<Self>, CredentialsFromUrlError> {
285        // First, attempt to retrieve the credentials from the URL
286        if let Some(credentials) = Self::from_url(request.url())? {
287            return Ok(Some(credentials));
288        }
289
290        // Then, attempt to pull the credentials from the headers
291        Ok(request
292            .headers()
293            .get(reqwest::header::AUTHORIZATION)
294            .and_then(Self::from_header_value))
295    }
296
297    /// Parse [`Credentials`] from an authorization header, if any.
298    ///
299    /// HTTP Basic and Bearer Authentication are both supported.
300    /// [`None`] will be returned if another authorization scheme is detected.
301    ///
302    /// Panics if the authentication is not conformant to the HTTP Basic Authentication scheme:
303    /// - The contents must be base64 encoded
304    /// - There must be a `:` separator
305    fn from_header_value(header: &HeaderValue) -> Option<Self> {
306        // Parse a `Basic` authentication header.
307        if let Some(mut value) = header.as_bytes().strip_prefix(b"Basic ") {
308            let mut decoder = DecoderReader::new(&mut value, &BASE64_STANDARD);
309            let mut buf = String::new();
310            decoder
311                .read_to_string(&mut buf)
312                .expect("HTTP Basic Authentication should be base64 encoded");
313            let (username, password) = buf
314                .split_once(':')
315                .expect("HTTP Basic Authentication should include a `:` separator");
316            let username = if username.is_empty() {
317                None
318            } else {
319                Some(username.to_string())
320            };
321            let password = if password.is_empty() {
322                None
323            } else {
324                Some(password.to_string())
325            };
326            return Some(Self::Basic {
327                username: Username::new(username),
328                password: password.map(Password),
329            });
330        }
331
332        // Parse a `Bearer` authentication header.
333        if let Some(token) = header.as_bytes().strip_prefix(b"Bearer ") {
334            return Some(Self::Bearer {
335                token: Token::new(token.to_vec()),
336            });
337        }
338
339        None
340    }
341
342    /// Create an HTTP Basic Authentication header for the credentials.
343    ///
344    /// Panics if the username or password cannot be base64 encoded.
345    pub fn to_header_value(&self) -> HeaderValue {
346        match self {
347            Self::Basic { .. } => {
348                // See: <https://github.com/seanmonstar/reqwest/blob/2c11ef000b151c2eebeed2c18a7b81042220c6b0/src/util.rs#L3>
349                let mut buf = b"Basic ".to_vec();
350                {
351                    let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD);
352                    write!(encoder, "{}:", self.username().unwrap_or_default())
353                        .expect("Write to base64 encoder should succeed");
354                    if let Some(password) = self.password() {
355                        write!(encoder, "{password}")
356                            .expect("Write to base64 encoder should succeed");
357                    }
358                }
359                let mut header =
360                    HeaderValue::from_bytes(&buf).expect("base64 is always valid HeaderValue");
361                header.set_sensitive(true);
362                header
363            }
364            Self::Bearer { token } => {
365                let mut header = HeaderValue::from_bytes(&[b"Bearer ", token.as_slice()].concat())
366                    .expect("Bearer token is always valid HeaderValue");
367                header.set_sensitive(true);
368                header
369            }
370        }
371    }
372
373    /// Apply the credentials to the given URL.
374    ///
375    /// Any existing credentials will be overridden.
376    #[must_use]
377    pub fn apply(&self, mut url: DisplaySafeUrl) -> DisplaySafeUrl {
378        if let Some(username) = self.username() {
379            let _ = url.set_username(username);
380        }
381        if let Some(password) = self.password() {
382            let _ = url.set_password(Some(password));
383        }
384        url
385    }
386
387    /// Attach the credentials to the given request.
388    ///
389    /// Any existing credentials will be overridden.
390    #[must_use]
391    pub fn authenticate(&self, mut request: Request) -> Request {
392        request
393            .headers_mut()
394            .insert(reqwest::header::AUTHORIZATION, Self::to_header_value(self));
395        request
396    }
397}
398
399#[derive(Clone, Debug)]
400pub(crate) enum Authentication {
401    /// HTTP Basic or Bearer Authentication credentials.
402    Credentials(Credentials),
403
404    /// AWS Signature Version 4 signing.
405    AwsSigner(AwsDefaultSigner),
406
407    /// Google Cloud signing.
408    GcsSigner(GcsDefaultSigner),
409
410    /// Azure Storage signing.
411    AzureSigner(AzureDefaultSigner),
412}
413
414#[derive(Debug, Error)]
415pub(crate) enum AuthenticationError {
416    #[error("Failed to convert request URL to URI")]
417    InvalidUri(#[from] http::uri::InvalidUri),
418
419    #[error("Failed to build request for {provider} signing")]
420    BuildRequest {
421        provider: &'static str,
422        #[source]
423        source: http::Error,
424    },
425
426    #[error("Failed to sign request with {provider} credentials")]
427    Sign {
428        provider: &'static str,
429        #[source]
430        source: reqsign::Error,
431    },
432}
433
434impl PartialEq for Authentication {
435    fn eq(&self, other: &Self) -> bool {
436        match (self, other) {
437            (Self::Credentials(a), Self::Credentials(b)) => a == b,
438            (Self::AwsSigner(..), Self::AwsSigner(..)) => true,
439            (Self::GcsSigner(..), Self::GcsSigner(..)) => true,
440            (Self::AzureSigner(..), Self::AzureSigner(..)) => true,
441            _ => false,
442        }
443    }
444}
445
446impl Eq for Authentication {}
447
448impl From<Credentials> for Authentication {
449    fn from(credentials: Credentials) -> Self {
450        Self::Credentials(credentials)
451    }
452}
453
454impl From<AwsDefaultSigner> for Authentication {
455    fn from(signer: AwsDefaultSigner) -> Self {
456        Self::AwsSigner(signer)
457    }
458}
459
460impl From<GcsDefaultSigner> for Authentication {
461    fn from(signer: GcsDefaultSigner) -> Self {
462        Self::GcsSigner(signer)
463    }
464}
465
466impl From<AzureDefaultSigner> for Authentication {
467    fn from(signer: AzureDefaultSigner) -> Self {
468        Self::AzureSigner(signer)
469    }
470}
471
472impl Authentication {
473    /// Return the password used for authentication, if any.
474    pub(crate) fn password(&self) -> Option<&str> {
475        match self {
476            Self::Credentials(credentials) => credentials.password(),
477            Self::AwsSigner(..) | Self::GcsSigner(..) | Self::AzureSigner(..) => None,
478        }
479    }
480
481    /// Return the username used for authentication, if any.
482    pub(crate) fn username(&self) -> Option<&str> {
483        match self {
484            Self::Credentials(credentials) => credentials.username(),
485            Self::AwsSigner(..) | Self::GcsSigner(..) | Self::AzureSigner(..) => None,
486        }
487    }
488
489    /// Return the username used for authentication, if any.
490    pub(crate) fn as_username(&self) -> Cow<'_, Username> {
491        match self {
492            Self::Credentials(credentials) => credentials.as_username(),
493            Self::AwsSigner(..) | Self::GcsSigner(..) | Self::AzureSigner(..) => {
494                Cow::Owned(Username::none())
495            }
496        }
497    }
498
499    /// Return the username used for authentication, if any.
500    pub(crate) fn to_username(&self) -> Username {
501        match self {
502            Self::Credentials(credentials) => credentials.to_username(),
503            Self::AwsSigner(..) | Self::GcsSigner(..) | Self::AzureSigner(..) => Username::none(),
504        }
505    }
506
507    /// Return `true` if the object contains a means of authenticating.
508    pub(crate) fn is_authenticated(&self) -> bool {
509        match self {
510            Self::Credentials(credentials) => credentials.is_authenticated(),
511            Self::AwsSigner(..) | Self::GcsSigner(..) | Self::AzureSigner(..) => true,
512        }
513    }
514
515    /// Return `true` if the object contains no credentials.
516    pub(crate) fn is_empty(&self) -> bool {
517        match self {
518            Self::Credentials(credentials) => credentials.is_empty(),
519            Self::AwsSigner(..) | Self::GcsSigner(..) | Self::AzureSigner(..) => false,
520        }
521    }
522
523    /// Apply the authentication to the given request.
524    ///
525    /// Any existing credentials will be overridden.
526    pub(crate) async fn authenticate(
527        &self,
528        mut request: Request,
529    ) -> Result<Request, AuthenticationError> {
530        match self {
531            Self::Credentials(credentials) => Ok(credentials.authenticate(request)),
532            Self::AwsSigner(signer) => {
533                // Build an `http::Request` from the `reqwest::Request`.
534                let uri = Uri::from_str(request.url().as_str())?;
535                let mut http_req = http::Request::builder()
536                    .method(request.method().clone())
537                    .uri(uri)
538                    .body(())
539                    .map_err(|source| AuthenticationError::BuildRequest {
540                        provider: "AWS",
541                        source,
542                    })?;
543                *http_req.headers_mut() = request.headers().clone();
544
545                // Sign the parts.
546                let (mut parts, ()) = http_req.into_parts();
547                signer.sign(&mut parts, None).await.map_err(|source| {
548                    AuthenticationError::Sign {
549                        provider: "AWS",
550                        source,
551                    }
552                })?;
553
554                // Copy over the signed headers.
555                request.headers_mut().extend(parts.headers);
556
557                // Copy over the signed path and query, if any.
558                if let Some(path_and_query) = parts.uri.path_and_query() {
559                    request.url_mut().set_path(path_and_query.path());
560                    request.url_mut().set_query(path_and_query.query());
561                }
562                Ok(request)
563            }
564            Self::GcsSigner(signer) => {
565                // Build an `http::Request` from the `reqwest::Request`.
566                let uri = Uri::from_str(request.url().as_str())?;
567                let mut http_req = http::Request::builder()
568                    .method(request.method().clone())
569                    .uri(uri)
570                    .body(())
571                    .map_err(|source| AuthenticationError::BuildRequest {
572                        provider: "GCS",
573                        source,
574                    })?;
575                *http_req.headers_mut() = request.headers().clone();
576
577                // Sign the parts.
578                let (mut parts, ()) = http_req.into_parts();
579                signer.sign(&mut parts, None).await.map_err(|source| {
580                    AuthenticationError::Sign {
581                        provider: "GCS",
582                        source,
583                    }
584                })?;
585
586                // Copy over the signed headers.
587                request.headers_mut().extend(parts.headers);
588
589                // Copy over the signed path and query, if any.
590                if let Some(path_and_query) = parts.uri.path_and_query() {
591                    request.url_mut().set_path(path_and_query.path());
592                    request.url_mut().set_query(path_and_query.query());
593                }
594                Ok(request)
595            }
596            Self::AzureSigner(signer) => {
597                // Build an `http::Request` from the `reqwest::Request`.
598                let uri = Uri::from_str(request.url().as_str())?;
599                let mut http_req = http::Request::builder()
600                    .method(request.method().clone())
601                    .uri(uri)
602                    .body(())
603                    .map_err(|source| AuthenticationError::BuildRequest {
604                        provider: "Azure",
605                        source,
606                    })?;
607                *http_req.headers_mut() = request.headers().clone();
608                http_req
609                    .headers_mut()
610                    .entry(HeaderName::from_static("x-ms-version"))
611                    .or_insert(HeaderValue::from_static(AZURE_STORAGE_VERSION));
612
613                // Sign the parts.
614                let (mut parts, ()) = http_req.into_parts();
615                signer.sign(&mut parts, None).await.map_err(|source| {
616                    AuthenticationError::Sign {
617                        provider: "Azure",
618                        source,
619                    }
620                })?;
621
622                // Copy over the signed headers.
623                request.headers_mut().extend(parts.headers);
624
625                // Copy over the signed path and query, if any.
626                if let Some(path_and_query) = parts.uri.path_and_query() {
627                    request.url_mut().set_path(path_and_query.path());
628                    request.url_mut().set_query(path_and_query.query());
629                }
630                Ok(request)
631            }
632        }
633    }
634}
635
636#[cfg(test)]
637mod tests {
638    use insta::{assert_debug_snapshot, assert_snapshot};
639    use reqsign::aws::Credential as AwsCredential;
640    use reqsign::azure::Credential as AzureCredential;
641    use reqsign::{Context, ProvideCredential};
642
643    use super::*;
644
645    #[derive(Debug)]
646    struct EmptyAwsCredentialProvider;
647
648    impl ProvideCredential for EmptyAwsCredentialProvider {
649        type Credential = AwsCredential;
650
651        async fn provide_credential(
652            &self,
653            _ctx: &Context,
654        ) -> reqsign::Result<Option<Self::Credential>> {
655            Ok(None)
656        }
657    }
658
659    #[derive(Debug)]
660    struct EmptyAzureCredentialProvider;
661
662    impl ProvideCredential for EmptyAzureCredentialProvider {
663        type Credential = AzureCredential;
664
665        async fn provide_credential(
666            &self,
667            _ctx: &Context,
668        ) -> reqsign::Result<Option<Self::Credential>> {
669            Ok(None)
670        }
671    }
672
673    #[test]
674    fn from_url_no_credentials() {
675        let url = &Url::parse("https://example.com/simple/first/").unwrap();
676        assert!(matches!(Credentials::from_url(url), Ok(None)));
677    }
678
679    #[test]
680    fn from_url_username_and_password() {
681        let url = &Url::parse("https://example.com/simple/first/").unwrap();
682        let mut auth_url = url.clone();
683        auth_url.set_username("user").unwrap();
684        auth_url.set_password(Some("password")).unwrap();
685        let credentials = Credentials::from_url(&auth_url).unwrap().unwrap();
686        assert_eq!(credentials.username(), Some("user"));
687        assert_eq!(credentials.password(), Some("password"));
688    }
689
690    #[test]
691    fn from_url_invalid_utf8_username() {
692        let url = Url::parse("https://%FF:password@example.com/simple/first/").unwrap();
693        let error = Credentials::from_url(&url).unwrap_err();
694        assert_snapshot!(error, @"URL username contains invalid UTF-8");
695    }
696
697    #[test]
698    fn from_url_invalid_utf8_password() {
699        let url = Url::parse("https://user:%FF@example.com/simple/first/").unwrap();
700        let error = Credentials::from_url(&url).unwrap_err();
701        assert_snapshot!(error, @"URL password contains invalid UTF-8");
702    }
703
704    #[test]
705    fn from_url_no_username() {
706        let url = &Url::parse("https://example.com/simple/first/").unwrap();
707        let mut auth_url = url.clone();
708        auth_url.set_password(Some("password")).unwrap();
709        let credentials = Credentials::from_url(&auth_url).unwrap().unwrap();
710        assert_eq!(credentials.username(), None);
711        assert_eq!(credentials.password(), Some("password"));
712    }
713
714    /// Test for <https://github.com/astral-sh/uv/issues/17343>
715    ///
716    /// URLs with an empty username but a password (e.g., `https://:token@example.com`)
717    /// should be recognized as having credentials.
718    #[test]
719    fn from_url_empty_username_with_password() {
720        // Parse a URL with the format `:password@host` directly
721        let url = Url::parse("https://:token@example.com/simple/first/").unwrap();
722        let credentials = Credentials::from_url(&url).unwrap().unwrap();
723        assert_eq!(credentials.username(), None);
724        assert_eq!(credentials.password(), Some("token"));
725        assert!(
726            credentials.is_authenticated(),
727            "URL with empty username but password should be considered authenticated"
728        );
729    }
730
731    #[test]
732    fn from_url_no_password() {
733        let url = &Url::parse("https://example.com/simple/first/").unwrap();
734        let mut auth_url = url.clone();
735        auth_url.set_username("user").unwrap();
736        let credentials = Credentials::from_url(&auth_url).unwrap().unwrap();
737        assert_eq!(credentials.username(), Some("user"));
738        assert_eq!(credentials.password(), None);
739    }
740
741    #[test]
742    fn authenticated_request_from_url() {
743        let url = Url::parse("https://example.com/simple/first/").unwrap();
744        let mut auth_url = url.clone();
745        auth_url.set_username("user").unwrap();
746        auth_url.set_password(Some("password")).unwrap();
747        let credentials = Credentials::from_url(&auth_url).unwrap().unwrap();
748
749        let mut request = Request::new(reqwest::Method::GET, url);
750        request = credentials.authenticate(request);
751
752        let mut header = request
753            .headers()
754            .get(reqwest::header::AUTHORIZATION)
755            .expect("Authorization header should be set")
756            .clone();
757        header.set_sensitive(false);
758
759        assert_debug_snapshot!(header, @r#""Basic dXNlcjpwYXNzd29yZA==""#);
760        assert_eq!(Credentials::from_header_value(&header), Some(credentials));
761    }
762
763    #[test]
764    fn authenticated_request_from_url_with_percent_encoded_user() {
765        let url = Url::parse("https://example.com/simple/first/").unwrap();
766        let mut auth_url = url.clone();
767        auth_url.set_username("user@domain").unwrap();
768        auth_url.set_password(Some("password")).unwrap();
769        let credentials = Credentials::from_url(&auth_url).unwrap().unwrap();
770
771        let mut request = Request::new(reqwest::Method::GET, url);
772        request = credentials.authenticate(request);
773
774        let mut header = request
775            .headers()
776            .get(reqwest::header::AUTHORIZATION)
777            .expect("Authorization header should be set")
778            .clone();
779        header.set_sensitive(false);
780
781        assert_debug_snapshot!(header, @r#""Basic dXNlckBkb21haW46cGFzc3dvcmQ=""#);
782        assert_eq!(Credentials::from_header_value(&header), Some(credentials));
783    }
784
785    #[test]
786    fn authenticated_request_from_url_with_percent_encoded_password() {
787        let url = Url::parse("https://example.com/simple/first/").unwrap();
788        let mut auth_url = url.clone();
789        auth_url.set_username("user").unwrap();
790        auth_url.set_password(Some("password==")).unwrap();
791        let credentials = Credentials::from_url(&auth_url).unwrap().unwrap();
792
793        let mut request = Request::new(reqwest::Method::GET, url);
794        request = credentials.authenticate(request);
795
796        let mut header = request
797            .headers()
798            .get(reqwest::header::AUTHORIZATION)
799            .expect("Authorization header should be set")
800            .clone();
801        header.set_sensitive(false);
802
803        assert_debug_snapshot!(header, @r#""Basic dXNlcjpwYXNzd29yZD09""#);
804        assert_eq!(Credentials::from_header_value(&header), Some(credentials));
805    }
806
807    #[tokio::test]
808    async fn authenticated_request_with_azure_signer() {
809        let signer = reqsign::azure::default_signer().with_credential_provider(
810            reqsign::azure::StaticCredentialProvider::new_bearer_token("token"),
811        );
812        let authentication = Authentication::from(signer);
813
814        let request = Request::new(
815            reqwest::Method::GET,
816            Url::parse("https://account.blob.core.windows.net/container/blob.whl").unwrap(),
817        );
818        let request = authentication.authenticate(request).await.unwrap();
819
820        let authorization = request
821            .headers()
822            .get(reqwest::header::AUTHORIZATION)
823            .expect("Authorization header should be set");
824        assert_eq!(authorization.to_str().unwrap(), "Bearer token");
825        assert!(request.headers().contains_key("x-ms-date"));
826        assert_eq!(
827            request
828                .headers()
829                .get("x-ms-version")
830                .expect("x-ms-version header should be set")
831                .to_str()
832                .unwrap(),
833            AZURE_STORAGE_VERSION
834        );
835    }
836
837    #[tokio::test]
838    async fn authenticated_request_with_aws_signer_missing_credentials() {
839        let signer = reqsign::aws::default_signer("s3", "us-east-1")
840            .with_credential_provider(EmptyAwsCredentialProvider);
841        let authentication = Authentication::from(signer);
842
843        let request = Request::new(
844            reqwest::Method::GET,
845            Url::parse("https://s3.amazonaws.com/bucket/blob.whl").unwrap(),
846        );
847        let err = authentication.authenticate(request).await.unwrap_err();
848
849        insta::assert_snapshot!(
850            err.to_string(),
851            @"Failed to sign request with AWS credentials"
852        );
853    }
854
855    #[tokio::test]
856    async fn authenticated_request_with_azure_signer_missing_credentials() {
857        let signer =
858            reqsign::azure::default_signer().with_credential_provider(EmptyAzureCredentialProvider);
859        let authentication = Authentication::from(signer);
860
861        let request = Request::new(
862            reqwest::Method::GET,
863            Url::parse("https://account.blob.core.windows.net/container/blob.whl").unwrap(),
864        );
865        let err = authentication.authenticate(request).await.unwrap_err();
866
867        insta::assert_snapshot!(
868            err.to_string(),
869            @"Failed to sign request with Azure credentials"
870        );
871    }
872
873    /// Passwords should be redacted in debug output.
874    #[test]
875    fn test_password_redaction() {
876        let credentials =
877            Credentials::basic(Some(String::from("user")), Some(String::from("password")));
878        insta::assert_compact_debug_snapshot!(credentials, @r#"Basic { username: Username(Some("user")), password: Some(****) }"#);
879    }
880
881    /// Bearer credentials should be redacted in debug output.
882    #[test]
883    fn test_bearer_token_redaction() {
884        let token = "super_secret_token";
885        let credentials = Credentials::bearer(token.into());
886        insta::assert_compact_debug_snapshot!(credentials, @"Bearer { token: **** }");
887    }
888}