Skip to main content

azure_identity/
azure_cli_credential.rs

1// Copyright (c) Microsoft Corporation. All rights reserved.
2// Licensed under the MIT License.
3
4use crate::{
5    authentication_error,
6    env::Env,
7    process::{new_executor, shell_exec, Executor, OutputProcessor},
8    validate_scope, validate_subscription, validate_tenant_id,
9};
10use azure_core::{
11    credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions},
12    error::{Error, ErrorKind, ResultExt},
13    json::from_json,
14    time::OffsetDateTime,
15};
16use serde::Deserialize;
17use std::{any::type_name, ffi::OsString, fmt, sync::Arc};
18use tracing::trace;
19
20/// The response from `az account get-access-token --output json`.
21#[derive(Debug, Clone, Deserialize)]
22struct CliTokenResponse {
23    #[serde(rename = "accessToken")]
24    pub access_token: Secret,
25    #[serde(rename = "expires_on")]
26    /// The token's expiry time in seconds since the epoch, a unix timestamp.
27    /// Available in Azure CLI 2.54.0 or newer.
28    pub expires_on: Option<i64>,
29    #[allow(unused)]
30    #[serde(rename = "tokenType")]
31    pub token_type: String,
32}
33
34impl CliTokenResponse {
35    pub fn expires_on(&self) -> azure_core::Result<OffsetDateTime> {
36        match self.expires_on {
37            Some(timestamp) => Ok(OffsetDateTime::from_unix_timestamp(timestamp)
38                .with_context_fn(ErrorKind::DataConversion, || {
39                    format!("unable to parse expires_on '{timestamp}'")
40                })?),
41            None => Err(Error::with_message(
42                ErrorKind::DataConversion,
43                "expires_on field not found. Please use Azure CLI 2.54.0 or newer.",
44            )),
45        }
46    }
47}
48
49impl OutputProcessor for CliTokenResponse {
50    fn get_error_message(_stderr: &str) -> Option<String> {
51        // Azure CLI's errors are generally clear and more helpful than anything we'd write here
52        None
53    }
54
55    fn deserialize_token(stdout: &str) -> azure_core::Result<AccessToken> {
56        let response: Self = from_json(stdout)?;
57        let expires_on = response.expires_on()?;
58        Ok(AccessToken::new(response.access_token, expires_on))
59    }
60
61    fn tool_name() -> &'static str {
62        "az"
63    }
64}
65
66/// Authenticates the identity logged in to the [Azure CLI](https://learn.microsoft.com/cli/azure/what-is-azure-cli).
67pub struct AzureCliCredential {
68    env: Env,
69    executor: Arc<dyn Executor>,
70    subscription: Option<String>,
71    tenant_id: Option<String>,
72}
73
74impl fmt::Debug for AzureCliCredential {
75    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
76        f.debug_struct(type_name::<Self>())
77            .field("tenant_id", &self.tenant_id)
78            .finish_non_exhaustive()
79    }
80}
81
82/// Options for constructing an [`AzureCliCredential`].
83#[derive(Clone, Default)]
84pub struct AzureCliCredentialOptions {
85    /// The name or ID of a subscription.
86    ///
87    /// Set this to acquire tokens for an account other than the Azure CLI's current account.
88    pub subscription: Option<String>,
89
90    /// Identifies the tenant the credential should authenticate in.
91    ///
92    /// Defaults to the CLI's default tenant, which is typically the home tenant of the logged in user.
93    pub tenant_id: Option<String>,
94
95    /// An implementation of [`Executor`] to run commands asynchronously.
96    ///
97    /// If `None`, one is created using [`new_executor`]; alternatively,
98    /// you can supply your own implementation using a different asynchronous runtime.
99    pub executor: Option<Arc<dyn Executor>>,
100
101    #[cfg(test)]
102    pub(crate) env: Option<Env>,
103}
104
105impl fmt::Debug for AzureCliCredentialOptions {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        f.debug_struct(type_name::<Self>())
108            .field("tenant_id", &self.tenant_id)
109            .finish_non_exhaustive()
110    }
111}
112
113impl AzureCliCredential {
114    /// Create a new `AzureCliCredential`.
115    pub fn new(options: Option<AzureCliCredentialOptions>) -> azure_core::Result<Arc<Self>> {
116        let options = options.unwrap_or_default();
117        if let Some(ref tenant_id) = options.tenant_id {
118            validate_tenant_id(tenant_id)?;
119        }
120        if let Some(ref subscription) = options.subscription {
121            validate_subscription(subscription)?;
122        }
123        #[cfg(test)]
124        let env = options.env.unwrap_or_default();
125        #[cfg(not(test))]
126        let env = Env::default();
127
128        Ok(Arc::new(Self {
129            env,
130            executor: options.executor.unwrap_or(new_executor()),
131            subscription: options.subscription,
132            tenant_id: options.tenant_id,
133        }))
134    }
135}
136
137#[async_trait::async_trait]
138impl TokenCredential for AzureCliCredential {
139    /// Requests a token from the Azure CLI. This credential doesn't cache tokens, so every call invokes the CLI.
140    async fn get_token(
141        &self,
142        scopes: &[&str],
143        _: Option<TokenRequestOptions<'_>>,
144    ) -> azure_core::Result<AccessToken> {
145        if scopes.is_empty() {
146            return Err(Error::new(
147                ErrorKind::Credential,
148                "exactly one scope required",
149            ));
150        }
151        validate_scope(scopes[0])?;
152
153        let mut command = OsString::from("az account get-access-token -o json --scope ");
154        command.push(scopes[0]);
155        if let Some(ref tenant_id) = self.tenant_id {
156            command.push(" --tenant ");
157            command.push(tenant_id);
158        }
159        if let Some(ref subscription) = self.subscription {
160            command.push(r#" --subscription ""#);
161            command.push(subscription);
162            command.push("\"");
163        }
164
165        trace!("running Azure CLI command: {command:?}");
166
167        shell_exec::<CliTokenResponse>(self.executor.clone(), &self.env, &command)
168            .await
169            .map_err(|err| authentication_error(stringify!(AzureCliCredential), err))
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176    use crate::tests::{MockExecutor, FAKE_TENANT_ID, FAKE_TOKEN, LIVE_TEST_SCOPES};
177    use std::ffi::OsStr;
178    use time::{format_description::well_known::Rfc3339, UtcOffset};
179
180    async fn run_test(
181        exit_code: i32,
182        stdout: &str,
183        stderr: &str,
184        subscription: Option<String>,
185        tenant_id: Option<String>,
186    ) -> azure_core::Result<AccessToken> {
187        let subscription_for_on_run = subscription.clone();
188        let tenant_for_on_run = tenant_id.clone();
189        let system_root = "/dev/null";
190        let options =
191            AzureCliCredentialOptions {
192                env: Some(Env::from(&[("SYSTEMROOT", system_root)][..])),
193                executor: Some(MockExecutor::with_output(
194                    exit_code,
195                    stdout,
196                    stderr,
197                    Some(Arc::new(move |program: &OsStr, args: &[&OsStr]| {
198                        let args: Vec<String> = args
199                            .iter()
200                            .map(|arg| arg.to_string_lossy().to_string())
201                            .collect();
202                        if cfg!(target_os = "windows") {
203                            assert_eq!(program.to_string_lossy(), "cmd");
204                            assert_eq!(args[0], "/C");
205                            assert!(args[1].starts_with(&format!(
206                                "cd {system_root} && az account get-access-token -o json"
207                            )));
208                        } else {
209                            assert_eq!(program, "/bin/sh");
210                            assert_eq!(args[0], "-c");
211                            assert!(args[1]
212                                .starts_with("cd /bin && az account get-access-token -o json"));
213                        }
214                        for scope in LIVE_TEST_SCOPES {
215                            assert!(args[1].contains(&format!(" --scope {scope}")));
216                        }
217                        if let Some(ref subscription_id) = subscription_for_on_run {
218                            assert!(args[1]
219                                .contains(&format!(r#" --subscription "{subscription_id}""#)));
220                        } else {
221                            assert!(!args[1].contains("--subscription"));
222                        }
223                        if let Some(ref tenant_id) = tenant_for_on_run {
224                            assert!(args[1].contains(&format!(" --tenant {tenant_id}")));
225                        } else {
226                            assert!(!args[1].contains("--tenant"));
227                        }
228                    })),
229                )),
230                tenant_id,
231                subscription,
232            };
233        let cred = AzureCliCredential::new(Some(options))?;
234        return cred.get_token(LIVE_TEST_SCOPES, None).await;
235    }
236
237    #[tokio::test]
238    async fn error_includes_stderr() {
239        let err = run_test(1, "stdout", "something went wrong", None, None)
240            .await
241            .expect_err("expected error");
242        assert!(matches!(err.kind(), ErrorKind::Credential));
243        assert_eq!(
244            "AzureCliCredential authentication failed. something went wrong\nTo troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#azure-cli",
245            err.to_string()
246        );
247    }
248
249    #[tokio::test]
250    async fn get_token_success() {
251        let expires_on = OffsetDateTime::parse("2038-01-18T00:00:00Z", &Rfc3339).unwrap();
252        let stdout = format!(
253            r#"{{"accessToken":"{FAKE_TOKEN}",
254            "expiresOn":"2030-01-02 03:04:05.000000",
255            "expires_on":{},
256            "subscription":"...",
257            "tenant":"{FAKE_TENANT_ID}",
258            "tokenType":"Bearer"}}"#,
259            expires_on.unix_timestamp()
260        );
261
262        let token = run_test(0, &stdout, "", None, None).await.expect("token");
263
264        assert_eq!(FAKE_TOKEN, token.token.secret());
265        assert_eq!(UtcOffset::UTC, token.expires_on.offset());
266        assert_eq!(expires_on, token.expires_on);
267    }
268
269    #[tokio::test]
270    async fn not_logged_in() {
271        let err = run_test(1, "", "Please run 'az login' to setup account.", None, None)
272            .await
273            .expect_err("error");
274        assert!(matches!(err.kind(), ErrorKind::Credential));
275        assert_eq!(
276            "AzureCliCredential authentication failed. Please run 'az login' to setup account.\nTo troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#azure-cli",
277            err.to_string()
278        );
279    }
280
281    #[tokio::test]
282    async fn program_not_found() {
283        let executor = MockExecutor::with_error(std::io::Error::from_raw_os_error(127));
284        let options = AzureCliCredentialOptions {
285            executor: Some(executor),
286            ..Default::default()
287        };
288
289        let err = AzureCliCredential::new(Some(options))
290            .expect("valid credential")
291            .get_token(LIVE_TEST_SCOPES, None)
292            .await
293            .expect_err("expected error");
294
295        assert!(matches!(err.kind(), ErrorKind::Credential));
296    }
297
298    #[tokio::test]
299    async fn subscription() {
300        let expires_on = OffsetDateTime::parse("2038-01-18T00:00:00Z", &Rfc3339).unwrap();
301        let subscription = "subscription name";
302        let stdout = format!(
303            r#"{{"accessToken":"{FAKE_TOKEN}",
304            "expiresOn":"2030-01-02 03:04:05.000000",
305            "expires_on":{},
306            "subscription":"{subscription}",
307            "tenant":"{FAKE_TENANT_ID}",
308            "tokenType":"Bearer"}}"#,
309            expires_on.unix_timestamp()
310        );
311
312        let token = run_test(0, &stdout, "", Some(subscription.to_string()), None)
313            .await
314            .expect("token");
315
316        assert_eq!(FAKE_TOKEN, token.token.secret());
317        assert_eq!(UtcOffset::UTC, token.expires_on.offset());
318        assert_eq!(expires_on, token.expires_on);
319    }
320
321    #[tokio::test]
322    async fn tenant_id() {
323        let expires_on = OffsetDateTime::parse("2038-01-18T00:00:00Z", &Rfc3339).unwrap();
324        let stdout = format!(
325            r#"{{"accessToken":"{FAKE_TOKEN}",
326            "expiresOn":"2030-01-02 03:04:05.000000",
327            "expires_on":{},
328            "subscription":"...",
329            "tenant":"{FAKE_TENANT_ID}",
330            "tokenType":"Bearer"}}"#,
331            expires_on.unix_timestamp()
332        );
333
334        let token = run_test(0, &stdout, "", None, Some(FAKE_TENANT_ID.to_string()))
335            .await
336            .expect("token");
337
338        assert_eq!(FAKE_TOKEN, token.token.secret());
339        assert_eq!(UtcOffset::UTC, token.expires_on.offset());
340        assert_eq!(expires_on, token.expires_on);
341    }
342}