Skip to main content

azure_identity/
azure_developer_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_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/// The JSON structure that `azd` writes to stderr on errors.
38#[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        // Try to parse azd's JSON error output and extract the message
56        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        // Fall back to raw stderr if parsing fails or message is empty
63        None
64    }
65
66    fn tool_name() -> &'static str {
67        "azd"
68    }
69}
70
71/// Authenticates the identity logged in to the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview).
72pub 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/// Options for constructing an [`AzureDeveloperCliCredential`].
87#[derive(Clone, Default)]
88pub struct AzureDeveloperCliCredentialOptions {
89    /// An implementation of [`Executor`] to run commands asynchronously.
90    ///
91    /// If `None`, one is created using [`new_executor`]; alternatively,
92    /// you can supply your own implementation using a different asynchronous runtime.
93    pub executor: Option<Arc<dyn Executor>>,
94
95    /// Identifies the tenant the credential should authenticate in.
96    ///
97    /// Defaults to the azd environment, which is the tenant of the selected Azure subscription.
98    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    /// Create a new [`AzureDeveloperCliCredential`].
114    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}