|
| 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