azure_identity/token_credentials/
azure_cli_credentials.rs1use crate::token_credentials::cache::TokenCache;
2use async_process::Command;
3use azure_core::{
4 auth::{AccessToken, Secret, TokenCredential},
5 error::{Error, ErrorKind, ResultExt},
6 from_json,
7};
8use serde::Deserialize;
9use std::str;
10use time::OffsetDateTime;
11use tracing::trace;
12
13#[cfg(feature = "old_azure_cli")]
14mod az_cli_date_format {
15 use azure_core::error::{ErrorKind, ResultExt};
16 use serde::{Deserialize, Deserializer};
17 use time::format_description::FormatItem;
18 use time::macros::format_description;
19 #[cfg(not(unix))]
20 use time::UtcOffset;
21 use time::{OffsetDateTime, PrimitiveDateTime};
22
23 const FORMAT: &[FormatItem] =
24 format_description!("[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:6]");
25
26 pub fn parse(s: &str) -> azure_core::Result<OffsetDateTime> {
27 let dt = PrimitiveDateTime::parse(s, FORMAT)
29 .with_context(ErrorKind::DataConversion, || {
30 format!("unable to parse expiresOn '{s}")
31 })?;
32 Ok(assume_local(&dt))
33 }
34
35 #[cfg(unix)]
36 pub(crate) fn assume_local(date: &PrimitiveDateTime) -> OffsetDateTime {
40 let as_utc = date.assume_utc();
41
42 let Some(tz) = std::env::var("TZ")
46 .ok()
47 .and_then(|x| tz::TimeZone::from_posix_tz(&x).ok())
48 .or_else(|| tz::TimeZone::local().ok())
49 else {
50 return as_utc;
51 };
52
53 let as_unix = as_utc.unix_timestamp();
54
55 let Ok(local_time_type) = tz.find_local_time_type(as_unix) else {
57 return as_utc;
58 };
59
60 let date = as_utc.date();
62 let time = as_utc.time();
63 let Ok(date) = tz::DateTime::new(
64 date.year(),
65 u8::from(date.month()),
66 date.day(),
67 time.hour(),
68 time.minute(),
69 time.second(),
70 time.nanosecond(),
71 *local_time_type,
72 ) else {
73 return as_utc;
74 };
75
76 let Ok(date) = OffsetDateTime::from_unix_timestamp(date.unix_time()) else {
79 return as_utc;
80 };
81
82 date
83 }
84
85 #[cfg(not(unix))]
87 pub(crate) fn assume_local(date: &PrimitiveDateTime) -> OffsetDateTime {
88 date.assume_offset(UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC))
89 }
90
91 pub fn deserialize<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
92 where
93 D: Deserializer<'de>,
94 {
95 let s = String::deserialize(deserializer)?;
96 parse(&s).map_err(serde::de::Error::custom)
97 }
98}
99
100#[derive(Debug, Clone, Deserialize)]
102struct CliTokenResponse {
103 #[serde(rename = "accessToken")]
104 pub access_token: Secret,
105 #[cfg(feature = "old_azure_cli")]
106 #[serde(rename = "expiresOn", with = "az_cli_date_format")]
107 pub local_expires_on: OffsetDateTime,
111 #[serde(rename = "expires_on")]
112 pub expires_on: Option<i64>,
115 pub subscription: String,
116 pub tenant: String,
117 #[allow(unused)]
118 #[serde(rename = "tokenType")]
119 pub token_type: String,
120}
121
122impl CliTokenResponse {
123 pub fn expires_on(&self) -> azure_core::Result<OffsetDateTime> {
124 match self.expires_on {
125 Some(timestamp) => Ok(OffsetDateTime::from_unix_timestamp(timestamp)
126 .with_context(ErrorKind::DataConversion, || {
127 format!("unable to parse expires_on '{timestamp}'")
128 })?),
129 None => {
130 #[cfg(feature = "old_azure_cli")]
131 {
132 Ok(self.local_expires_on)
133 }
134 #[cfg(not(feature = "old_azure_cli"))]
135 {
136 Err(Error::message(
137 ErrorKind::DataConversion,
138 "expires_on field not found. Please use Azure CLI 2.54.0 or newer.",
139 ))
140 }
141 }
142 }
143 }
144}
145
146#[derive(Debug)]
148pub struct AzureCliCredential {
149 cache: TokenCache,
150}
151
152impl Default for AzureCliCredential {
153 fn default() -> Self {
154 Self::new()
155 }
156}
157
158impl AzureCliCredential {
159 pub fn create() -> azure_core::Result<Self> {
160 Ok(AzureCliCredential::new())
162 }
163
164 pub fn new() -> Self {
166 Self {
167 cache: TokenCache::new(),
168 }
169 }
170
171 async fn get_access_token(scopes: Option<&[&str]>) -> azure_core::Result<CliTokenResponse> {
173 let program = if cfg!(target_os = "windows") {
176 "cmd"
177 } else {
178 "az"
179 };
180 let mut args = Vec::new();
181 if cfg!(target_os = "windows") {
182 args.push("/C");
183 args.push("az");
184 }
185 args.push("account");
186 args.push("get-access-token");
187 args.push("--output");
188 args.push("json");
189
190 let scopes = scopes.map(|x| x.join(" "));
191
192 if let Some(scopes) = &scopes {
193 args.push("--scope");
194 args.push(scopes);
195 }
196
197 trace!(
198 "fetching credential via Azure CLI: {program} {}",
199 args.join(" "),
200 );
201
202 match Command::new(program).args(args).output().await {
203 Ok(az_output) if az_output.status.success() => {
204 let output = str::from_utf8(&az_output.stdout)?;
205
206 let access_token = from_json(output)?;
207 Ok(access_token)
208 }
209 Ok(az_output) => {
210 let output = String::from_utf8_lossy(&az_output.stderr);
211 Err(Error::with_message(ErrorKind::Credential, || {
212 format!("'az account get-access-token' command failed: {output}")
213 }))
214 }
215 Err(e) => match e.kind() {
216 std::io::ErrorKind::NotFound => {
217 Err(Error::message(ErrorKind::Other, "Azure CLI not installed"))
218 }
219 error_kind => Err(Error::with_message(ErrorKind::Other, || {
220 format!("Unknown error of kind: {error_kind:?}")
221 })),
222 },
223 }
224 }
225
226 pub async fn get_subscription() -> azure_core::Result<String> {
228 let tr = Self::get_access_token(None).await?;
229 Ok(tr.subscription)
230 }
231
232 pub async fn get_tenant() -> azure_core::Result<String> {
234 let tr = Self::get_access_token(None).await?;
235 Ok(tr.tenant)
236 }
237
238 async fn get_token(&self, scopes: &[&str]) -> azure_core::Result<AccessToken> {
239 let tr = Self::get_access_token(Some(scopes)).await?;
240 let expires_on = tr.expires_on()?;
241 Ok(AccessToken::new(tr.access_token, expires_on))
242 }
243}
244
245#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
246#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
247impl TokenCredential for AzureCliCredential {
248 async fn get_token(&self, scopes: &[&str]) -> azure_core::Result<AccessToken> {
249 self.cache.get_token(scopes, self.get_token(scopes)).await
250 }
251
252 async fn clear_cache(&self) -> azure_core::Result<()> {
254 self.cache.clear().await
255 }
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261 #[cfg(feature = "old_azure_cli")]
262 use serial_test::serial;
263 #[cfg(feature = "old_azure_cli")]
264 use time::macros::datetime;
265
266 #[cfg(feature = "old_azure_cli")]
267 #[test]
268 #[serial]
269 fn can_parse_expires_on() -> azure_core::Result<()> {
270 let expires_on = "2022-07-30 12:12:53.919110";
271 assert_eq!(
272 az_cli_date_format::parse(expires_on)?,
273 az_cli_date_format::assume_local(&datetime!(2022-07-30 12:12:53.919110))
274 );
275
276 Ok(())
277 }
278
279 #[cfg(all(feature = "old_azure_cli", unix))]
280 #[test]
281 #[serial]
282 fn check_timezone() -> azure_core::Result<()> {
288 let before = std::env::var("TZ").ok();
289 std::env::set_var("TZ", "US/Pacific");
290 let expires_on = "2022-11-30 12:12:53.919110";
291 let result = az_cli_date_format::parse(expires_on);
292
293 if let Some(before) = before {
294 std::env::set_var("TZ", before);
295 } else {
296 std::env::remove_var("TZ");
297 }
298
299 let expected = datetime!(2022-11-30 20:12:53.0).assume_utc();
300 assert_eq!(expected, result?);
301
302 Ok(())
303 }
304
305 #[test]
307 fn read_old_cli_token_response() -> azure_core::Result<()> {
308 let json = br#"
309 {
310 "accessToken": "MuchLonger_NotTheRealOne_Sv8Orn0Wq0OaXuQEg",
311 "expiresOn": "2024-01-01 19:23:16.000000",
312 "subscription": "33b83be5-faf7-42ea-a712-320a5f9dd111",
313 "tenant": "065e9f5e-870d-4ed1-af2b-1b58092353f3",
314 "tokenType": "Bearer"
315 }
316 "#;
317 let token_response: CliTokenResponse = from_json(json)?;
318 assert_eq!(
319 token_response.tenant,
320 "065e9f5e-870d-4ed1-af2b-1b58092353f3"
321 );
322 Ok(())
323 }
324
325 #[test]
327 fn read_cli_token_response() -> azure_core::Result<()> {
328 let json = br#"
329 {
330 "accessToken": "MuchLonger_NotTheRealOne_Sv8Orn0Wq0OaXuQEg",
331 "expiresOn": "2024-01-01 19:23:16.000000",
332 "expires_on": 1704158596,
333 "subscription": "33b83be5-faf7-42ea-a712-320a5f9dd111",
334 "tenant": "065e9f5e-870d-4ed1-af2b-1b58092353f3",
335 "tokenType": "Bearer"
336 }
337 "#;
338 let token_response: CliTokenResponse = from_json(json)?;
339 assert_eq!(
340 token_response.tenant,
341 "065e9f5e-870d-4ed1-af2b-1b58092353f3"
342 );
343 assert_eq!(token_response.expires_on, Some(1704158596));
344 assert_eq!(token_response.expires_on()?.unix_timestamp(), 1704158596);
345 Ok(())
346 }
347}