Skip to content

Commit 914f79c

Browse files
authored
Add AzureDeveloperCliCredential (#2519)
1 parent 897a894 commit 914f79c

File tree

3 files changed

+332
-1
lines changed

3 files changed

+332
-1
lines changed

sdk/identity/azure_identity/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## 0.24.0 (Unreleased)
44

55
### Features Added
6+
- 'AzureDeveloperCliCredential' authenticates the identity logged in to the [Azure Developer CLI](https://learn.microsoft.com/azure/developer/azure-developer-cli/overview).
67

78
### Breaking Changes
89

Lines changed: 255 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,255 @@
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+
}

sdk/identity/azure_identity/src/lib.rs

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
#![cfg_attr(docsrs, feature(doc_auto_cfg))]
66

77
mod authorization_code_flow;
8+
mod azure_developer_cli_credential;
89
mod azure_pipelines_credential;
910
mod chained_token_credential;
1011
mod client_secret_credential;
@@ -19,6 +20,7 @@ use azure_core::{
1920
http::Response,
2021
Error, Result,
2122
};
23+
pub use azure_developer_cli_credential::*;
2224
pub use azure_pipelines_credential::*;
2325
pub use client_secret_credential::*;
2426
pub use credentials::*;
@@ -149,19 +151,92 @@ fn test_validate_tenant_id() {
149151

150152
#[cfg(test)]
151153
mod tests {
154+
use async_trait::async_trait;
152155
use azure_core::{
153156
error::ErrorKind,
154157
http::{Request, Response},
158+
process::Executor,
155159
Error, Result,
156160
};
157-
use std::sync::{Arc, Mutex};
161+
use std::{
162+
ffi::OsStr,
163+
process::Output,
164+
sync::{Arc, Mutex},
165+
};
158166

159167
pub const FAKE_CLIENT_ID: &str = "fake-client";
160168
pub const FAKE_TENANT_ID: &str = "fake-tenant";
161169
pub const FAKE_TOKEN: &str = "***";
162170
pub const LIVE_TEST_RESOURCE: &str = "https://management.azure.com";
163171
pub const LIVE_TEST_SCOPES: &[&str] = &["https://management.azure.com/.default"];
164172

173+
pub type RunCallback = Arc<dyn Fn(&OsStr, &[&OsStr]) + Send + Sync>;
174+
175+
#[derive(Default)]
176+
pub struct MockExecutor {
177+
error: Option<std::io::Error>,
178+
on_run: Option<RunCallback>,
179+
output: Mutex<Option<Output>>,
180+
}
181+
182+
impl std::fmt::Debug for MockExecutor {
183+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184+
f.debug_struct("MockExecutor").finish()
185+
}
186+
}
187+
188+
impl MockExecutor {
189+
pub fn with_error(err: std::io::Error) -> Arc<Self> {
190+
Arc::new(Self {
191+
error: Some(err),
192+
..Default::default()
193+
})
194+
}
195+
196+
pub fn with_output(
197+
exit_code: i32,
198+
stdout: &str,
199+
stderr: &str,
200+
on_run: Option<RunCallback>,
201+
) -> Arc<Self> {
202+
let output = Output {
203+
status: {
204+
#[cfg(windows)]
205+
{
206+
std::os::windows::process::ExitStatusExt::from_raw(
207+
exit_code.try_into().unwrap(),
208+
)
209+
}
210+
#[cfg(unix)]
211+
{
212+
std::os::unix::process::ExitStatusExt::from_raw(exit_code)
213+
}
214+
},
215+
stdout: stdout.as_bytes().to_vec(),
216+
stderr: stderr.as_bytes().to_vec(),
217+
};
218+
Arc::new(Self {
219+
on_run,
220+
output: Mutex::new(Some(output)),
221+
..Default::default()
222+
})
223+
}
224+
}
225+
226+
#[async_trait]
227+
impl Executor for MockExecutor {
228+
async fn run(&self, program: &OsStr, args: &[&OsStr]) -> std::io::Result<Output> {
229+
if let Some(on_run) = &self.on_run {
230+
on_run(program, args);
231+
}
232+
if let Some(err) = &self.error {
233+
return Err(std::io::Error::new(err.kind(), err.to_string()));
234+
}
235+
let mut output = self.output.lock().unwrap();
236+
Ok(output.take().expect("MockExecutor output already consumed"))
237+
}
238+
}
239+
165240
pub type RequestCallback = Arc<dyn Fn(&Request) -> Result<()> + Send + Sync>;
166241

167242
pub struct MockSts {

0 commit comments

Comments
 (0)