1use crate::{
5 authentication_error,
6 env::Env,
7 process::{new_executor, shell_exec, Executor, OutputProcessor},
8 validate_scope, validate_tenant_id,
9};
10use azure_core::{
11 credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions},
12 error::{Error, ErrorKind},
13 json::from_json,
14 time::OffsetDateTime,
15};
16use serde::de::{self, Deserializer};
17use serde::Deserialize;
18use std::{any::type_name, ffi::OsString, fmt, sync::Arc};
19use time::format_description::well_known::Rfc3339;
20
21#[derive(Clone, Debug, Deserialize)]
22struct AzdTokenResponse {
23 #[serde(rename = "token")]
24 pub access_token: Secret,
25 #[serde(rename = "expiresOn", deserialize_with = "parse_expires_on")]
26 pub expires_on: OffsetDateTime,
27}
28
29fn parse_expires_on<'de, D>(deserializer: D) -> std::result::Result<OffsetDateTime, D::Error>
30where
31 D: Deserializer<'de>,
32{
33 let s: &str = Deserialize::deserialize(deserializer)?;
34 OffsetDateTime::parse(s, &Rfc3339).map_err(de::Error::custom)
35}
36
37#[derive(Debug, Deserialize)]
39struct AzdErrorOutput {
40 data: AzdErrorData,
41}
42
43#[derive(Debug, Deserialize)]
44struct AzdErrorData {
45 message: String,
46}
47
48impl OutputProcessor for AzdTokenResponse {
49 fn deserialize_token(stdout: &str) -> azure_core::Result<AccessToken> {
50 let response: Self = from_json(stdout)?;
51 Ok(AccessToken::new(response.access_token, response.expires_on))
52 }
53
54 fn get_error_message(stderr: &str) -> Option<String> {
55 if let Ok(error_output) = from_json::<_, AzdErrorOutput>(stderr) {
57 let message = error_output.data.message.trim();
58 if !message.is_empty() {
59 return Some(format!("\n{message}"));
60 }
61 }
62 None
64 }
65
66 fn tool_name() -> &'static str {
67 "azd"
68 }
69}
70
71pub struct AzureDeveloperCliCredential {
73 env: Env,
74 executor: Arc<dyn Executor>,
75 tenant_id: Option<String>,
76}
77
78impl fmt::Debug for AzureDeveloperCliCredential {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 f.debug_struct(type_name::<Self>())
81 .field("tenant_id", &self.tenant_id)
82 .finish_non_exhaustive()
83 }
84}
85
86#[derive(Clone, Default)]
88pub struct AzureDeveloperCliCredentialOptions {
89 pub executor: Option<Arc<dyn Executor>>,
94
95 pub tenant_id: Option<String>,
99
100 #[cfg(test)]
101 pub(crate) env: Option<Env>,
102}
103
104impl fmt::Debug for AzureDeveloperCliCredentialOptions {
105 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 f.debug_struct(type_name::<Self>())
107 .field("tenant_id", &self.tenant_id)
108 .finish_non_exhaustive()
109 }
110}
111
112impl AzureDeveloperCliCredential {
113 pub fn new(
115 options: Option<AzureDeveloperCliCredentialOptions>,
116 ) -> azure_core::Result<Arc<Self>> {
117 let options = options.unwrap_or_default();
118 if let Some(ref tenant_id) = options.tenant_id {
119 validate_tenant_id(tenant_id)?;
120 }
121 #[cfg(test)]
122 let env = options.env.unwrap_or_default();
123 #[cfg(not(test))]
124 let env = Env::default();
125 let executor = options.executor.unwrap_or(new_executor());
126 Ok(Arc::new(Self {
127 env,
128 executor,
129 tenant_id: options.tenant_id,
130 }))
131 }
132}
133
134#[async_trait::async_trait]
135impl TokenCredential for AzureDeveloperCliCredential {
136 async fn get_token(
137 &self,
138 scopes: &[&str],
139 _: Option<TokenRequestOptions<'_>>,
140 ) -> azure_core::Result<AccessToken> {
141 if scopes.is_empty() {
142 return Err(Error::new(
143 ErrorKind::Credential,
144 "at least one scope required",
145 ));
146 }
147 let mut command = OsString::from("azd auth token -o json --no-prompt");
148 for scope in scopes {
149 validate_scope(scope)?;
150 command.push(" --scope ");
151 command.push(scope);
152 }
153 if let Some(ref tenant_id) = self.tenant_id {
154 command.push(" --tenant-id ");
155 command.push(tenant_id);
156 }
157 shell_exec::<AzdTokenResponse>(self.executor.clone(), &self.env, &command)
158 .await
159 .map_err(|err| authentication_error(stringify!(AzureDeveloperCliCredential), err))
160 }
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166 use crate::tests::{MockExecutor, FAKE_TENANT_ID, FAKE_TOKEN, LIVE_TEST_SCOPES};
167 use std::ffi::OsStr;
168 use time::UtcOffset;
169
170 async fn run_test(
171 exit_code: i32,
172 stdout: &str,
173 stderr: &str,
174 tenant_id: Option<String>,
175 ) -> azure_core::Result<AccessToken> {
176 let tenant_id_for_on_run = tenant_id.clone();
177 let system_root = "/dev/null";
178 let options = AzureDeveloperCliCredentialOptions {
179 env: Some(Env::from(&[("SYSTEMROOT", system_root)][..])),
180 executor: Some(MockExecutor::with_output(
181 exit_code,
182 stdout,
183 stderr,
184 Some(Arc::new(move |program: &OsStr, args: &[&OsStr]| {
185 let args: Vec<String> = args
186 .iter()
187 .map(|arg| arg.to_string_lossy().to_string())
188 .collect();
189 if cfg!(target_os = "windows") {
190 assert_eq!(program.to_string_lossy(), "cmd");
191 assert_eq!(args[0], "/C");
192 assert!(args[1].starts_with(&format!(
193 "cd {system_root} && azd auth token -o json --no-prompt"
194 )));
195 } else {
196 assert_eq!(program, "/bin/sh");
197 assert_eq!(args[0], "-c");
198 assert!(
199 args[1].starts_with("cd /bin && azd auth token -o json --no-prompt")
200 );
201 }
202 for scope in LIVE_TEST_SCOPES {
203 assert!(args[1].contains(&format!(" --scope {scope}")));
204 }
205 if let Some(ref tenant_id) = tenant_id_for_on_run {
206 assert!(args[1].ends_with(&format!(" --tenant-id {tenant_id}")));
207 } else {
208 assert!(!args[1].contains("--tenant-id"));
209 }
210 })),
211 )),
212 tenant_id,
213 };
214 let cred = AzureDeveloperCliCredential::new(Some(options))?;
215 return cred.get_token(LIVE_TEST_SCOPES, None).await;
216 }
217
218 #[tokio::test]
219 async fn error_includes_stderr() {
220 let err = run_test(1, "stdout", "something went wrong", None)
221 .await
222 .expect_err("expected error");
223 assert!(matches!(err.kind(), ErrorKind::Credential));
224 assert_eq!(
225 "AzureDeveloperCliCredential authentication failed. something went wrong\nTo troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#azd",
226 err.to_string()
227 );
228 }
229
230 #[tokio::test]
231 async fn get_token_success() {
232 let expires_on = "2038-01-18T00:00:00Z";
233 let stdout = format!(r#"{{"token":"{FAKE_TOKEN}","expiresOn":"{expires_on}"}}"#);
234 let token = run_test(0, &stdout, "", None).await.expect("token");
235 assert_eq!(FAKE_TOKEN, token.token.secret());
236 assert_eq!(
237 OffsetDateTime::parse(expires_on, &Rfc3339).unwrap(),
238 token.expires_on
239 );
240 assert_eq!(UtcOffset::UTC, token.expires_on.offset());
241 }
242
243 #[tokio::test]
244 async fn not_logged_in() {
245 let stderr = r#"{"type":"consoleMessage","timestamp":"2038-01-18T00:00:00Z","data":{"message":"\nERROR: not logged in, run `azd auth login` to login\n"}}"#;
246 let err = run_test(1, "", stderr, None).await.expect_err("error");
247 assert!(matches!(err.kind(), ErrorKind::Credential));
248 assert_eq!(
249 "AzureDeveloperCliCredential authentication failed.\nERROR: not logged in, run `azd auth login` to login\nTo troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#azd",
250 err.to_string()
251 );
252 }
253
254 #[tokio::test]
255 async fn json_error_parsing() {
256 let stderr = r#"{"type":"consoleMessage","timestamp":"2038-01-18T00:00:00Z","data":{"message":"ERROR: fetching token: some error occurred"}}"#;
257 let err = run_test(1, "", stderr, None).await.expect_err("error");
258 assert!(matches!(err.kind(), ErrorKind::Credential));
259 assert_eq!(
260 "AzureDeveloperCliCredential authentication failed.\nERROR: fetching token: some error occurred\nTo troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#azd",
261 err.to_string()
262 );
263 }
264
265 #[tokio::test]
266 async fn invalid_json_fallback() {
267 let stderr = "not valid json at all";
268 let err = run_test(1, "", stderr, None).await.expect_err("error");
269 assert!(matches!(err.kind(), ErrorKind::Credential));
270 assert_eq!(
271 "AzureDeveloperCliCredential authentication failed. not valid json at all\nTo troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#azd",
272 err.to_string()
273 );
274 }
275
276 #[tokio::test]
277 async fn empty_message_fallback() {
278 let stderr =
279 r#"{"type":"consoleMessage","timestamp":"2038-01-18T00:00:00Z","data":{"message":""}}"#;
280 let err = run_test(1, "", stderr, None).await.expect_err("error");
281 assert!(matches!(err.kind(), ErrorKind::Credential));
282 assert_eq!(
283 "AzureDeveloperCliCredential authentication failed. {\"type\":\"consoleMessage\",\"timestamp\":\"2038-01-18T00:00:00Z\",\"data\":{\"message\":\"\"}}\nTo troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#azd",
284 err.to_string()
285 );
286 }
287
288 #[tokio::test]
289 async fn message_with_whitespace() {
290 let stderr = r#"{"type":"consoleMessage","timestamp":"2038-01-18T00:00:00Z","data":{"message":" \n ERROR: some error \n "}}"#;
291 let err = run_test(1, "", stderr, None).await.expect_err("error");
292 assert!(matches!(err.kind(), ErrorKind::Credential));
293 assert_eq!(
294 "AzureDeveloperCliCredential authentication failed.\nERROR: some error\nTo troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#azd",
295 err.to_string()
296 );
297 }
298
299 #[tokio::test]
300 async fn program_not_found() {
301 let executor = MockExecutor::with_error(std::io::Error::from_raw_os_error(127));
302 let options = AzureDeveloperCliCredentialOptions {
303 executor: Some(executor),
304 ..Default::default()
305 };
306 let cred = AzureDeveloperCliCredential::new(Some(options)).expect("valid credential");
307 let err = cred
308 .get_token(LIVE_TEST_SCOPES, None)
309 .await
310 .expect_err("expected error");
311 assert!(matches!(err.kind(), ErrorKind::Credential));
312 }
313
314 #[tokio::test]
315 async fn tenant_id() {
316 let stdout = format!(r#"{{"token":"{FAKE_TOKEN}","expiresOn":"2038-01-18T00:00:00Z"}}"#);
317 let token = run_test(0, &stdout, "", Some(FAKE_TENANT_ID.to_string()))
318 .await
319 .expect("token");
320 assert_eq!(FAKE_TOKEN, token.token.secret());
321 assert_eq!(UtcOffset::UTC, token.expires_on.offset());
322 }
323}