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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
33pub enum AuthKind {
34 #[default]
36 UserToMachine,
37 MachineToMachine,
39 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 pub group_id: Option<String>,
60 pub scopes: Vec<String>,
61 pub target: TargetKind,
62 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#[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 pub client_secret: Option<String>,
257 pub access_token: Option<String>,
259 pub group_id: Option<String>,
261 pub auth_type: Option<String>,
263 pub scopes: Option<Vec<String>>,
264 pub target: Option<TargetKind>,
265 pub config_file: Option<PathBuf>,
266 pub prefer_user_to_machine: bool,
268 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}