| 
 | 1 | +// Copyright (c) Microsoft Corporation. All rights reserved.  | 
 | 2 | +// Licensed under the MIT License.  | 
 | 3 | + | 
 | 4 | +// cspell:ignore SYSTEMROOT workdir  | 
 | 5 | + | 
 | 6 | +use crate::{env::Env, validate_scope, validate_tenant_id};  | 
 | 7 | +use azure_core::{  | 
 | 8 | +    credentials::{AccessToken, Secret, TokenCredential},  | 
 | 9 | +    error::{Error, ErrorKind},  | 
 | 10 | +    json::from_json,  | 
 | 11 | +    process::{new_executor, Executor},  | 
 | 12 | +};  | 
 | 13 | +use serde::de::{self, Deserializer};  | 
 | 14 | +use serde::Deserialize;  | 
 | 15 | +use std::{ffi::OsStr, fmt::Debug, str, sync::Arc};  | 
 | 16 | +use time::format_description::well_known::Rfc3339;  | 
 | 17 | +use time::OffsetDateTime;  | 
 | 18 | + | 
 | 19 | +const AZURE_DEVELOPER_CLI_CREDENTIAL: &str = "AzureDeveloperCliCredential";  | 
 | 20 | + | 
 | 21 | +#[derive(Clone, Debug, Deserialize)]  | 
 | 22 | +struct 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 | + | 
 | 29 | +fn parse_expires_on<'de, D>(deserializer: D) -> std::result::Result<OffsetDateTime, D::Error>  | 
 | 30 | +where  | 
 | 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 | +/// Authenticates the identity logged in to the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview).  | 
 | 38 | +#[derive(Debug)]  | 
 | 39 | +pub struct AzureDeveloperCliCredential {  | 
 | 40 | +    env: Env,  | 
 | 41 | +    executor: Arc<dyn Executor>,  | 
 | 42 | +    tenant_id: Option<String>,  | 
 | 43 | +}  | 
 | 44 | + | 
 | 45 | +/// Options for constructing an [`AzureDeveloperCliCredential`].  | 
 | 46 | +#[derive(Clone, Debug, Default)]  | 
 | 47 | +pub struct AzureDeveloperCliCredentialOptions {  | 
 | 48 | +    /// An implementation of [`Executor`] to run commands asynchronously.  | 
 | 49 | +    ///  | 
 | 50 | +    /// If `None`, one is created using [`new_executor`]; alternatively,  | 
 | 51 | +    /// you can supply your own implementation using a different asynchronous runtime.  | 
 | 52 | +    pub executor: Option<Arc<dyn Executor>>,  | 
 | 53 | + | 
 | 54 | +    /// Identifies the tenant the credential should authenticate in.  | 
 | 55 | +    ///  | 
 | 56 | +    /// Defaults to the azd environment, which is the tenant of the selected Azure subscription.  | 
 | 57 | +    pub tenant_id: Option<String>,  | 
 | 58 | + | 
 | 59 | +    env: Option<Env>,  | 
 | 60 | +}  | 
 | 61 | + | 
 | 62 | +impl AzureDeveloperCliCredential {  | 
 | 63 | +    /// Create a new [`AzureDeveloperCliCredential`].  | 
 | 64 | +    pub fn new(  | 
 | 65 | +        options: Option<AzureDeveloperCliCredentialOptions>,  | 
 | 66 | +    ) -> azure_core::Result<Arc<Self>> {  | 
 | 67 | +        let options = options.unwrap_or_default();  | 
 | 68 | +        if let Some(ref tenant_id) = options.tenant_id {  | 
 | 69 | +            validate_tenant_id(tenant_id)?;  | 
 | 70 | +        }  | 
 | 71 | +        let env = options.env.unwrap_or_default();  | 
 | 72 | +        let executor = options.executor.unwrap_or(new_executor());  | 
 | 73 | +        Ok(Arc::new(Self {  | 
 | 74 | +            env,  | 
 | 75 | +            executor,  | 
 | 76 | +            tenant_id: options.tenant_id,  | 
 | 77 | +        }))  | 
 | 78 | +    }  | 
 | 79 | +}  | 
 | 80 | + | 
 | 81 | +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]  | 
 | 82 | +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]  | 
 | 83 | +impl TokenCredential for AzureDeveloperCliCredential {  | 
 | 84 | +    async fn get_token(&self, scopes: &[&str]) -> azure_core::Result<AccessToken> {  | 
 | 85 | +        if scopes.is_empty() {  | 
 | 86 | +            return Err(Error::new(  | 
 | 87 | +                ErrorKind::Credential,  | 
 | 88 | +                "at least one scope required",  | 
 | 89 | +            ));  | 
 | 90 | +        }  | 
 | 91 | +        let mut command = "azd auth token -o json".to_string();  | 
 | 92 | +        for scope in scopes {  | 
 | 93 | +            validate_scope(scope)?;  | 
 | 94 | +            command.push_str(" --scope ");  | 
 | 95 | +            command.push_str(scope);  | 
 | 96 | +        }  | 
 | 97 | +        if let Some(ref tenant_id) = self.tenant_id {  | 
 | 98 | +            command.push_str(" --tenant-id ");  | 
 | 99 | +            command.push_str(tenant_id);  | 
 | 100 | +        }  | 
 | 101 | +        let (workdir, program, c_switch) = if cfg!(target_os = "windows") {  | 
 | 102 | +            let system_root = self.env.var("SYSTEMROOT").map_err(|_| {  | 
 | 103 | +                Error::message(  | 
 | 104 | +                    ErrorKind::Credential,  | 
 | 105 | +                    "SYSTEMROOT environment variable not set",  | 
 | 106 | +                )  | 
 | 107 | +            })?;  | 
 | 108 | +            (system_root, "cmd", "/C")  | 
 | 109 | +        } else {  | 
 | 110 | +            ("/bin".to_string(), "/bin/sh", "-c")  | 
 | 111 | +        };  | 
 | 112 | +        let command_string = format!("cd {workdir} && {command}");  | 
 | 113 | +        let args = vec![OsStr::new(c_switch), OsStr::new(command_string.as_str())];  | 
 | 114 | + | 
 | 115 | +        let status = self.executor.run(OsStr::new(program), &args).await;  | 
 | 116 | + | 
 | 117 | +        match status {  | 
 | 118 | +            Ok(azd_output) if azd_output.status.success() => {  | 
 | 119 | +                let output = str::from_utf8(&azd_output.stdout)?;  | 
 | 120 | +                let response: AzdTokenResponse = from_json(output)?;  | 
 | 121 | +                Ok(AccessToken::new(response.access_token, response.expires_on))  | 
 | 122 | +            }  | 
 | 123 | +            Ok(azd_output) => {  | 
 | 124 | +                let stderr = String::from_utf8_lossy(&azd_output.stderr);  | 
 | 125 | +                let message = if stderr.contains("azd auth login") {  | 
 | 126 | +                    "please run 'azd auth login' from a command prompt before using this credential"  | 
 | 127 | +                } else if azd_output.status.code() == Some(127)  | 
 | 128 | +                    || stderr.contains("'azd' is not recognized")  | 
 | 129 | +                {  | 
 | 130 | +                    "Azure Developer CLI not found on path"  | 
 | 131 | +                } else {  | 
 | 132 | +                    &stderr  | 
 | 133 | +                };  | 
 | 134 | +                Err(Error::with_message(ErrorKind::Credential, || {  | 
 | 135 | +                    format!("{AZURE_DEVELOPER_CLI_CREDENTIAL} authentication failed: {message}")  | 
 | 136 | +                }))  | 
 | 137 | +            }  | 
 | 138 | +            Err(e) => {  | 
 | 139 | +                let message = format!(  | 
 | 140 | +                    "{AZURE_DEVELOPER_CLI_CREDENTIAL} authentication failed due to {} error: {e}",  | 
 | 141 | +                    e.kind()  | 
 | 142 | +                );  | 
 | 143 | +                Err(Error::with_message(ErrorKind::Credential, || message))  | 
 | 144 | +            }  | 
 | 145 | +        }  | 
 | 146 | +    }  | 
 | 147 | +}  | 
 | 148 | + | 
 | 149 | +#[cfg(test)]  | 
 | 150 | +mod tests {  | 
 | 151 | +    use super::*;  | 
 | 152 | +    use crate::tests::{MockExecutor, FAKE_TENANT_ID, FAKE_TOKEN, LIVE_TEST_SCOPES};  | 
 | 153 | +    use time::UtcOffset;  | 
 | 154 | + | 
 | 155 | +    async fn run_test(  | 
 | 156 | +        exit_code: i32,  | 
 | 157 | +        stdout: &str,  | 
 | 158 | +        stderr: &str,  | 
 | 159 | +        tenant_id: Option<String>,  | 
 | 160 | +    ) -> azure_core::Result<AccessToken> {  | 
 | 161 | +        let tenant_id_for_on_run = tenant_id.clone();  | 
 | 162 | +        let system_root = "/dev/null";  | 
 | 163 | +        let options = AzureDeveloperCliCredentialOptions {  | 
 | 164 | +            env: Some(Env::from(&[("SYSTEMROOT", system_root)][..])),  | 
 | 165 | +            executor: Some(MockExecutor::with_output(  | 
 | 166 | +                exit_code,  | 
 | 167 | +                stdout,  | 
 | 168 | +                stderr,  | 
 | 169 | +                Some(Arc::new(move |program: &OsStr, args: &[&OsStr]| {  | 
 | 170 | +                    let args: Vec<String> = args  | 
 | 171 | +                        .iter()  | 
 | 172 | +                        .map(|arg| arg.to_string_lossy().to_string())  | 
 | 173 | +                        .collect();  | 
 | 174 | +                    if cfg!(target_os = "windows") {  | 
 | 175 | +                        assert_eq!(program.to_string_lossy(), "cmd");  | 
 | 176 | +                        assert_eq!(args[0], "/C");  | 
 | 177 | +                        assert!(args[1]  | 
 | 178 | +                            .starts_with(&format!("cd {system_root} && azd auth token -o json")));  | 
 | 179 | +                    } else {  | 
 | 180 | +                        assert_eq!(program, "/bin/sh");  | 
 | 181 | +                        assert_eq!(args[0], "-c");  | 
 | 182 | +                        assert!(args[1].starts_with("cd /bin && azd auth token -o json"));  | 
 | 183 | +                    }  | 
 | 184 | +                    for scope in LIVE_TEST_SCOPES {  | 
 | 185 | +                        assert!(args[1].contains(&format!(" --scope {scope}")));  | 
 | 186 | +                    }  | 
 | 187 | +                    if let Some(ref tenant_id) = tenant_id_for_on_run {  | 
 | 188 | +                        assert!(args[1].ends_with(&format!(" --tenant-id {tenant_id}")));  | 
 | 189 | +                    } else {  | 
 | 190 | +                        assert!(!args[1].contains("--tenant-id"));  | 
 | 191 | +                    }  | 
 | 192 | +                })),  | 
 | 193 | +            )),  | 
 | 194 | +            tenant_id,  | 
 | 195 | +        };  | 
 | 196 | +        let cred = AzureDeveloperCliCredential::new(Some(options))?;  | 
 | 197 | +        return cred.get_token(LIVE_TEST_SCOPES).await;  | 
 | 198 | +    }  | 
 | 199 | + | 
 | 200 | +    #[tokio::test]  | 
 | 201 | +    async fn error_includes_stderr() {  | 
 | 202 | +        let stderr = "something went wrong";  | 
 | 203 | +        let err = run_test(1, "stdout", stderr, None)  | 
 | 204 | +            .await  | 
 | 205 | +            .expect_err("expected error");  | 
 | 206 | +        assert!(matches!(err.kind(), ErrorKind::Credential));  | 
 | 207 | +        assert!(err.to_string().contains(stderr));  | 
 | 208 | +    }  | 
 | 209 | + | 
 | 210 | +    #[tokio::test]  | 
 | 211 | +    async fn get_token_success() {  | 
 | 212 | +        let expires_on = "2038-01-18T00:00:00Z";  | 
 | 213 | +        let stdout = format!(r#"{{"token":"{FAKE_TOKEN}","expiresOn":"{expires_on}"}}"#);  | 
 | 214 | +        let token = run_test(0, &stdout, "", None).await.expect("token");  | 
 | 215 | +        assert_eq!(FAKE_TOKEN, token.token.secret());  | 
 | 216 | +        assert_eq!(  | 
 | 217 | +            OffsetDateTime::parse(expires_on, &Rfc3339).unwrap(),  | 
 | 218 | +            token.expires_on  | 
 | 219 | +        );  | 
 | 220 | +        assert_eq!(UtcOffset::UTC, token.expires_on.offset());  | 
 | 221 | +    }  | 
 | 222 | + | 
 | 223 | +    #[tokio::test]  | 
 | 224 | +    async fn not_logged_in() {  | 
 | 225 | +        let stderr = r#"{{"type":"consoleMessage","timestamp":"2038-01-18T00:00:00Z","data":{"message":"\nERROR: not logged in, run `azd auth login` to login\n"}}"#;  | 
 | 226 | +        let err = run_test(1, "", stderr, None).await.expect_err("error");  | 
 | 227 | +        assert!(matches!(err.kind(), ErrorKind::Credential));  | 
 | 228 | +        assert!(err.to_string().contains("azd auth login"));  | 
 | 229 | +    }  | 
 | 230 | + | 
 | 231 | +    #[tokio::test]  | 
 | 232 | +    async fn program_not_found() {  | 
 | 233 | +        let executor = MockExecutor::with_error(std::io::Error::from_raw_os_error(127));  | 
 | 234 | +        let options = AzureDeveloperCliCredentialOptions {  | 
 | 235 | +            executor: Some(executor),  | 
 | 236 | +            ..Default::default()  | 
 | 237 | +        };  | 
 | 238 | +        let cred = AzureDeveloperCliCredential::new(Some(options)).expect("valid credential");  | 
 | 239 | +        let err = cred  | 
 | 240 | +            .get_token(LIVE_TEST_SCOPES)  | 
 | 241 | +            .await  | 
 | 242 | +            .expect_err("expected error");  | 
 | 243 | +        assert!(matches!(err.kind(), ErrorKind::Credential));  | 
 | 244 | +    }  | 
 | 245 | + | 
 | 246 | +    #[tokio::test]  | 
 | 247 | +    async fn tenant_id() {  | 
 | 248 | +        let stdout = format!(r#"{{"token":"{FAKE_TOKEN}","expiresOn":"2038-01-18T00:00:00Z"}}"#);  | 
 | 249 | +        let token = run_test(0, &stdout, "", Some(FAKE_TENANT_ID.to_string()))  | 
 | 250 | +            .await  | 
 | 251 | +            .expect("token");  | 
 | 252 | +        assert_eq!(FAKE_TOKEN, token.token.secret());  | 
 | 253 | +        assert_eq!(UtcOffset::UTC, token.expires_on.offset());  | 
 | 254 | +    }  | 
 | 255 | +}  | 
0 commit comments