From 0aef35f823a7a427ad46c7b0c5d8bf989b9c4c4d Mon Sep 17 00:00:00 2001 From: Charles Lowell <10964656+chlowell@users.noreply.github.com> Date: Tue, 25 Mar 2025 18:04:34 +0000 Subject: [PATCH 1/4] Add AzureDeveloperCliCredential --- sdk/identity/azure_identity/CHANGELOG.md | 1 + .../src/azure_developer_cli_credential.rs | 249 ++++++++++++++++++ sdk/identity/azure_identity/src/lib.rs | 77 +++++- 3 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 sdk/identity/azure_identity/src/azure_developer_cli_credential.rs diff --git a/sdk/identity/azure_identity/CHANGELOG.md b/sdk/identity/azure_identity/CHANGELOG.md index 766ff4821b..8556acdb8a 100644 --- a/sdk/identity/azure_identity/CHANGELOG.md +++ b/sdk/identity/azure_identity/CHANGELOG.md @@ -3,6 +3,7 @@ ## 0.24.0 (Unreleased) ### Features Added +- 'AzureDeveloperCliCredential' authenticates the identity logged in to the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview). ### Breaking Changes diff --git a/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs b/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs new file mode 100644 index 0000000000..758204bcd9 --- /dev/null +++ b/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs @@ -0,0 +1,249 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +use crate::{env::Env, validate_scope, validate_tenant_id}; +use azure_core::{ + credentials::{AccessToken, Secret, TokenCredential}, + error::{Error, ErrorKind}, + json::from_json, + process::{new_executor, Executor}, +}; +use serde::de::{self, Deserializer}; +use serde::Deserialize; +use std::{ffi::OsStr, fmt::Debug, str, sync::Arc}; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; + +const AZURE_DEVELOPER_CLI_CREDENTIAL: &str = "AzureDeveloperCliCredential"; + +#[derive(Clone, Debug, Deserialize)] +struct AzdTokenResponse { + #[serde(rename = "token")] + pub access_token: Secret, + #[serde(rename = "expiresOn", deserialize_with = "parse_expires_on")] + pub expires_on: OffsetDateTime, +} + +fn parse_expires_on<'de, D>(deserializer: D) -> std::result::Result +where + D: Deserializer<'de>, +{ + let s: &str = Deserialize::deserialize(deserializer)?; + OffsetDateTime::parse(s, &Rfc3339).map_err(de::Error::custom) +} + +/// Authenticates the identity logged in to the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview). +#[derive(Debug)] +pub struct AzureDeveloperCliCredential { + env: Env, + executor: Arc, + tenant_id: Option, +} + +/// Options for constructing an [`AzureDeveloperCliCredential`]. +#[derive(Clone, Debug, Default)] +pub struct AzureDeveloperCliCredentialOptions { + /// An implementation of [`Executor`] to run commands asynchronously. + /// + /// If `None`, one is created using [`new_executor`]; alternatively, + /// you can supply your own implementation using a different asynchronous runtime. + pub executor: Option>, + + /// Identifies the tenant the credential should authenticate in. + /// + /// Defaults to the azd environment, which is the tenant of the selected Azure subscription. + pub tenant_id: Option, + + env: Option, +} + +impl AzureDeveloperCliCredential { + /// Create a new [`AzureDeveloperCliCredential`]. + pub fn new( + options: Option, + ) -> azure_core::Result> { + let options = options.unwrap_or_default(); + if let Some(ref tenant_id) = options.tenant_id { + validate_tenant_id(tenant_id)?; + } + let env = options.env.unwrap_or_default(); + let executor = options.executor.unwrap_or(new_executor()); + Ok(Arc::new(Self { + env, + executor, + tenant_id: options.tenant_id, + })) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +impl TokenCredential for AzureDeveloperCliCredential { + async fn get_token(&self, scopes: &[&str]) -> azure_core::Result { + if scopes.is_empty() { + return Err(Error::new( + ErrorKind::Credential, + "at least one scope required", + )); + } + let mut command = "azd auth token -o json".to_string(); + for scope in scopes { + validate_scope(scope)?; + command.push_str(" --scope "); + command.push_str(scope); + } + if let Some(ref tenant_id) = self.tenant_id { + command.push_str(" --tenant-id "); + command.push_str(tenant_id); + } + let (workdir, program, c_switch) = if cfg!(target_os = "windows") { + let system_root = self.env.var("SYSTEMROOT").map_err(|_| { + Error::message( + ErrorKind::Credential, + "SYSTEMROOT environment variable not set", + ) + })?; + (system_root, "cmd", "/C") + } else { + ("/bin".to_string(), "/bin/sh", "-c") + }; + let command_string = format!("cd {workdir} && {command}"); + let args = vec![OsStr::new(c_switch), OsStr::new(command_string.as_str())]; + + let status = self.executor.run(OsStr::new(program), &args).await; + + match status { + Ok(azd_output) if azd_output.status.success() => { + let output = str::from_utf8(&azd_output.stdout)?; + let response: AzdTokenResponse = from_json(output)?; + Ok(AccessToken::new(response.access_token, response.expires_on)) + } + Ok(azd_output) => { + let stderr = String::from_utf8_lossy(&azd_output.stderr); + let message = if stderr.contains("azd auth login") { + "please run 'azd auth login' from a command prompt before using this credential" + } else if azd_output.status.code() == Some(127) + || stderr.contains("'azd' is not recognized") + { + "Azure Developer CLI not found on path" + } else { + &stderr + }; + Err(Error::with_message(ErrorKind::Credential, || { + format!("{AZURE_DEVELOPER_CLI_CREDENTIAL} authentication failed: {message}") + })) + } + Err(e) => { + let message = format!( + "{AZURE_DEVELOPER_CLI_CREDENTIAL} authentication failed due to {} error: {e}", + e.kind() + ); + Err(Error::with_message(ErrorKind::Credential, || message)) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tests::{MockExecutor, FAKE_TENANT_ID, FAKE_TOKEN, LIVE_TEST_SCOPES}; + use time::UtcOffset; + + async fn run_test( + exit_code: i32, + stdout: &str, + stderr: &str, + tenant_id: Option, + ) -> azure_core::Result { + let tenant_id_for_on_run = tenant_id.clone(); + let options = AzureDeveloperCliCredentialOptions { + executor: Some(MockExecutor::with_output( + exit_code, + stdout, + stderr, + Some(Arc::new(move |program: &OsStr, args: &[&OsStr]| { + let args: Vec = args + .iter() + .map(|arg| arg.to_string_lossy().to_string()) + .collect(); + if cfg!(target_os = "windows") { + assert_eq!(program.to_string_lossy(), "cmd"); + assert_eq!(args[0], "/C"); + } else { + assert_eq!(program, "/bin/sh"); + assert_eq!(args[0], "-c"); + } + for scope in LIVE_TEST_SCOPES { + assert!(args[1].contains(&format!(" --scope {scope}"))); + } + if let Some(ref tenant_id) = tenant_id_for_on_run { + assert!(args[1].ends_with(&format!(" --tenant-id {tenant_id}"))); + } else { + assert!(!args[1].contains("--tenant-id")); + } + })), + )), + tenant_id, + ..Default::default() + }; + let cred = AzureDeveloperCliCredential::new(Some(options))?; + return cred.get_token(LIVE_TEST_SCOPES).await; + } + + #[tokio::test] + async fn error_includes_stderr() { + let stderr = "something went wrong"; + let err = run_test(1, "stdout", stderr, None) + .await + .expect_err("expected error"); + assert!(matches!(err.kind(), ErrorKind::Credential)); + assert!(err.to_string().contains(stderr)); + } + + #[tokio::test] + async fn get_token_success() { + let expires_on = "2038-01-18T00:00:00Z"; + let stdout = format!(r#"{{"token":"{FAKE_TOKEN}","expiresOn":"{expires_on}"}}"#); + let token = run_test(0, &stdout, "", None).await.expect("token"); + assert_eq!(FAKE_TOKEN, token.token.secret()); + assert_eq!( + OffsetDateTime::parse(expires_on, &Rfc3339).unwrap(), + token.expires_on + ); + assert_eq!(UtcOffset::UTC, token.expires_on.offset()); + } + + #[tokio::test] + async fn not_logged_in() { + let stderr = r#"{{"type":"consoleMessage","timestamp":"2038-01-18T00:00:00Z","data":{"message":"\nERROR: not logged in, run `azd auth login` to login\n"}}"#; + let err = run_test(1, "", stderr, None).await.expect_err("error"); + assert!(matches!(err.kind(), ErrorKind::Credential)); + assert!(err.to_string().contains("azd auth login")); + } + + #[tokio::test] + async fn program_not_found() { + let executor = MockExecutor::with_error(std::io::Error::from_raw_os_error(127)); + let options = AzureDeveloperCliCredentialOptions { + executor: Some(executor), + ..Default::default() + }; + let cred = AzureDeveloperCliCredential::new(Some(options)).expect("valid credential"); + let err = cred + .get_token(LIVE_TEST_SCOPES) + .await + .expect_err("expected error"); + assert!(matches!(err.kind(), ErrorKind::Credential)); + } + + #[tokio::test] + async fn tenant_id() { + let stdout = format!(r#"{{"token":"{FAKE_TOKEN}","expiresOn":"2038-01-18T00:00:00Z"}}"#); + let token = run_test(0, &stdout, "", Some(FAKE_TENANT_ID.to_string())) + .await + .expect("token"); + assert_eq!(FAKE_TOKEN, token.token.secret()); + assert_eq!(UtcOffset::UTC, token.expires_on.offset()); + } +} diff --git a/sdk/identity/azure_identity/src/lib.rs b/sdk/identity/azure_identity/src/lib.rs index 39c0a0c0db..1b1f410d1a 100644 --- a/sdk/identity/azure_identity/src/lib.rs +++ b/sdk/identity/azure_identity/src/lib.rs @@ -5,6 +5,7 @@ #![cfg_attr(docsrs, feature(doc_auto_cfg))] mod authorization_code_flow; +mod azure_developer_cli_credential; mod azure_pipelines_credential; mod chained_token_credential; mod client_secret_credential; @@ -20,6 +21,7 @@ use azure_core::{ http::Response, Error, Result, }; +pub use azure_developer_cli_credential::*; pub use azure_pipelines_credential::*; pub use client_secret_credential::*; pub use credentials::*; @@ -150,12 +152,18 @@ fn test_validate_tenant_id() { #[cfg(test)] mod tests { + use async_trait::async_trait; use azure_core::{ error::ErrorKind, http::{Request, Response}, + process::Executor, Error, Result, }; - use std::sync::{Arc, Mutex}; + use std::{ + ffi::OsStr, + process::Output, + sync::{Arc, Mutex}, + }; pub const FAKE_CLIENT_ID: &str = "fake-client"; pub const FAKE_TENANT_ID: &str = "fake-tenant"; @@ -163,6 +171,73 @@ mod tests { pub const LIVE_TEST_RESOURCE: &str = "https://management.azure.com"; pub const LIVE_TEST_SCOPES: &[&str] = &["https://management.azure.com/.default"]; + pub type RunCallback = Arc; + + #[derive(Default)] + pub struct MockExecutor { + error: Option, + on_run: Option, + output: Mutex>, + } + + impl std::fmt::Debug for MockExecutor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MockExecutor").finish() + } + } + + impl MockExecutor { + pub fn with_error(err: std::io::Error) -> Arc { + Arc::new(Self { + error: Some(err), + ..Default::default() + }) + } + + pub fn with_output( + exit_code: i32, + stdout: &str, + stderr: &str, + on_run: Option, + ) -> Arc { + let output = Output { + status: { + #[cfg(windows)] + { + std::os::windows::process::ExitStatusExt::from_raw( + exit_code.try_into().unwrap(), + ) + } + #[cfg(unix)] + { + std::os::unix::process::ExitStatusExt::from_raw(exit_code) + } + }, + stdout: stdout.as_bytes().to_vec(), + stderr: stderr.as_bytes().to_vec(), + }; + Arc::new(Self { + on_run, + output: Mutex::new(Some(output)), + ..Default::default() + }) + } + } + + #[async_trait] + impl Executor for MockExecutor { + async fn run(&self, program: &OsStr, args: &[&OsStr]) -> std::io::Result { + if let Some(on_run) = &self.on_run { + on_run(program, args); + } + if let Some(err) = &self.error { + return Err(std::io::Error::new(err.kind(), err.to_string())); + } + let mut output = self.output.lock().unwrap(); + Ok(output.take().expect("MockExecutor output already consumed")) + } + } + pub type RequestCallback = Arc Result<()> + Send + Sync>; pub struct MockSts { From b9365814f4408e2cc3beb8953c174d8ecb1dabbe Mon Sep 17 00:00:00 2001 From: Charles Lowell <10964656+chlowell@users.noreply.github.com> Date: Thu, 24 Apr 2025 16:59:23 +0000 Subject: [PATCH 2/4] =?UTF-8?q?cspell=20=F0=9F=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../azure_identity/src/azure_developer_cli_credential.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs b/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs index 758204bcd9..c4d4af0334 100644 --- a/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs +++ b/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +// cspell:ignore SYSTEMROOT + use crate::{env::Env, validate_scope, validate_tenant_id}; use azure_core::{ credentials::{AccessToken, Secret, TokenCredential}, From 8bbf7a9ed496e3859916f309dd69c70ef5ed1731 Mon Sep 17 00:00:00 2001 From: Charles Lowell <10964656+chlowell@users.noreply.github.com> Date: Thu, 24 Apr 2025 17:01:00 +0000 Subject: [PATCH 3/4] test the whole command line --- .../azure_identity/src/azure_developer_cli_credential.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs b/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs index c4d4af0334..23ed40fa64 100644 --- a/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs +++ b/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs @@ -159,7 +159,9 @@ mod tests { tenant_id: Option, ) -> azure_core::Result { let tenant_id_for_on_run = tenant_id.clone(); + let system_root = "/dev/null"; let options = AzureDeveloperCliCredentialOptions { + env: Some(Env::from(&[("SYSTEMROOT", system_root)][..])), executor: Some(MockExecutor::with_output( exit_code, stdout, @@ -172,9 +174,12 @@ mod tests { if cfg!(target_os = "windows") { assert_eq!(program.to_string_lossy(), "cmd"); assert_eq!(args[0], "/C"); + assert!(args[1] + .starts_with(&format!("cd {system_root} && azd auth token -o json"))); } else { assert_eq!(program, "/bin/sh"); assert_eq!(args[0], "-c"); + assert!(args[1].starts_with("cd /bin && azd auth token -o json")); } for scope in LIVE_TEST_SCOPES { assert!(args[1].contains(&format!(" --scope {scope}"))); @@ -187,7 +192,6 @@ mod tests { })), )), tenant_id, - ..Default::default() }; let cred = AzureDeveloperCliCredential::new(Some(options))?; return cred.get_token(LIVE_TEST_SCOPES).await; From 9e9d10f7ba6c56fd3f0403b770ae171d0cd1791c Mon Sep 17 00:00:00 2001 From: Charles Lowell <10964656+chlowell@users.noreply.github.com> Date: Thu, 24 Apr 2025 17:20:47 +0000 Subject: [PATCH 4/4] more cspelling --- .../azure_identity/src/azure_developer_cli_credential.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs b/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs index 23ed40fa64..3df432510c 100644 --- a/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs +++ b/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -// cspell:ignore SYSTEMROOT +// cspell:ignore SYSTEMROOT workdir use crate::{env::Env, validate_scope, validate_tenant_id}; use azure_core::{