cargo/util/auth/
mod.rs

1//! Registry authentication support.
2
3use crate::{
4    core::features::cargo_docs_link,
5    util::{context::ConfigKey, CanonicalUrl, CargoResult, GlobalContext, IntoUrl},
6};
7use anyhow::{bail, Context as _};
8use cargo_credential::{
9    Action, CacheControl, Credential, CredentialResponse, LoginOptions, Operation, RegistryInfo,
10    Secret,
11};
12
13use core::fmt;
14use serde::Deserialize;
15use std::error::Error;
16use time::{Duration, OffsetDateTime};
17use url::Url;
18
19use crate::core::SourceId;
20use crate::util::context::Value;
21use crate::util::credential::adaptor::BasicProcessCredential;
22use crate::util::credential::paseto::PasetoCredential;
23
24use super::{
25    context::{CredentialCacheValue, OptValue, PathAndArgs},
26    credential::process::CredentialProcessCredential,
27    credential::token::TokenCredential,
28};
29
30/// `[registries.NAME]` tables.
31///
32/// The values here should be kept in sync with `RegistryConfigExtended`
33#[derive(Deserialize, Clone, Debug)]
34#[serde(rename_all = "kebab-case")]
35pub struct RegistryConfig {
36    pub index: Option<String>,
37    pub token: OptValue<Secret<String>>,
38    pub credential_provider: Option<PathAndArgs>,
39    pub secret_key: OptValue<Secret<String>>,
40    pub secret_key_subject: Option<String>,
41    #[serde(rename = "protocol")]
42    _protocol: Option<String>,
43}
44
45/// The `[registry]` table, which more keys than the `[registries.NAME]` tables.
46///
47/// Note: nesting `RegistryConfig` inside this struct and using `serde(flatten)` *should* work
48/// but fails with "invalid type: sequence, expected a value" when attempting to deserialize.
49#[derive(Deserialize)]
50#[serde(rename_all = "kebab-case")]
51pub struct RegistryConfigExtended {
52    pub index: Option<String>,
53    pub token: OptValue<Secret<String>>,
54    pub credential_provider: Option<PathAndArgs>,
55    pub secret_key: OptValue<Secret<String>>,
56    pub secret_key_subject: Option<String>,
57    #[serde(rename = "default")]
58    _default: Option<String>,
59    #[serde(rename = "global-credential-providers")]
60    _global_credential_providers: Option<Vec<String>>,
61}
62
63impl RegistryConfigExtended {
64    pub fn to_registry_config(self) -> RegistryConfig {
65        RegistryConfig {
66            index: self.index,
67            token: self.token,
68            credential_provider: self.credential_provider,
69            secret_key: self.secret_key,
70            secret_key_subject: self.secret_key_subject,
71            _protocol: None,
72        }
73    }
74}
75
76/// Get the list of credential providers for a registry source.
77fn credential_provider(
78    gctx: &GlobalContext,
79    sid: &SourceId,
80    require_cred_provider_config: bool,
81    show_warnings: bool,
82) -> CargoResult<Vec<Vec<String>>> {
83    let warn = |message: String| {
84        if show_warnings {
85            gctx.shell().warn(message)
86        } else {
87            Ok(())
88        }
89    };
90
91    let cfg = registry_credential_config_raw(gctx, sid)?;
92    let mut global_provider_defined = true;
93    let default_providers = || {
94        global_provider_defined = false;
95        if gctx.cli_unstable().asymmetric_token {
96            // Enable the PASETO provider
97            vec![
98                vec!["cargo:token".to_string()],
99                vec!["cargo:paseto".to_string()],
100            ]
101        } else {
102            vec![vec!["cargo:token".to_string()]]
103        }
104    };
105    let global_providers = gctx
106        .get::<Option<Vec<Value<String>>>>("registry.global-credential-providers")?
107        .filter(|p| !p.is_empty())
108        .map(|p| {
109            p.iter()
110                .rev()
111                .map(PathAndArgs::from_whitespace_separated_string)
112                .map(|p| resolve_credential_alias(gctx, p))
113                .collect()
114        })
115        .unwrap_or_else(default_providers);
116    tracing::debug!(?global_providers);
117
118    match cfg {
119        // If there's a specific provider configured for this registry, use it.
120        Some(RegistryConfig {
121            credential_provider: Some(provider),
122            token,
123            secret_key,
124            ..
125        }) => {
126            let provider = resolve_credential_alias(gctx, provider);
127            if let Some(token) = token {
128                if provider[0] != "cargo:token" {
129                    warn(format!(
130                        "{sid} has a token configured in {} that will be ignored \
131                        because this registry is configured to use credential-provider `{}`",
132                        token.definition, provider[0],
133                    ))?;
134                }
135            }
136            if let Some(secret_key) = secret_key {
137                if provider[0] != "cargo:paseto" {
138                    warn(format!(
139                        "{sid} has a secret-key configured in {} that will be ignored \
140                        because this registry is configured to use credential-provider `{}`",
141                        secret_key.definition, provider[0],
142                    ))?;
143                }
144            }
145            return Ok(vec![provider]);
146        }
147
148        // Warning for both `token` and `secret-key`, stating which will be ignored
149        Some(RegistryConfig {
150            token: Some(token),
151            secret_key: Some(secret_key),
152            ..
153        }) if gctx.cli_unstable().asymmetric_token => {
154            let token_pos = global_providers
155                .iter()
156                .position(|p| p.first().map(String::as_str) == Some("cargo:token"));
157            let paseto_pos = global_providers
158                .iter()
159                .position(|p| p.first().map(String::as_str) == Some("cargo:paseto"));
160            match (token_pos, paseto_pos) {
161                (Some(token_pos), Some(paseto_pos)) => {
162                    if token_pos < paseto_pos {
163                        warn(format!(
164                            "{sid} has a `secret_key` configured in {} that will be ignored \
165                        because a `token` is also configured, and the `cargo:token` provider is \
166                        configured with higher precedence",
167                            secret_key.definition
168                        ))?;
169                    } else {
170                        warn(format!("{sid} has a `token` configured in {} that will be ignored \
171                        because a `secret_key` is also configured, and the `cargo:paseto` provider is \
172                        configured with higher precedence", token.definition))?;
173                    }
174                }
175                (_, _) => {
176                    // One or both of the below individual warnings will trigger
177                }
178            }
179        }
180
181        // Check if a `token` is configured that will be ignored.
182        Some(RegistryConfig {
183            token: Some(token), ..
184        }) => {
185            if !global_providers
186                .iter()
187                .any(|p| p.first().map(String::as_str) == Some("cargo:token"))
188            {
189                warn(format!(
190                    "{sid} has a token configured in {} that will be ignored \
191                    because the `cargo:token` credential provider is not listed in \
192                    `registry.global-credential-providers`",
193                    token.definition
194                ))?;
195            }
196        }
197
198        // Check if a asymmetric token is configured that will be ignored.
199        Some(RegistryConfig {
200            secret_key: Some(token),
201            ..
202        }) if gctx.cli_unstable().asymmetric_token => {
203            if !global_providers
204                .iter()
205                .any(|p| p.first().map(String::as_str) == Some("cargo:paseto"))
206            {
207                warn(format!(
208                    "{sid} has a secret-key configured in {} that will be ignored \
209                    because the `cargo:paseto` credential provider is not listed in \
210                    `registry.global-credential-providers`",
211                    token.definition
212                ))?;
213            }
214        }
215
216        // If we couldn't find a registry-specific provider, use the fallback provider list.
217        None | Some(RegistryConfig { .. }) => {}
218    };
219    if !global_provider_defined && require_cred_provider_config {
220        bail!(
221            "authenticated registries require a credential-provider to be configured\n\
222        see {} for details",
223            cargo_docs_link("reference/registry-authentication.html")
224        );
225    }
226    Ok(global_providers)
227}
228
229/// Get the credential configuration for a `SourceId`.
230pub fn registry_credential_config_raw(
231    gctx: &GlobalContext,
232    sid: &SourceId,
233) -> CargoResult<Option<RegistryConfig>> {
234    let mut cache = gctx.registry_config();
235    if let Some(cfg) = cache.get(&sid) {
236        return Ok(cfg.clone());
237    }
238    let cfg = registry_credential_config_raw_uncached(gctx, sid)?;
239    cache.insert(*sid, cfg.clone());
240    return Ok(cfg);
241}
242
243fn registry_credential_config_raw_uncached(
244    gctx: &GlobalContext,
245    sid: &SourceId,
246) -> CargoResult<Option<RegistryConfig>> {
247    tracing::trace!("loading credential config for {}", sid);
248    gctx.load_credentials()?;
249    if !sid.is_remote_registry() {
250        bail!(
251            "{} does not support API commands.\n\
252             Check for a source-replacement in .cargo/config.",
253            sid
254        );
255    }
256
257    // Handle crates.io specially, since it uses different configuration keys.
258    if sid.is_crates_io() {
259        gctx.check_registry_index_not_set()?;
260        return Ok(gctx
261            .get::<Option<RegistryConfigExtended>>("registry")?
262            .map(|c| c.to_registry_config()));
263    }
264
265    // Find the SourceId's name by its index URL. If environment variables
266    // are available they will be preferred over configuration values.
267    //
268    // The fundamental problem is that we only know the index url of the registry
269    // for certain. For example, an unnamed registry source can come from the `--index`
270    // command line argument, or from a Cargo.lock file. For this reason, we always
271    // attempt to discover the name by looking it up by the index URL.
272    //
273    // This also allows the authorization token for a registry to be set
274    // without knowing the registry name by using the _INDEX and _TOKEN
275    // environment variables.
276
277    let name = {
278        // Discover names from environment variables.
279        let index = sid.canonical_url();
280        let mut names: Vec<_> = gctx
281            .env()
282            .filter_map(|(k, v)| {
283                Some((
284                    k.strip_prefix("CARGO_REGISTRIES_")?
285                        .strip_suffix("_INDEX")?,
286                    v,
287                ))
288            })
289            .filter_map(|(k, v)| Some((k, CanonicalUrl::new(&v.into_url().ok()?).ok()?)))
290            .filter(|(_, v)| v == index)
291            .map(|(k, _)| k.to_lowercase())
292            .collect();
293
294        // Discover names from the configuration only if none were found in the environment.
295        if names.len() == 0 {
296            if let Some(registries) = gctx.values()?.get("registries") {
297                let (registries, _) = registries.table("registries")?;
298                for (name, value) in registries {
299                    if let Some(v) = value.table(&format!("registries.{name}"))?.0.get("index") {
300                        let (v, _) = v.string(&format!("registries.{name}.index"))?;
301                        if index == &CanonicalUrl::new(&v.into_url()?)? {
302                            names.push(name.clone());
303                        }
304                    }
305                }
306            }
307        }
308        names.sort();
309        match names.len() {
310            0 => None,
311            1 => Some(std::mem::take(&mut names[0])),
312            _ => anyhow::bail!(
313                "multiple registries are configured with the same index url '{}': {}",
314                &sid.as_url(),
315                names.join(", ")
316            ),
317        }
318    };
319
320    // It's possible to have a registry configured in a Cargo config file,
321    // then override it with configuration from environment variables.
322    // If the name doesn't match, leave a note to help the user understand
323    // the potentially confusing situation.
324    if let Some(name) = name.as_deref() {
325        if Some(name) != sid.alt_registry_key() {
326            gctx.shell().note(format!(
327                "name of alternative registry `{}` set to `{name}`",
328                sid.url()
329            ))?
330        }
331    }
332
333    if let Some(name) = &name {
334        tracing::debug!("found alternative registry name `{name}` for {sid}");
335        gctx.get::<Option<RegistryConfig>>(&format!("registries.{name}"))
336    } else {
337        tracing::debug!("no registry name found for {sid}");
338        Ok(None)
339    }
340}
341
342/// Use the `[credential-alias]` table to see if the provider name has been aliased.
343fn resolve_credential_alias(gctx: &GlobalContext, mut provider: PathAndArgs) -> Vec<String> {
344    if provider.args.is_empty() {
345        let name = provider.path.raw_value();
346        let key = format!("credential-alias.{name}");
347        if let Ok(alias) = gctx.get::<Value<PathAndArgs>>(&key) {
348            tracing::debug!("resolving credential alias '{key}' -> '{alias:?}'");
349            if BUILT_IN_PROVIDERS.contains(&name) {
350                let _ = gctx.shell().warn(format!(
351                    "credential-alias `{name}` (defined in `{}`) will be \
352                    ignored because it would shadow a built-in credential-provider",
353                    alias.definition
354                ));
355            } else {
356                provider = alias.val;
357            }
358        }
359    }
360    provider.args.insert(
361        0,
362        provider
363            .path
364            .resolve_program(gctx)
365            .to_str()
366            .unwrap()
367            .to_string(),
368    );
369    provider.args
370}
371
372#[derive(Debug, PartialEq)]
373pub enum AuthorizationErrorReason {
374    TokenMissing,
375    TokenRejected,
376}
377
378impl fmt::Display for AuthorizationErrorReason {
379    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
380        match self {
381            AuthorizationErrorReason::TokenMissing => write!(f, "no token found"),
382            AuthorizationErrorReason::TokenRejected => write!(f, "token rejected"),
383        }
384    }
385}
386
387/// An authorization error from accessing a registry.
388#[derive(Debug)]
389pub struct AuthorizationError {
390    /// Url that was attempted
391    sid: SourceId,
392    /// The `registry.default` config value.
393    default_registry: Option<String>,
394    /// Url where the user could log in.
395    pub login_url: Option<Url>,
396    /// Specific reason indicating what failed
397    reason: AuthorizationErrorReason,
398    /// Should `cargo login` and the `_TOKEN` env var be included when displaying this error?
399    supports_cargo_token_credential_provider: bool,
400}
401
402impl AuthorizationError {
403    pub fn new(
404        gctx: &GlobalContext,
405        sid: SourceId,
406        login_url: Option<Url>,
407        reason: AuthorizationErrorReason,
408    ) -> CargoResult<Self> {
409        // Only display the _TOKEN environment variable suggestion if the `cargo:token` credential
410        // provider is available for the source. Otherwise setting the environment variable will
411        // have no effect.
412        let supports_cargo_token_credential_provider =
413            credential_provider(gctx, &sid, false, false)?
414                .iter()
415                .any(|p| p.first().map(String::as_str) == Some("cargo:token"));
416        Ok(AuthorizationError {
417            sid,
418            default_registry: gctx.default_registry()?,
419            login_url,
420            reason,
421            supports_cargo_token_credential_provider,
422        })
423    }
424}
425
426impl Error for AuthorizationError {}
427impl fmt::Display for AuthorizationError {
428    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
429        if self.sid.is_crates_io() {
430            let args = if self.default_registry.is_some() {
431                " --registry crates-io"
432            } else {
433                ""
434            };
435            write!(f, "{}, please run `cargo login{args}`", self.reason)?;
436            if self.supports_cargo_token_credential_provider {
437                write!(f, "\nor use environment variable CARGO_REGISTRY_TOKEN")?;
438            }
439            Ok(())
440        } else if let Some(name) = self.sid.alt_registry_key() {
441            write!(
442                f,
443                "{} for `{}`",
444                self.reason,
445                self.sid.display_registry_name()
446            )?;
447            if self.supports_cargo_token_credential_provider {
448                let key = ConfigKey::from_str(&format!("registries.{name}.token"));
449                write!(
450                    f,
451                    ", please run `cargo login --registry {name}`\n\
452                    or use environment variable {}",
453                    key.as_env_key()
454                )?;
455            } else {
456                write!(
457                    f,
458                    "\nYou may need to log in using this registry's credential provider"
459                )?;
460            }
461            Ok(())
462        } else if self.reason == AuthorizationErrorReason::TokenMissing {
463            write!(
464                f,
465                r#"{} for `{}`
466consider setting up an alternate registry in Cargo's configuration
467as described by https://doc.rust-lang.org/cargo/reference/registries.html
468
469[registries]
470my-registry = {{ index = "{}" }}
471"#,
472                self.reason,
473                self.sid.display_registry_name(),
474                self.sid.url()
475            )
476        } else {
477            write!(
478                f,
479                r#"{} for `{}`"#,
480                self.reason,
481                self.sid.display_registry_name(),
482            )
483        }
484    }
485}
486
487/// Store a token in the cache for future calls.
488pub fn cache_token_from_commandline(gctx: &GlobalContext, sid: &SourceId, token: Secret<&str>) {
489    let url = sid.canonical_url();
490    gctx.credential_cache().insert(
491        url.clone(),
492        CredentialCacheValue {
493            token_value: token.to_owned(),
494            expiration: None,
495            operation_independent: true,
496        },
497    );
498}
499
500/// List of credential providers built-in to Cargo.
501/// Keep in sync with the `match` in `credential_action`.
502static BUILT_IN_PROVIDERS: &[&'static str] = &[
503    "cargo:token",
504    "cargo:paseto",
505    "cargo:token-from-stdout",
506    "cargo:wincred",
507    "cargo:macos-keychain",
508    "cargo:libsecret",
509];
510
511fn credential_action(
512    gctx: &GlobalContext,
513    sid: &SourceId,
514    action: Action<'_>,
515    headers: Vec<String>,
516    args: &[&str],
517    require_cred_provider_config: bool,
518) -> CargoResult<CredentialResponse> {
519    let name = sid.alt_registry_key();
520    let registry = RegistryInfo {
521        index_url: sid.url().as_str(),
522        name,
523        headers,
524    };
525    let providers = credential_provider(gctx, sid, require_cred_provider_config, true)?;
526    let mut any_not_found = false;
527    for provider in providers {
528        let args: Vec<&str> = provider
529            .iter()
530            .map(String::as_str)
531            .chain(args.iter().copied())
532            .collect();
533        let process = args[0];
534        tracing::debug!("attempting credential provider: {args:?}");
535        // If the available built-in providers are changed, update the `BUILT_IN_PROVIDERS` list.
536        let provider: Box<dyn Credential> = match process {
537            "cargo:token" => Box::new(TokenCredential::new(gctx)),
538            "cargo:paseto" if gctx.cli_unstable().asymmetric_token => {
539                Box::new(PasetoCredential::new(gctx))
540            }
541            "cargo:paseto" => bail!("cargo:paseto requires -Zasymmetric-token"),
542            "cargo:token-from-stdout" => Box::new(BasicProcessCredential {}),
543            #[cfg(windows)]
544            "cargo:wincred" => Box::new(cargo_credential_wincred::WindowsCredential {}),
545            #[cfg(target_os = "macos")]
546            "cargo:macos-keychain" => Box::new(cargo_credential_macos_keychain::MacKeychain {}),
547            #[cfg(target_os = "linux")]
548            "cargo:libsecret" => Box::new(cargo_credential_libsecret::LibSecretCredential {}),
549            name if BUILT_IN_PROVIDERS.contains(&name) => {
550                Box::new(cargo_credential::UnsupportedCredential {})
551            }
552            process => Box::new(CredentialProcessCredential::new(process)),
553        };
554        gctx.shell().verbose(|c| {
555            c.status(
556                "Credential",
557                format!(
558                    "{} {action} {}",
559                    args.join(" "),
560                    sid.display_registry_name()
561                ),
562            )
563        })?;
564        match provider.perform(&registry, &action, &args[1..]) {
565            Ok(response) => return Ok(response),
566            Err(cargo_credential::Error::UrlNotSupported) => {}
567            Err(cargo_credential::Error::NotFound) => any_not_found = true,
568            e => {
569                return e.with_context(|| {
570                    format!(
571                        "credential provider `{}` failed action `{action}`",
572                        args.join(" ")
573                    )
574                })
575            }
576        }
577    }
578    if any_not_found {
579        Err(cargo_credential::Error::NotFound.into())
580    } else {
581        anyhow::bail!("no credential providers could handle the request")
582    }
583}
584
585/// Returns the token to use for the given registry.
586/// If a `login_url` is provided and a token is not available, the
587/// `login_url` will be included in the returned error.
588pub fn auth_token(
589    gctx: &GlobalContext,
590    sid: &SourceId,
591    login_url: Option<&Url>,
592    operation: Operation<'_>,
593    headers: Vec<String>,
594    require_cred_provider_config: bool,
595) -> CargoResult<String> {
596    match auth_token_optional(gctx, sid, operation, headers, require_cred_provider_config)? {
597        Some(token) => Ok(token.expose()),
598        None => Err(AuthorizationError::new(
599            gctx,
600            *sid,
601            login_url.cloned(),
602            AuthorizationErrorReason::TokenMissing,
603        )?
604        .into()),
605    }
606}
607
608/// Returns the token to use for the given registry.
609fn auth_token_optional(
610    gctx: &GlobalContext,
611    sid: &SourceId,
612    operation: Operation<'_>,
613    headers: Vec<String>,
614    require_cred_provider_config: bool,
615) -> CargoResult<Option<Secret<String>>> {
616    tracing::trace!("token requested for {}", sid.display_registry_name());
617    let mut cache = gctx.credential_cache();
618    let url = sid.canonical_url();
619    if let Some(cached_token) = cache.get(url) {
620        if cached_token
621            .expiration
622            .map(|exp| OffsetDateTime::now_utc() + Duration::minutes(1) < exp)
623            .unwrap_or(true)
624        {
625            if cached_token.operation_independent || matches!(operation, Operation::Read) {
626                tracing::trace!("using token from in-memory cache");
627                return Ok(Some(cached_token.token_value.clone()));
628            }
629        } else {
630            // Remove expired token from the cache
631            cache.remove(url);
632        }
633    }
634
635    let credential_response = credential_action(
636        gctx,
637        sid,
638        Action::Get(operation),
639        headers,
640        &[],
641        require_cred_provider_config,
642    );
643    if let Some(e) = credential_response.as_ref().err() {
644        if let Some(e) = e.downcast_ref::<cargo_credential::Error>() {
645            if matches!(e, cargo_credential::Error::NotFound) {
646                return Ok(None);
647            }
648        }
649    }
650    let credential_response = credential_response?;
651
652    let CredentialResponse::Get {
653        token,
654        cache: cache_control,
655        operation_independent,
656    } = credential_response
657    else {
658        bail!("credential provider produced unexpected response for `get` request: {credential_response:?}")
659    };
660    let token = Secret::from(token);
661    tracing::trace!("found token");
662    let expiration = match cache_control {
663        CacheControl::Expires { expiration } => Some(expiration),
664        CacheControl::Session => None,
665        CacheControl::Never | _ => return Ok(Some(token)),
666    };
667
668    cache.insert(
669        url.clone(),
670        CredentialCacheValue {
671            token_value: token.clone(),
672            expiration,
673            operation_independent,
674        },
675    );
676    Ok(Some(token))
677}
678
679/// Log out from the given registry.
680pub fn logout(gctx: &GlobalContext, sid: &SourceId) -> CargoResult<()> {
681    let credential_response = credential_action(gctx, sid, Action::Logout, vec![], &[], false);
682    if let Some(e) = credential_response.as_ref().err() {
683        if let Some(e) = e.downcast_ref::<cargo_credential::Error>() {
684            if matches!(e, cargo_credential::Error::NotFound) {
685                gctx.shell().status(
686                    "Logout",
687                    format!(
688                        "not currently logged in to `{}`",
689                        sid.display_registry_name()
690                    ),
691                )?;
692                return Ok(());
693            }
694        }
695    }
696    let credential_response = credential_response?;
697    let CredentialResponse::Logout = credential_response else {
698        bail!("credential provider produced unexpected response for `logout` request: {credential_response:?}")
699    };
700    Ok(())
701}
702
703/// Log in to the given registry.
704pub fn login(
705    gctx: &GlobalContext,
706    sid: &SourceId,
707    options: LoginOptions<'_>,
708    args: &[&str],
709) -> CargoResult<()> {
710    let credential_response =
711        credential_action(gctx, sid, Action::Login(options), vec![], args, false)?;
712    let CredentialResponse::Login = credential_response else {
713        bail!("credential provider produced unexpected response for `login` request: {credential_response:?}")
714    };
715    Ok(())
716}