Skip to main content

cargo/util/auth/
mod.rs

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