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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub enum AuthKind {
33 #[default]
35 UserToMachine,
36 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 pub group_id: Option<String>,
57 pub scopes: Vec<String>,
58 pub target: TargetKind,
59 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#[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 pub client_secret: Option<String>,
235 pub group_id: Option<String>,
237 pub auth_type: Option<String>,
239 pub scopes: Option<Vec<String>>,
240 pub target: Option<TargetKind>,
241 pub config_file: Option<PathBuf>,
242 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}