1use 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#[derive(Debug, Clone, Deserialize)]
22struct CliTokenResponse {
23 #[serde(rename = "accessToken")]
24 pub access_token: Secret,
25 #[serde(rename = "expires_on")]
26 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 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
66pub 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#[derive(Clone, Default)]
84pub struct AzureCliCredentialOptions {
85 pub subscription: Option<String>,
89
90 pub tenant_id: Option<String>,
94
95 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 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 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}