Skip to content

Add AzureDeveloperCliCredential #2519

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk/identity/azure_identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
255 changes: 255 additions & 0 deletions sdk/identity/azure_identity/src/azure_developer_cli_credential.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

// cspell:ignore SYSTEMROOT workdir

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<OffsetDateTime, D::Error>
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<dyn Executor>,
tenant_id: Option<String>,
}

/// 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<Arc<dyn Executor>>,

/// 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<String>,

env: Option<Env>,
}

impl AzureDeveloperCliCredential {
/// Create a new [`AzureDeveloperCliCredential`].
pub fn new(
options: Option<AzureDeveloperCliCredentialOptions>,
) -> azure_core::Result<Arc<Self>> {
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<AccessToken> {
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<String>,
) -> azure_core::Result<AccessToken> {
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,
stderr,
Some(Arc::new(move |program: &OsStr, args: &[&OsStr]| {
let args: Vec<String> = 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");
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}")));
}
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,
};
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());
}
}
77 changes: 76 additions & 1 deletion sdk/identity/azure_identity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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::*;
Expand Down Expand Up @@ -150,19 +152,92 @@ 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";
pub const FAKE_TOKEN: &str = "***";
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<dyn Fn(&OsStr, &[&OsStr]) + Send + Sync>;

#[derive(Default)]
pub struct MockExecutor {
error: Option<std::io::Error>,
on_run: Option<RunCallback>,
output: Mutex<Option<Output>>,
}

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<Self> {
Arc::new(Self {
error: Some(err),
..Default::default()
})
}

pub fn with_output(
exit_code: i32,
stdout: &str,
stderr: &str,
on_run: Option<RunCallback>,
) -> Arc<Self> {
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<Output> {
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<dyn Fn(&Request) -> Result<()> + Send + Sync>;

pub struct MockSts {
Expand Down