Skip to main content

azure_identity/
developer_tools_credential.rs

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT License.
3
4use crate::{
5    AzureCliCredential, AzureCliCredentialOptions, AzureDeveloperCliCredential,
6    AzureDeveloperCliCredentialOptions, Executor,
7};
8use azure_core::{
9    credentials::{AccessToken, TokenCredential, TokenRequestOptions},
10    error::{Error, ErrorKind},
11};
12use std::{
13    any::type_name,
14    fmt,
15    sync::{
16        atomic::{AtomicUsize, Ordering},
17        Arc,
18    },
19};
20
21/// Options for constructing a new [`DeveloperToolsCredential`]
22#[derive(Clone, Default)]
23pub struct DeveloperToolsCredentialOptions {
24    /// An implementation of [`Executor`] to run commands asynchronously.
25    ///
26    /// If `None`, one is created using [`crate::process::new_executor`]; alternatively,
27    /// you can supply your own implementation using a different asynchronous runtime.
28    pub executor: Option<Arc<dyn Executor>>,
29}
30
31impl fmt::Debug for DeveloperToolsCredentialOptions {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.debug_struct(type_name::<Self>()).finish_non_exhaustive()
34    }
35}
36
37/// Authenticates through developer tools such as the Azure CLI.
38///
39/// It tries the following credential types, in this order, stopping when one provides a token:
40///
41/// * [`AzureCliCredential`]
42/// * [`AzureDeveloperCliCredential`]
43///
44/// `DeveloperToolsCredential` uses the first credential that provides a token for all subsequent token requests. It never tries the others again.
45pub struct DeveloperToolsCredential {
46    sources: Vec<Arc<dyn TokenCredential>>,
47    // index of the source that first provided a token. usize::MAX indicates no source has provided a token.
48    cached_source_index: AtomicUsize,
49}
50
51impl DeveloperToolsCredential {
52    /// Creates a new instance of `DeveloperToolsCredential`.
53    ///
54    /// # Arguments
55    /// * `options`: Options for configuring the credential. If `None`, the credential uses its default options.
56    pub fn new(
57        options: Option<DeveloperToolsCredentialOptions>,
58    ) -> azure_core::Result<Arc<DeveloperToolsCredential>> {
59        let options = options.unwrap_or_default();
60        let sources: Vec<Arc<dyn TokenCredential>> = vec![
61            AzureCliCredential::new(Some(AzureCliCredentialOptions {
62                executor: options.executor.clone(),
63                ..Default::default()
64            }))?,
65            AzureDeveloperCliCredential::new(Some(AzureDeveloperCliCredentialOptions {
66                executor: options.executor,
67                ..Default::default()
68            }))?,
69        ];
70        Ok(Arc::new(Self {
71            sources,
72            cached_source_index: AtomicUsize::new(usize::MAX),
73        }))
74    }
75
76    #[cfg(test)]
77    pub(crate) fn new_with_sources(
78        sources: Vec<Arc<dyn TokenCredential>>,
79    ) -> azure_core::Result<Arc<DeveloperToolsCredential>> {
80        Ok(Arc::new(Self {
81            sources,
82            cached_source_index: AtomicUsize::new(usize::MAX),
83        }))
84    }
85}
86
87impl fmt::Debug for DeveloperToolsCredential {
88    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89        f.debug_struct(type_name::<Self>()).finish_non_exhaustive()
90    }
91}
92
93#[async_trait::async_trait]
94impl TokenCredential for DeveloperToolsCredential {
95    async fn get_token(
96        &self,
97        scopes: &[&str],
98        options: Option<TokenRequestOptions<'_>>,
99    ) -> azure_core::Result<AccessToken> {
100        let cached_index = self.cached_source_index.load(Ordering::Relaxed);
101        if cached_index != usize::MAX {
102            if let Some(source) = self.sources.get(cached_index) {
103                return source.get_token(scopes, options).await;
104            }
105            // impossible because the vector's size never changes
106            panic!("DeveloperToolsCredential source index {cached_index} is out of bounds")
107        }
108
109        let mut errors = Vec::new();
110        for (index, source) in self.sources.iter().enumerate() {
111            match source.get_token(scopes, options.clone()).await {
112                Ok(token) => {
113                    self.cached_source_index.store(index, Ordering::Relaxed);
114                    return Ok(token);
115                }
116                Err(error) => errors.push(error),
117            }
118        }
119        Err(Error::with_message_fn(ErrorKind::Credential, || {
120            format!(
121                "Multiple errors were encountered while attempting to authenticate:\n{}",
122                format_aggregate_error(&errors)
123            )
124        }))
125    }
126}
127
128fn format_aggregate_error(errors: &[Error]) -> String {
129    use std::error::Error;
130    errors
131        .iter()
132        .map(|e| {
133            let mut current: Option<&dyn Error> = Some(e);
134            let mut stack = vec![];
135            while let Some(err) = current.take() {
136                stack.push(err.to_string());
137                current = err.source();
138            }
139            stack.join(" - ")
140        })
141        .collect::<Vec<String>>()
142        .join("\n")
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148    use crate::tests::MockExecutor;
149    use azure_core::credentials::AccessToken;
150    use std::sync::atomic::{AtomicUsize, Ordering};
151    use std::time::{Duration, SystemTime};
152
153    #[derive(Debug)]
154    struct MockCredential {
155        call_count: AtomicUsize,
156        id: String,
157        succeed: bool,
158    }
159
160    impl MockCredential {
161        fn new(id: &str, succeed: bool) -> Arc<Self> {
162            Arc::new(Self {
163                call_count: AtomicUsize::new(0),
164                id: id.to_string(),
165                succeed,
166            })
167        }
168
169        fn call_count(&self) -> usize {
170            self.call_count.load(Ordering::SeqCst)
171        }
172    }
173
174    #[async_trait::async_trait]
175    impl TokenCredential for MockCredential {
176        async fn get_token(
177            &self,
178            _scopes: &[&str],
179            _options: Option<TokenRequestOptions<'_>>,
180        ) -> azure_core::Result<AccessToken> {
181            self.call_count.fetch_add(1, Ordering::SeqCst);
182            if self.succeed {
183                Ok(AccessToken {
184                    token: self.id.clone().into(),
185                    expires_on: (SystemTime::now() + Duration::from_secs(3600)).into(),
186                })
187            } else {
188                Err(Error::with_message_fn(ErrorKind::Credential, || {
189                    format!("{} failed", self.id)
190                }))
191            }
192        }
193    }
194
195    #[tokio::test]
196    async fn cached_credential() {
197        let mock1 = MockCredential::new("mock1", false);
198        let mock2 = MockCredential::new("mock2", false);
199        let mock3 = MockCredential::new("mock3", true);
200        let mock4 = MockCredential::new("mock4", true);
201        let sources: Vec<Arc<dyn TokenCredential>> =
202            vec![mock1.clone(), mock2.clone(), mock3.clone(), mock4.clone()];
203
204        let credential = DeveloperToolsCredential::new_with_sources(sources).unwrap();
205
206        for i in 1..=5 {
207            let token = credential
208                .get_token(&["scope"], None)
209                .await
210                .expect("authentication success");
211            assert_eq!(token.token.secret(), "mock3");
212            assert_eq!(mock1.call_count(), 1);
213            assert_eq!(mock2.call_count(), 1);
214            assert_eq!(mock3.call_count(), i);
215            assert_eq!(mock4.call_count(), 0);
216        }
217    }
218
219    #[tokio::test]
220    async fn error_message() {
221        let mock1 = MockCredential::new("mock1", false);
222        let mock2 = MockCredential::new("mock2", false);
223        let mock3 = MockCredential::new("mock3", false);
224        let sources: Vec<Arc<dyn TokenCredential>> =
225            vec![mock1.clone(), mock2.clone(), mock3.clone()];
226
227        let credential = DeveloperToolsCredential::new_with_sources(sources).unwrap();
228
229        let error_msg = credential
230            .get_token(&["scope"], None)
231            .await
232            .expect_err("authentication error")
233            .to_string();
234
235        assert_eq!(mock1.call_count(), 1);
236        assert_eq!(mock2.call_count(), 1);
237        assert_eq!(mock3.call_count(), 1);
238        assert_eq!(
239            "Multiple errors were encountered while attempting to authenticate:\nmock1 failed\nmock2 failed\nmock3 failed",
240            error_msg
241        );
242    }
243
244    #[tokio::test]
245    async fn executor() {
246        let err = std::io::Error::other("something went wrong");
247        let executor = MockExecutor::with_error(err);
248        let options = DeveloperToolsCredentialOptions {
249            executor: Some(executor.clone()),
250        };
251        let err = DeveloperToolsCredential::new(Some(options))
252            .expect("valid credential")
253            .get_token(&["scope"], None)
254            .await
255            .expect_err("expected error");
256        assert_eq!(
257            "Multiple errors were encountered while attempting to authenticate:\n\
258             AzureCliCredential authentication failed. other error error: something went wrong\n\
259             To troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#azure-cli - other error error: something went wrong - something went wrong\n\
260             AzureDeveloperCliCredential authentication failed. other error error: something went wrong\n\
261             To troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#azd - other error error: something went wrong - something went wrong",
262            err.to_string()
263        );
264        assert_eq!(
265            2,
266            executor.call_count(),
267            "Executor should have been called once for each inner credential"
268        );
269    }
270}