uv_auth/
credentials.rs

1use std::borrow::Cow;
2use std::fmt;
3use std::io::Read;
4use std::io::Write;
5use std::str::FromStr;
6
7use base64::prelude::BASE64_STANDARD;
8use base64::read::DecoderReader;
9use base64::write::EncoderWriter;
10use http::Uri;
11use netrc::Netrc;
12use reqsign::aws::DefaultSigner as AwsDefaultSigner;
13use reqsign::google::DefaultSigner as GcsDefaultSigner;
14use reqwest::Request;
15use reqwest::header::HeaderValue;
16use serde::{Deserialize, Serialize};
17use url::Url;
18
19use uv_redacted::DisplaySafeUrl;
20use uv_static::EnvVars;
21
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum Credentials {
24    /// RFC 7617 HTTP Basic Authentication
25    Basic {
26        /// The username to use for authentication.
27        username: Username,
28        /// The password to use for authentication.
29        password: Option<Password>,
30    },
31    /// RFC 6750 Bearer Token Authentication
32    Bearer {
33        /// The token to use for authentication.
34        token: Token,
35    },
36}
37
38#[derive(Clone, Debug, PartialEq, Eq, Ord, PartialOrd, Hash, Default, Serialize, Deserialize)]
39#[serde(transparent)]
40pub struct Username(Option<String>);
41
42impl Username {
43    /// Create a new username.
44    ///
45    /// Unlike `reqwest`, empty usernames are be encoded as `None` instead of an empty string.
46    pub(crate) fn new(value: Option<String>) -> Self {
47        // Ensure empty strings are `None`
48        Self(value.filter(|s| !s.is_empty()))
49    }
50
51    pub(crate) fn none() -> Self {
52        Self::new(None)
53    }
54
55    pub(crate) fn is_none(&self) -> bool {
56        self.0.is_none()
57    }
58
59    pub(crate) fn is_some(&self) -> bool {
60        self.0.is_some()
61    }
62
63    pub(crate) fn as_deref(&self) -> Option<&str> {
64        self.0.as_deref()
65    }
66}
67
68impl From<String> for Username {
69    fn from(value: String) -> Self {
70        Self::new(Some(value))
71    }
72}
73
74impl From<Option<String>> for Username {
75    fn from(value: Option<String>) -> Self {
76        Self::new(value)
77    }
78}
79
80#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default, Serialize, Deserialize)]
81#[serde(transparent)]
82pub struct Password(String);
83
84impl Password {
85    pub fn new(password: String) -> Self {
86        Self(password)
87    }
88
89    /// Return the [`Password`] as a string slice.
90    pub fn as_str(&self) -> &str {
91        self.0.as_str()
92    }
93
94    /// Convert the [`Password`] into its underlying [`String`].
95    pub fn into_string(self) -> String {
96        self.0
97    }
98}
99
100impl fmt::Debug for Password {
101    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102        write!(f, "****")
103    }
104}
105
106#[derive(Clone, PartialEq, Eq, Ord, PartialOrd, Hash, Default, Deserialize)]
107#[serde(transparent)]
108pub struct Token(Vec<u8>);
109
110impl Token {
111    pub fn new(token: Vec<u8>) -> Self {
112        Self(token)
113    }
114
115    /// Return the [`Token`] as a byte slice.
116    pub fn as_slice(&self) -> &[u8] {
117        self.0.as_slice()
118    }
119
120    /// Convert the [`Token`] into its underlying [`Vec<u8>`].
121    pub fn into_bytes(self) -> Vec<u8> {
122        self.0
123    }
124
125    /// Return whether the [`Token`] is empty.
126    pub fn is_empty(&self) -> bool {
127        self.0.is_empty()
128    }
129}
130
131impl fmt::Debug for Token {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        write!(f, "****")
134    }
135}
136impl Credentials {
137    /// Create a set of HTTP Basic Authentication credentials.
138    #[allow(dead_code)]
139    pub fn basic(username: Option<String>, password: Option<String>) -> Self {
140        Self::Basic {
141            username: Username::new(username),
142            password: password.map(Password),
143        }
144    }
145
146    /// Create a set of Bearer Authentication credentials.
147    #[allow(dead_code)]
148    pub fn bearer(token: Vec<u8>) -> Self {
149        Self::Bearer {
150            token: Token::new(token),
151        }
152    }
153
154    pub fn username(&self) -> Option<&str> {
155        match self {
156            Self::Basic { username, .. } => username.as_deref(),
157            Self::Bearer { .. } => None,
158        }
159    }
160
161    pub(crate) fn to_username(&self) -> Username {
162        match self {
163            Self::Basic { username, .. } => username.clone(),
164            Self::Bearer { .. } => Username::none(),
165        }
166    }
167
168    pub(crate) fn as_username(&self) -> Cow<'_, Username> {
169        match self {
170            Self::Basic { username, .. } => Cow::Borrowed(username),
171            Self::Bearer { .. } => Cow::Owned(Username::none()),
172        }
173    }
174
175    pub fn password(&self) -> Option<&str> {
176        match self {
177            Self::Basic { password, .. } => password.as_ref().map(Password::as_str),
178            Self::Bearer { .. } => None,
179        }
180    }
181
182    pub fn is_authenticated(&self) -> bool {
183        match self {
184            Self::Basic {
185                username: _,
186                password,
187            } => password.is_some(),
188            Self::Bearer { token } => !token.is_empty(),
189        }
190    }
191
192    pub(crate) fn is_empty(&self) -> bool {
193        match self {
194            Self::Basic { username, password } => username.is_none() && password.is_none(),
195            Self::Bearer { token } => token.is_empty(),
196        }
197    }
198
199    /// Return [`Credentials`] for a [`Url`] from a [`Netrc`] file, if any.
200    ///
201    /// If a username is provided, it must match the login in the netrc file or [`None`] is returned.
202    pub(crate) fn from_netrc(
203        netrc: &Netrc,
204        url: &DisplaySafeUrl,
205        username: Option<&str>,
206    ) -> Option<Self> {
207        let host = url.host_str()?;
208        let entry = netrc
209            .hosts
210            .get(host)
211            .or_else(|| netrc.hosts.get("default"))?;
212
213        // Ensure the username matches if provided
214        if username.is_some_and(|username| username != entry.login) {
215            return None;
216        }
217
218        Some(Self::Basic {
219            username: Username::new(Some(entry.login.clone())),
220            password: Some(Password(entry.password.clone())),
221        })
222    }
223
224    /// Parse [`Credentials`] from a URL, if any.
225    ///
226    /// Returns [`None`] if both [`Url::username`] and [`Url::password`] are not populated.
227    pub fn from_url(url: &Url) -> Option<Self> {
228        if url.username().is_empty() && url.password().is_none() {
229            return None;
230        }
231        Some(Self::Basic {
232            // Remove percent-encoding from URL credentials
233            // See <https://github.com/pypa/pip/blob/06d21db4ff1ab69665c22a88718a4ea9757ca293/src/pip/_internal/utils/misc.py#L497-L499>
234            username: if url.username().is_empty() {
235                None
236            } else {
237                Some(
238                    percent_encoding::percent_decode_str(url.username())
239                        .decode_utf8()
240                        .expect("An encoded username should always decode")
241                        .into_owned(),
242                )
243            }
244            .into(),
245            password: url.password().map(|password| {
246                Password(
247                    percent_encoding::percent_decode_str(password)
248                        .decode_utf8()
249                        .expect("An encoded password should always decode")
250                        .into_owned(),
251                )
252            }),
253        })
254    }
255
256    /// Extract the [`Credentials`] from the environment, given a named source.
257    ///
258    /// For example, given a name of `"pytorch"`, search for `UV_INDEX_PYTORCH_USERNAME` and
259    /// `UV_INDEX_PYTORCH_PASSWORD`.
260    pub fn from_env(name: impl AsRef<str>) -> Option<Self> {
261        let username = std::env::var(EnvVars::index_username(name.as_ref())).ok();
262        let password = std::env::var(EnvVars::index_password(name.as_ref())).ok();
263        if username.is_none() && password.is_none() {
264            None
265        } else {
266            Some(Self::basic(username, password))
267        }
268    }
269
270    /// Parse [`Credentials`] from an HTTP request, if any.
271    ///
272    /// Only HTTP Basic Authentication is supported.
273    pub(crate) fn from_request(request: &Request) -> Option<Self> {
274        // First, attempt to retrieve the credentials from the URL
275        Self::from_url(request.url()).or(
276            // Then, attempt to pull the credentials from the headers
277            request
278                .headers()
279                .get(reqwest::header::AUTHORIZATION)
280                .map(Self::from_header_value)?,
281        )
282    }
283
284    /// Parse [`Credentials`] from an authorization header, if any.
285    ///
286    /// HTTP Basic and Bearer Authentication are both supported.
287    /// [`None`] will be returned if another authorization scheme is detected.
288    ///
289    /// Panics if the authentication is not conformant to the HTTP Basic Authentication scheme:
290    /// - The contents must be base64 encoded
291    /// - There must be a `:` separator
292    pub(crate) fn from_header_value(header: &HeaderValue) -> Option<Self> {
293        // Parse a `Basic` authentication header.
294        if let Some(mut value) = header.as_bytes().strip_prefix(b"Basic ") {
295            let mut decoder = DecoderReader::new(&mut value, &BASE64_STANDARD);
296            let mut buf = String::new();
297            decoder
298                .read_to_string(&mut buf)
299                .expect("HTTP Basic Authentication should be base64 encoded");
300            let (username, password) = buf
301                .split_once(':')
302                .expect("HTTP Basic Authentication should include a `:` separator");
303            let username = if username.is_empty() {
304                None
305            } else {
306                Some(username.to_string())
307            };
308            let password = if password.is_empty() {
309                None
310            } else {
311                Some(password.to_string())
312            };
313            return Some(Self::Basic {
314                username: Username::new(username),
315                password: password.map(Password),
316            });
317        }
318
319        // Parse a `Bearer` authentication header.
320        if let Some(token) = header.as_bytes().strip_prefix(b"Bearer ") {
321            return Some(Self::Bearer {
322                token: Token::new(token.to_vec()),
323            });
324        }
325
326        None
327    }
328
329    /// Create an HTTP Basic Authentication header for the credentials.
330    ///
331    /// Panics if the username or password cannot be base64 encoded.
332    pub fn to_header_value(&self) -> HeaderValue {
333        match self {
334            Self::Basic { .. } => {
335                // See: <https://github.com/seanmonstar/reqwest/blob/2c11ef000b151c2eebeed2c18a7b81042220c6b0/src/util.rs#L3>
336                let mut buf = b"Basic ".to_vec();
337                {
338                    let mut encoder = EncoderWriter::new(&mut buf, &BASE64_STANDARD);
339                    write!(encoder, "{}:", self.username().unwrap_or_default())
340                        .expect("Write to base64 encoder should succeed");
341                    if let Some(password) = self.password() {
342                        write!(encoder, "{password}")
343                            .expect("Write to base64 encoder should succeed");
344                    }
345                }
346                let mut header =
347                    HeaderValue::from_bytes(&buf).expect("base64 is always valid HeaderValue");
348                header.set_sensitive(true);
349                header
350            }
351            Self::Bearer { token } => {
352                let mut header = HeaderValue::from_bytes(&[b"Bearer ", token.as_slice()].concat())
353                    .expect("Bearer token is always valid HeaderValue");
354                header.set_sensitive(true);
355                header
356            }
357        }
358    }
359
360    /// Apply the credentials to the given URL.
361    ///
362    /// Any existing credentials will be overridden.
363    #[must_use]
364    pub fn apply(&self, mut url: DisplaySafeUrl) -> DisplaySafeUrl {
365        if let Some(username) = self.username() {
366            let _ = url.set_username(username);
367        }
368        if let Some(password) = self.password() {
369            let _ = url.set_password(Some(password));
370        }
371        url
372    }
373
374    /// Attach the credentials to the given request.
375    ///
376    /// Any existing credentials will be overridden.
377    #[must_use]
378    pub fn authenticate(&self, mut request: Request) -> Request {
379        request
380            .headers_mut()
381            .insert(reqwest::header::AUTHORIZATION, Self::to_header_value(self));
382        request
383    }
384}
385
386#[derive(Clone, Debug)]
387pub(crate) enum Authentication {
388    /// HTTP Basic or Bearer Authentication credentials.
389    Credentials(Credentials),
390
391    /// AWS Signature Version 4 signing.
392    AwsSigner(AwsDefaultSigner),
393
394    /// Google Cloud signing.
395    GcsSigner(GcsDefaultSigner),
396}
397
398impl PartialEq for Authentication {
399    fn eq(&self, other: &Self) -> bool {
400        match (self, other) {
401            (Self::Credentials(a), Self::Credentials(b)) => a == b,
402            (Self::AwsSigner(..), Self::AwsSigner(..)) => true,
403            (Self::GcsSigner(..), Self::GcsSigner(..)) => true,
404            _ => false,
405        }
406    }
407}
408
409impl Eq for Authentication {}
410
411impl From<Credentials> for Authentication {
412    fn from(credentials: Credentials) -> Self {
413        Self::Credentials(credentials)
414    }
415}
416
417impl From<AwsDefaultSigner> for Authentication {
418    fn from(signer: AwsDefaultSigner) -> Self {
419        Self::AwsSigner(signer)
420    }
421}
422
423impl From<GcsDefaultSigner> for Authentication {
424    fn from(signer: GcsDefaultSigner) -> Self {
425        Self::GcsSigner(signer)
426    }
427}
428
429impl Authentication {
430    /// Return the password used for authentication, if any.
431    pub(crate) fn password(&self) -> Option<&str> {
432        match self {
433            Self::Credentials(credentials) => credentials.password(),
434            Self::AwsSigner(..) | Self::GcsSigner(..) => None,
435        }
436    }
437
438    /// Return the username used for authentication, if any.
439    pub(crate) fn username(&self) -> Option<&str> {
440        match self {
441            Self::Credentials(credentials) => credentials.username(),
442            Self::AwsSigner(..) | Self::GcsSigner(..) => None,
443        }
444    }
445
446    /// Return the username used for authentication, if any.
447    pub(crate) fn as_username(&self) -> Cow<'_, Username> {
448        match self {
449            Self::Credentials(credentials) => credentials.as_username(),
450            Self::AwsSigner(..) | Self::GcsSigner(..) => Cow::Owned(Username::none()),
451        }
452    }
453
454    /// Return the username used for authentication, if any.
455    pub(crate) fn to_username(&self) -> Username {
456        match self {
457            Self::Credentials(credentials) => credentials.to_username(),
458            Self::AwsSigner(..) | Self::GcsSigner(..) => Username::none(),
459        }
460    }
461
462    /// Return `true` if the object contains a means of authenticating.
463    pub(crate) fn is_authenticated(&self) -> bool {
464        match self {
465            Self::Credentials(credentials) => credentials.is_authenticated(),
466            Self::AwsSigner(..) | Self::GcsSigner(..) => true,
467        }
468    }
469
470    /// Return `true` if the object contains no credentials.
471    pub(crate) fn is_empty(&self) -> bool {
472        match self {
473            Self::Credentials(credentials) => credentials.is_empty(),
474            Self::AwsSigner(..) | Self::GcsSigner(..) => false,
475        }
476    }
477
478    /// Apply the authentication to the given request.
479    ///
480    /// Any existing credentials will be overridden.
481    #[must_use]
482    pub(crate) async fn authenticate(&self, mut request: Request) -> Request {
483        match self {
484            Self::Credentials(credentials) => credentials.authenticate(request),
485            Self::AwsSigner(signer) => {
486                // Build an `http::Request` from the `reqwest::Request`.
487                // SAFETY: If we have a valid `reqwest::Request`, we expect (e.g.) the URL to be valid.
488                let uri = Uri::from_str(request.url().as_str()).unwrap();
489                let mut http_req = http::Request::builder()
490                    .method(request.method().clone())
491                    .uri(uri)
492                    .body(())
493                    .unwrap();
494                *http_req.headers_mut() = request.headers().clone();
495
496                // Sign the parts.
497                let (mut parts, ()) = http_req.into_parts();
498                signer
499                    .sign(&mut parts, None)
500                    .await
501                    .expect("AWS signing should succeed");
502
503                // Copy over the signed headers.
504                request.headers_mut().extend(parts.headers);
505
506                // Copy over the signed path and query, if any.
507                if let Some(path_and_query) = parts.uri.path_and_query() {
508                    request.url_mut().set_path(path_and_query.path());
509                    request.url_mut().set_query(path_and_query.query());
510                }
511                request
512            }
513            Self::GcsSigner(signer) => {
514                // Build an `http::Request` from the `reqwest::Request`.
515                // SAFETY: If we have a valid `reqwest::Request`, we expect (e.g.) the URL to be valid.
516                let uri = Uri::from_str(request.url().as_str()).unwrap();
517                let mut http_req = http::Request::builder()
518                    .method(request.method().clone())
519                    .uri(uri)
520                    .body(())
521                    .unwrap();
522                *http_req.headers_mut() = request.headers().clone();
523
524                // Sign the parts.
525                let (mut parts, ()) = http_req.into_parts();
526                signer
527                    .sign(&mut parts, None)
528                    .await
529                    .expect("GCS signing should succeed");
530
531                // Copy over the signed headers.
532                request.headers_mut().extend(parts.headers);
533
534                // Copy over the signed path and query, if any.
535                if let Some(path_and_query) = parts.uri.path_and_query() {
536                    request.url_mut().set_path(path_and_query.path());
537                    request.url_mut().set_query(path_and_query.query());
538                }
539                request
540            }
541        }
542    }
543}
544
545#[cfg(test)]
546mod tests {
547    use insta::assert_debug_snapshot;
548
549    use super::*;
550
551    #[test]
552    fn from_url_no_credentials() {
553        let url = &Url::parse("https://example.com/simple/first/").unwrap();
554        assert_eq!(Credentials::from_url(url), None);
555    }
556
557    #[test]
558    fn from_url_username_and_password() {
559        let url = &Url::parse("https://example.com/simple/first/").unwrap();
560        let mut auth_url = url.clone();
561        auth_url.set_username("user").unwrap();
562        auth_url.set_password(Some("password")).unwrap();
563        let credentials = Credentials::from_url(&auth_url).unwrap();
564        assert_eq!(credentials.username(), Some("user"));
565        assert_eq!(credentials.password(), Some("password"));
566    }
567
568    #[test]
569    fn from_url_no_username() {
570        let url = &Url::parse("https://example.com/simple/first/").unwrap();
571        let mut auth_url = url.clone();
572        auth_url.set_password(Some("password")).unwrap();
573        let credentials = Credentials::from_url(&auth_url).unwrap();
574        assert_eq!(credentials.username(), None);
575        assert_eq!(credentials.password(), Some("password"));
576    }
577
578    /// Test for <https://github.com/astral-sh/uv/issues/17343>
579    ///
580    /// URLs with an empty username but a password (e.g., `https://:token@example.com`)
581    /// should be recognized as having credentials.
582    #[test]
583    fn from_url_empty_username_with_password() {
584        // Parse a URL with the format `:password@host` directly
585        let url = Url::parse("https://:token@example.com/simple/first/").unwrap();
586        let credentials = Credentials::from_url(&url).unwrap();
587        assert_eq!(credentials.username(), None);
588        assert_eq!(credentials.password(), Some("token"));
589        assert!(
590            credentials.is_authenticated(),
591            "URL with empty username but password should be considered authenticated"
592        );
593    }
594
595    #[test]
596    fn from_url_no_password() {
597        let url = &Url::parse("https://example.com/simple/first/").unwrap();
598        let mut auth_url = url.clone();
599        auth_url.set_username("user").unwrap();
600        let credentials = Credentials::from_url(&auth_url).unwrap();
601        assert_eq!(credentials.username(), Some("user"));
602        assert_eq!(credentials.password(), None);
603    }
604
605    #[test]
606    fn authenticated_request_from_url() {
607        let url = Url::parse("https://example.com/simple/first/").unwrap();
608        let mut auth_url = url.clone();
609        auth_url.set_username("user").unwrap();
610        auth_url.set_password(Some("password")).unwrap();
611        let credentials = Credentials::from_url(&auth_url).unwrap();
612
613        let mut request = Request::new(reqwest::Method::GET, url);
614        request = credentials.authenticate(request);
615
616        let mut header = request
617            .headers()
618            .get(reqwest::header::AUTHORIZATION)
619            .expect("Authorization header should be set")
620            .clone();
621        header.set_sensitive(false);
622
623        assert_debug_snapshot!(header, @r#""Basic dXNlcjpwYXNzd29yZA==""#);
624        assert_eq!(Credentials::from_header_value(&header), Some(credentials));
625    }
626
627    #[test]
628    fn authenticated_request_from_url_with_percent_encoded_user() {
629        let url = Url::parse("https://example.com/simple/first/").unwrap();
630        let mut auth_url = url.clone();
631        auth_url.set_username("user@domain").unwrap();
632        auth_url.set_password(Some("password")).unwrap();
633        let credentials = Credentials::from_url(&auth_url).unwrap();
634
635        let mut request = Request::new(reqwest::Method::GET, url);
636        request = credentials.authenticate(request);
637
638        let mut header = request
639            .headers()
640            .get(reqwest::header::AUTHORIZATION)
641            .expect("Authorization header should be set")
642            .clone();
643        header.set_sensitive(false);
644
645        assert_debug_snapshot!(header, @r#""Basic dXNlckBkb21haW46cGFzc3dvcmQ=""#);
646        assert_eq!(Credentials::from_header_value(&header), Some(credentials));
647    }
648
649    #[test]
650    fn authenticated_request_from_url_with_percent_encoded_password() {
651        let url = Url::parse("https://example.com/simple/first/").unwrap();
652        let mut auth_url = url.clone();
653        auth_url.set_username("user").unwrap();
654        auth_url.set_password(Some("password==")).unwrap();
655        let credentials = Credentials::from_url(&auth_url).unwrap();
656
657        let mut request = Request::new(reqwest::Method::GET, url);
658        request = credentials.authenticate(request);
659
660        let mut header = request
661            .headers()
662            .get(reqwest::header::AUTHORIZATION)
663            .expect("Authorization header should be set")
664            .clone();
665        header.set_sensitive(false);
666
667        assert_debug_snapshot!(header, @r#""Basic dXNlcjpwYXNzd29yZD09""#);
668        assert_eq!(Credentials::from_header_value(&header), Some(credentials));
669    }
670
671    // Test that we don't include the password in debug messages.
672    #[test]
673    fn test_password_obfuscation() {
674        let credentials =
675            Credentials::basic(Some(String::from("user")), Some(String::from("password")));
676        let debugged = format!("{credentials:?}");
677        assert_eq!(
678            debugged,
679            "Basic { username: Username(Some(\"user\")), password: Some(****) }"
680        );
681    }
682
683    #[test]
684    fn test_bearer_token_obfuscation() {
685        let token = "super_secret_token";
686        let credentials = Credentials::bearer(token.into());
687        let debugged = format!("{credentials:?}");
688        assert!(
689            !debugged.contains(token),
690            "Token should be obfuscated in Debug impl: {debugged}"
691        );
692    }
693}