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