Skip to main content

dbx_tools_databricks_auth/
profile.rs

1use std::{
2    collections::HashMap,
3    env, fmt,
4    path::{Path, PathBuf},
5    sync::{Arc, Mutex, OnceLock},
6};
7
8use configparser::ini::Ini;
9use directories::UserDirs;
10use sha2::{Digest, Sha256};
11use url::Url;
12
13use crate::{Error, Result};
14
15pub const DEFAULT_CLIENT_ID: &str = "databricks-cli";
16pub const DEFAULT_ACCOUNTS_HOST: &str = "https://accounts.cloud.databricks.com";
17pub const DEFAULT_CONFIG_FILE: &str = "~/.databrickscfg";
18const SETTINGS_SECTION: &str = "__settings__";
19const AUTH_TYPE_DATABRICKS_CLI: &str = "databricks-cli";
20const AUTH_TYPE_M2M: &str = "oauth-m2m";
21static CONFIG_CACHE: OnceLock<Mutex<HashMap<PathBuf, CachedConfig>>> = OnceLock::new();
22
23#[derive(Clone)]
24enum CachedConfig {
25    Loaded(Arc<Ini>),
26    Missing,
27    Invalid(String),
28}
29
30/// OAuth strategy selected from Databricks configuration.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub enum AuthKind {
33    /// Interactive user authorization with refresh-token storage.
34    #[default]
35    UserToMachine,
36    /// Service-principal client credentials.
37    MachineToMachine,
38}
39
40#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
41pub enum TargetKind {
42    #[default]
43    Workspace,
44    Account,
45    Unified,
46}
47
48#[derive(Clone)]
49pub struct Profile {
50    pub name: String,
51    pub host: Url,
52    pub account_id: Option<String>,
53    pub workspace_id: Option<String>,
54    pub client_id: String,
55    /// Optional group role requested by M2M token generation.
56    pub group_id: Option<String>,
57    pub scopes: Vec<String>,
58    pub target: TargetKind,
59    /// OAuth strategy resolved for this profile.
60    pub auth_kind: AuthKind,
61    pub(crate) client_secret: Option<String>,
62}
63
64impl Profile {
65    pub fn from_sources(options: ProfileOptions) -> Result<Self> {
66        let config_file = resolve_config_file(options.config_file.as_deref())?;
67        let prefer_user_to_machine = options.prefer_user_to_machine;
68        let environment_profile = env_nonempty("DATABRICKS_CONFIG_PROFILE");
69        let explicit_profile = options.profile.is_some() || environment_profile.is_some();
70        let requested_name = options
71            .profile
72            .or(environment_profile)
73            .map(|value| value.trim().to_owned())
74            .filter(|value| !value.is_empty());
75        let config = load_config(&config_file)?;
76        let profile_name = resolve_auth_profile_name(
77            requested_name.as_deref(),
78            explicit_profile,
79            config.as_deref(),
80            prefer_user_to_machine,
81        )?;
82        let configured = config
83            .as_deref()
84            .map(|config| load_profile(config, &profile_name))
85            .unwrap_or_default();
86
87        let host = options
88            .host
89            .or_else(|| env_nonempty("DATABRICKS_HOST"))
90            .or(configured.host)
91            .ok_or_else(|| Error::Config(format!("profile {profile_name} has no host")))?;
92        let host = normalize_host(&host)?;
93        let account_id = options
94            .account_id
95            .or_else(|| env_nonempty("DATABRICKS_ACCOUNT_ID"))
96            .or(configured.account_id);
97        let workspace_id = options
98            .workspace_id
99            .or_else(|| env_nonempty("DATABRICKS_WORKSPACE_ID"))
100            .or(configured.workspace_id);
101        let client_id = options
102            .client_id
103            .or_else(|| env_nonempty("DATABRICKS_CLIENT_ID"))
104            .or(configured.client_id);
105        let client_secret = options
106            .client_secret
107            .or_else(|| env_nonempty("DATABRICKS_CLIENT_SECRET"))
108            .or(configured.client_secret);
109        let group_id = options
110            .group_id
111            .or_else(|| env_nonempty("DATABRICKS_GROUP_ID"))
112            .or(configured.group_id);
113        let auth_type = options
114            .auth_type
115            .or_else(|| env_nonempty("DATABRICKS_AUTH_TYPE"))
116            .or(configured.auth_type);
117        let auth_kind = resolve_auth_kind(
118            auth_type.as_deref(),
119            client_id.as_deref(),
120            client_secret.as_deref(),
121        )?;
122        let client_id = match auth_kind {
123            AuthKind::UserToMachine => client_id.unwrap_or_else(|| DEFAULT_CLIENT_ID.to_owned()),
124            AuthKind::MachineToMachine => client_id.ok_or_else(|| {
125                Error::Config(format!(
126                    "profile {profile_name} requires client_id for oauth-m2m"
127                ))
128            })?,
129        };
130        let scopes = options
131            .scopes
132            .or_else(|| configured.scopes.map(split_list))
133            .unwrap_or_else(|| vec!["all-apis".to_owned()]);
134        let target = options.target.unwrap_or_else(|| {
135            if account_id.is_some() && host.host_str() == Some("accounts.cloud.databricks.com") {
136                TargetKind::Account
137            } else {
138                TargetKind::Workspace
139            }
140        });
141
142        Ok(Self {
143            name: profile_name,
144            host,
145            account_id,
146            workspace_id,
147            client_id,
148            group_id,
149            scopes,
150            target,
151            auth_kind,
152            client_secret,
153        })
154    }
155
156    pub fn cache_key(&self) -> String {
157        match self.auth_kind {
158            AuthKind::UserToMachine => self.name.clone(),
159            AuthKind::MachineToMachine => {
160                let scopes = self.machine_scopes();
161                let identity = format!(
162                    "{}\0{}\0{}\0{}\0{}\0{}",
163                    self.host,
164                    self.account_id.as_deref().unwrap_or_default(),
165                    self.workspace_id.as_deref().unwrap_or_default(),
166                    self.client_id,
167                    self.group_id.as_deref().unwrap_or_default(),
168                    scopes.join(" "),
169                );
170                format!(
171                    "{}-oauth-m2m-{:x}",
172                    self.name,
173                    Sha256::digest(identity.as_bytes())
174                )
175            }
176        }
177    }
178
179    pub(crate) fn client_secret(&self) -> Option<&str> {
180        self.client_secret.as_deref()
181    }
182
183    pub fn effective_scopes(&self) -> Vec<String> {
184        let mut scopes = vec!["offline_access".to_owned()];
185        for scope in &self.scopes {
186            if !scopes.contains(scope) {
187                scopes.push(scope.clone());
188            }
189        }
190        scopes
191    }
192
193    pub fn machine_scopes(&self) -> Vec<String> {
194        let mut scopes = self.scopes.clone();
195        if scopes.is_empty() {
196            scopes.push("all-apis".to_owned());
197        }
198        scopes.sort();
199        scopes.dedup();
200        scopes
201    }
202}
203
204impl fmt::Debug for Profile {
205    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
206        formatter
207            .debug_struct("Profile")
208            .field("name", &self.name)
209            .field("host", &self.host)
210            .field("account_id", &self.account_id)
211            .field("workspace_id", &self.workspace_id)
212            .field("client_id", &self.client_id)
213            .field("group_id", &self.group_id)
214            .field("scopes", &self.scopes)
215            .field("target", &self.target)
216            .field("auth_kind", &self.auth_kind)
217            .field(
218                "client_secret",
219                &self.client_secret.as_ref().map(|_| "[REDACTED]"),
220            )
221            .finish()
222    }
223}
224
225/// Databricks profile overrides; debug output never includes the client secret.
226#[derive(Clone)]
227pub struct ProfileOptions {
228    pub profile: Option<String>,
229    pub host: Option<String>,
230    pub account_id: Option<String>,
231    pub workspace_id: Option<String>,
232    pub client_id: Option<String>,
233    /// M2M secret accepted by the Rust API and redacted from debug output.
234    pub client_secret: Option<String>,
235    /// Optional group role requested by M2M.
236    pub group_id: Option<String>,
237    /// Explicit Databricks auth type, such as `databricks-cli` or `oauth-m2m`.
238    pub auth_type: Option<String>,
239    pub scopes: Option<Vec<String>>,
240    pub target: Option<TargetKind>,
241    pub config_file: Option<PathBuf>,
242    /// Whether implicit M2M defaults should select one matching U2M profile.
243    pub prefer_user_to_machine: bool,
244}
245
246impl fmt::Debug for ProfileOptions {
247    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
248        formatter
249            .debug_struct("ProfileOptions")
250            .field("profile", &self.profile)
251            .field("host", &self.host)
252            .field("client_id", &self.client_id)
253            .field("auth_type", &self.auth_type)
254            .field(
255                "client_secret",
256                &self.client_secret.as_ref().map(|_| "[REDACTED]"),
257            )
258            .finish_non_exhaustive()
259    }
260}
261
262impl Default for ProfileOptions {
263    fn default() -> Self {
264        Self {
265            profile: None,
266            host: None,
267            account_id: None,
268            workspace_id: None,
269            client_id: None,
270            client_secret: None,
271            group_id: None,
272            auth_type: None,
273            scopes: None,
274            target: None,
275            config_file: None,
276            prefer_user_to_machine: true,
277        }
278    }
279}
280
281#[derive(Clone, Debug, Default)]
282struct RawProfile {
283    host: Option<String>,
284    account_id: Option<String>,
285    workspace_id: Option<String>,
286    client_id: Option<String>,
287    client_secret: Option<String>,
288    group_id: Option<String>,
289    scopes: Option<String>,
290    auth_type: Option<String>,
291}
292
293pub fn resolve_config_file(explicit: Option<&Path>) -> Result<PathBuf> {
294    let path = explicit
295        .map(PathBuf::from)
296        .or_else(|| env_nonempty("DATABRICKS_CONFIG_FILE").map(PathBuf::from))
297        .unwrap_or_else(|| PathBuf::from(DEFAULT_CONFIG_FILE));
298    expand_home(path)
299}
300
301fn expand_home(path: PathBuf) -> Result<PathBuf> {
302    let value = path.to_string_lossy();
303    if value == "~" || value.starts_with("~/") || value.starts_with("~\\") {
304        let home = UserDirs::new()
305            .map(|dirs| dirs.home_dir().to_path_buf())
306            .ok_or_else(|| Error::Config("cannot find home directory".into()))?;
307        if value == "~" {
308            return Ok(home);
309        }
310        return Ok(home.join(&value[2..]));
311    }
312    Ok(path)
313}
314
315fn load_config(path: &Path) -> Result<Option<Arc<Ini>>> {
316    let path = if path.is_absolute() {
317        path.to_path_buf()
318    } else {
319        env::current_dir()
320            .map_err(|error| Error::Config(format!("could not resolve profile path: {error}")))?
321            .join(path)
322    };
323    let mut cache = CONFIG_CACHE
324        .get_or_init(|| Mutex::new(HashMap::new()))
325        .lock()
326        .map_err(|_| Error::Config("Databricks profile cache lock is poisoned".into()))?;
327    if let Some(cached) = cache.get(&path) {
328        return cached_config(cached);
329    }
330    let loaded = if path.exists() {
331        let mut ini = Ini::new_cs();
332        match ini.load(&path) {
333            Ok(_) => CachedConfig::Loaded(Arc::new(ini)),
334            Err(error) => {
335                CachedConfig::Invalid(format!("could not read {}: {error}", path.display()))
336            }
337        }
338    } else {
339        CachedConfig::Missing
340    };
341    let result = cached_config(&loaded);
342    cache.insert(path, loaded);
343    result
344}
345
346fn cached_config(cached: &CachedConfig) -> Result<Option<Arc<Ini>>> {
347    match cached {
348        CachedConfig::Loaded(config) => Ok(Some(Arc::clone(config))),
349        CachedConfig::Missing => Ok(None),
350        CachedConfig::Invalid(error) => Err(Error::Config(error.clone())),
351    }
352}
353
354fn resolve_auth_profile_name(
355    requested: Option<&str>,
356    explicit: bool,
357    config: Option<&Ini>,
358    prefer_user_to_machine: bool,
359) -> Result<String> {
360    let selected = resolve_profile_name(requested, config)?;
361    if explicit || !prefer_user_to_machine {
362        return Ok(selected);
363    }
364    let Some(config) = config else {
365        return Ok(selected);
366    };
367    let selected_profile = load_profile(config, &selected);
368    if !is_m2m_profile(&selected_profile) {
369        return Ok(selected);
370    }
371    if selected_profile.host.is_none() {
372        return Ok(selected);
373    };
374    let mut matches = config
375        .sections()
376        .into_iter()
377        .filter(|name| name != SETTINGS_SECTION && name != &selected)
378        .filter(|name| {
379            let profile = load_profile(config, name);
380            profile.auth_type.as_deref().is_some_and(is_u2m_auth_type)
381                && same_auth_target(&selected_profile, &profile)
382        });
383    let Some(profile) = matches.next() else {
384        return Ok(selected);
385    };
386    if matches.next().is_some() {
387        return Ok(selected);
388    }
389    Ok(profile)
390}
391
392fn is_u2m_auth_type(auth_type: &str) -> bool {
393    auth_type == AUTH_TYPE_DATABRICKS_CLI
394}
395
396fn is_m2m_profile(profile: &RawProfile) -> bool {
397    profile.auth_type.as_deref() == Some(AUTH_TYPE_M2M)
398        || (profile.auth_type.is_none()
399            && profile.client_id.is_some()
400            && profile.client_secret.is_some())
401}
402
403fn same_auth_target(selected: &RawProfile, candidate: &RawProfile) -> bool {
404    let hosts_match = selected
405        .host
406        .as_deref()
407        .and_then(|host| normalize_host(host).ok())
408        .zip(
409            candidate
410                .host
411                .as_deref()
412                .and_then(|host| normalize_host(host).ok()),
413        )
414        .is_some_and(|(selected, candidate)| selected == candidate);
415    hosts_match
416        && selected
417            .account_id
418            .as_ref()
419            .is_none_or(|account_id| candidate.account_id.as_ref() == Some(account_id))
420        && selected
421            .workspace_id
422            .as_ref()
423            .is_none_or(|workspace_id| candidate.workspace_id.as_ref() == Some(workspace_id))
424}
425
426fn resolve_auth_kind(
427    auth_type: Option<&str>,
428    client_id: Option<&str>,
429    client_secret: Option<&str>,
430) -> Result<AuthKind> {
431    match auth_type {
432        Some(AUTH_TYPE_DATABRICKS_CLI) => Ok(AuthKind::UserToMachine),
433        Some(AUTH_TYPE_M2M) => {
434            if client_id.is_none() || client_secret.is_none() {
435                return Err(Error::Config(
436                    "oauth-m2m requires client_id and client_secret".into(),
437                ));
438            }
439            Ok(AuthKind::MachineToMachine)
440        }
441        Some(auth_type) => Err(Error::Config(format!(
442            "authentication type {auth_type} is not supported"
443        ))),
444        None if client_id.is_some() && client_secret.is_some() => Ok(AuthKind::MachineToMachine),
445        None if client_secret.is_some() => Err(Error::Config(
446            "oauth-m2m client_secret requires client_id".into(),
447        )),
448        None => Ok(AuthKind::UserToMachine),
449    }
450}
451
452fn resolve_profile_name(requested: Option<&str>, config: Option<&Ini>) -> Result<String> {
453    if let Some(profile) = requested {
454        if profile == SETTINGS_SECTION {
455            return Err(Error::Config(format!(
456                "{SETTINGS_SECTION} is a reserved section name and cannot be used as a profile"
457            )));
458        }
459        return Ok(profile.to_owned());
460    }
461    if let Some(profile) = config
462        .and_then(|config| config.get(SETTINGS_SECTION, "default_profile"))
463        .map(|value| value.trim().to_owned())
464        .filter(|value| !value.is_empty())
465    {
466        if profile == SETTINGS_SECTION {
467            return Err(Error::Config(format!(
468                "{SETTINGS_SECTION} is a reserved section name and cannot be used as a profile"
469            )));
470        }
471        return Ok(profile);
472    }
473    Ok("DEFAULT".to_owned())
474}
475
476fn load_profile(ini: &Ini, name: &str) -> RawProfile {
477    RawProfile {
478        host: ini.get(name, "host"),
479        account_id: ini.get(name, "account_id"),
480        workspace_id: ini.get(name, "workspace_id"),
481        client_id: ini.get(name, "client_id"),
482        client_secret: ini.get(name, "client_secret"),
483        group_id: ini.get(name, "group_id"),
484        scopes: ini.get(name, "scopes"),
485        auth_type: ini
486            .get(name, "auth_type")
487            .map(|value| value.trim().to_ascii_lowercase())
488            .filter(|value| !value.is_empty()),
489    }
490}
491
492fn normalize_host(value: &str) -> Result<Url> {
493    let value = value.trim().trim_end_matches('/');
494    let value = if value.starts_with("http://") || value.starts_with("https://") {
495        value.to_owned()
496    } else {
497        format!("https://{value}")
498    };
499    let url = Url::parse(&value)?;
500    if url.scheme() != "https"
501        && url
502            .host_str()
503            .is_none_or(|host| host != "127.0.0.1" && host != "localhost")
504    {
505        return Err(Error::Config("Databricks host must use HTTPS".into()));
506    }
507    Ok(url)
508}
509
510fn env_nonempty(name: &str) -> Option<String> {
511    env::var(name)
512        .ok()
513        .map(|value| value.trim().to_owned())
514        .filter(|value| !value.is_empty())
515}
516
517fn split_list(value: String) -> Vec<String> {
518    value
519        .split(',')
520        .map(str::trim)
521        .filter(|value| !value.is_empty())
522        .map(str::to_owned)
523        .collect()
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn profile_files_are_cached_by_absolute_path() {
532        let directory = tempfile::tempdir().unwrap();
533        let path = directory.path().join("databrickscfg");
534        std::fs::write(&path, "[DEFAULT]\nhost = first.example\n").unwrap();
535
536        let first = load_config(&path).unwrap().unwrap();
537        assert_eq!(
538            first.get("DEFAULT", "host").as_deref(),
539            Some("first.example")
540        );
541
542        std::fs::write(&path, "[DEFAULT]\nhost = second.example\n").unwrap();
543        let second = load_config(&path).unwrap().unwrap();
544        assert!(Arc::ptr_eq(&first, &second));
545        assert_eq!(
546            second.get("DEFAULT", "host").as_deref(),
547            Some("first.example")
548        );
549    }
550
551    #[test]
552    fn profile_option_debug_redacts_client_secrets() {
553        let options = ProfileOptions {
554            client_secret: Some("sensitive-client-secret".into()),
555            ..ProfileOptions::default()
556        };
557        let debug = format!("{options:?}");
558        assert!(!debug.contains("sensitive-client-secret"));
559        assert!(debug.contains("[REDACTED]"));
560    }
561
562    #[test]
563    fn configured_default_precedes_legacy_default() {
564        let mut config = Ini::new();
565        config.set(SETTINGS_SECTION, "default_profile", Some("selected".into()));
566        assert_eq!(
567            resolve_profile_name(None, Some(&config)).unwrap(),
568            "selected"
569        );
570    }
571
572    #[test]
573    fn default_is_the_legacy_fallback() {
574        assert_eq!(resolve_profile_name(None, None).unwrap(), "DEFAULT");
575    }
576
577    #[test]
578    fn settings_is_not_a_profile() {
579        assert!(resolve_profile_name(Some(SETTINGS_SECTION), None).is_err());
580    }
581
582    #[test]
583    fn reads_profile_auth_type() {
584        let mut config = Ini::new();
585        config.set("service", "auth_type", Some("oauth-m2m".into()));
586        config.set("service", "client_id", Some("client".into()));
587        config.set("service", "client_secret", Some("secret".into()));
588        config.set("service", "group_id", Some("group".into()));
589        let profile = load_profile(&config, "service");
590        assert_eq!(profile.auth_type.as_deref(), Some("oauth-m2m"));
591        assert_eq!(profile.client_id.as_deref(), Some("client"));
592        assert_eq!(profile.client_secret.as_deref(), Some("secret"));
593        assert_eq!(profile.group_id.as_deref(), Some("group"));
594    }
595
596    fn mixed_auth_config() -> Ini {
597        let mut config = Ini::new_cs();
598        config.set(SETTINGS_SECTION, "default_profile", Some("DEFAULT".into()));
599        config.set("DEFAULT", "host", Some("https://workspace.example".into()));
600        config.set("DEFAULT", "auth_type", Some("oauth-m2m".into()));
601        config.set("FEVM-AWS", "host", Some("https://workspace.example".into()));
602        config.set("FEVM-AWS", "auth_type", Some("databricks-cli".into()));
603        config
604    }
605
606    #[test]
607    fn implicit_m2m_default_maps_to_unique_u2m_profile_on_same_host() {
608        let config = mixed_auth_config();
609        assert_eq!(
610            resolve_auth_profile_name(None, false, Some(&config), true).unwrap(),
611            "FEVM-AWS"
612        );
613    }
614
615    #[test]
616    fn disabled_u2m_preference_keeps_the_m2m_default() {
617        let config = mixed_auth_config();
618        assert_eq!(
619            resolve_auth_profile_name(None, false, Some(&config), false).unwrap(),
620            "DEFAULT"
621        );
622    }
623
624    #[test]
625    fn implicit_credentials_without_auth_type_still_prefer_u2m() {
626        let mut config = mixed_auth_config();
627        config.remove_key("DEFAULT", "auth_type");
628        config.set("DEFAULT", "client_id", Some("client".into()));
629        config.set("DEFAULT", "client_secret", Some("secret".into()));
630        assert_eq!(
631            resolve_auth_profile_name(None, false, Some(&config), true).unwrap(),
632            "FEVM-AWS"
633        );
634    }
635
636    #[test]
637    fn only_databricks_cli_is_u2m_compatible() {
638        assert!(is_u2m_auth_type(AUTH_TYPE_DATABRICKS_CLI));
639        assert!(!is_u2m_auth_type(AUTH_TYPE_M2M));
640        assert!(!is_u2m_auth_type("external-browser"));
641    }
642
643    #[test]
644    fn non_m2m_default_is_not_remapped() {
645        let mut config = mixed_auth_config();
646        config.set("DEFAULT", "auth_type", Some("pat".into()));
647        assert_eq!(
648            resolve_auth_profile_name(None, false, Some(&config), true).unwrap(),
649            "DEFAULT"
650        );
651    }
652
653    #[test]
654    fn explicit_m2m_profile_is_not_remapped() {
655        let config = mixed_auth_config();
656        assert_eq!(
657            resolve_auth_profile_name(Some("DEFAULT"), true, Some(&config), true).unwrap(),
658            "DEFAULT"
659        );
660    }
661
662    #[test]
663    fn ambiguous_u2m_host_match_does_not_remap() {
664        let mut config = mixed_auth_config();
665        config.set(
666            "FEVM-AWS-2",
667            "host",
668            Some("https://workspace.example".into()),
669        );
670        config.set("FEVM-AWS-2", "auth_type", Some("databricks-cli".into()));
671        assert_eq!(
672            resolve_auth_profile_name(None, false, Some(&config), true).unwrap(),
673            "DEFAULT"
674        );
675    }
676
677    #[test]
678    fn different_host_does_not_remap() {
679        let mut config = mixed_auth_config();
680        config.set("FEVM-AWS", "host", Some("https://other.example".into()));
681        assert_eq!(
682            resolve_auth_profile_name(None, false, Some(&config), true).unwrap(),
683            "DEFAULT"
684        );
685    }
686
687    #[test]
688    fn equivalent_hosts_match_after_normalization() {
689        let mut config = mixed_auth_config();
690        config.set("DEFAULT", "host", Some("workspace.example/".into()));
691        config.set("FEVM-AWS", "host", Some("https://workspace.example".into()));
692        assert_eq!(
693            resolve_auth_profile_name(None, false, Some(&config), true).unwrap(),
694            "FEVM-AWS"
695        );
696    }
697
698    #[test]
699    fn different_account_does_not_remap() {
700        let mut config = mixed_auth_config();
701        config.set("DEFAULT", "account_id", Some("account-a".into()));
702        config.set("FEVM-AWS", "account_id", Some("account-b".into()));
703        assert_eq!(
704            resolve_auth_profile_name(None, false, Some(&config), true).unwrap(),
705            "DEFAULT"
706        );
707    }
708
709    #[test]
710    fn resolves_explicit_and_default_m2m_credentials() {
711        assert_eq!(
712            resolve_auth_kind(Some(AUTH_TYPE_M2M), Some("client"), Some("secret")).unwrap(),
713            AuthKind::MachineToMachine
714        );
715        assert_eq!(
716            resolve_auth_kind(None, Some("client"), Some("secret")).unwrap(),
717            AuthKind::MachineToMachine
718        );
719        assert_eq!(
720            resolve_auth_kind(None, Some("client"), None).unwrap(),
721            AuthKind::UserToMachine
722        );
723        assert!(resolve_auth_kind(Some(AUTH_TYPE_M2M), Some("client"), None).is_err());
724        assert!(resolve_auth_kind(None, None, Some("secret")).is_err());
725    }
726
727    #[test]
728    fn explicit_m2m_profile_builds_without_browser_auth() {
729        let directory = tempfile::tempdir().unwrap();
730        let profile = Profile::from_sources(ProfileOptions {
731            profile: Some("service".into()),
732            host: Some("http://127.0.0.1:8080".into()),
733            client_id: Some("client".into()),
734            client_secret: Some("secret".into()),
735            auth_type: Some(AUTH_TYPE_M2M.into()),
736            config_file: Some(directory.path().join("missing")),
737            ..ProfileOptions::default()
738        })
739        .unwrap();
740        assert_eq!(profile.auth_kind, AuthKind::MachineToMachine);
741        assert_eq!(profile.client_secret(), Some("secret"));
742    }
743
744    #[test]
745    fn client_credentials_inference_does_not_depend_on_profile_preference() {
746        let directory = tempfile::tempdir().unwrap();
747        let options = |prefer_user_to_machine| ProfileOptions {
748            profile: Some("service".into()),
749            host: Some("http://127.0.0.1:8080".into()),
750            client_id: Some("client".into()),
751            client_secret: Some("secret".into()),
752            config_file: Some(directory.path().join("missing")),
753            prefer_user_to_machine,
754            ..ProfileOptions::default()
755        };
756        assert_eq!(
757            Profile::from_sources(options(true)).unwrap().auth_kind,
758            AuthKind::MachineToMachine
759        );
760        assert_eq!(
761            Profile::from_sources(options(false)).unwrap().auth_kind,
762            AuthKind::MachineToMachine
763        );
764    }
765
766    #[test]
767    fn m2m_cache_keys_include_client_group_and_scopes() {
768        let profile = Profile {
769            name: "service".into(),
770            host: Url::parse("https://workspace.example").unwrap(),
771            account_id: None,
772            workspace_id: None,
773            client_id: "client".into(),
774            group_id: Some("group".into()),
775            scopes: vec!["jobs".into(), "files:read".into()],
776            target: TargetKind::Workspace,
777            auth_kind: AuthKind::MachineToMachine,
778            client_secret: Some("credential-value".into()),
779        };
780        let key = profile.cache_key();
781        assert!(key.starts_with("service-oauth-m2m-"));
782        assert!(!key.contains("credential-value"));
783        assert!(!format!("{profile:?}").contains("credential-value"));
784    }
785}