azure_identity/token_credentials/
azure_cli_credentials.rs

1use 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        // expiresOn from azure cli uses the local timezone and needs to be converted to UTC
28        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    /// attempt to convert `PrimitiveDateTime` to `OffsetDate` using
37    /// `tz::TimeZone`.  If any part of the conversion fails, such as if no
38    /// timezone can be found, then use use the value as UTC.
39    pub(crate) fn assume_local(date: &PrimitiveDateTime) -> OffsetDateTime {
40        let as_utc = date.assume_utc();
41
42        // try parsing the timezone from `TZ` enviornment variable.  If that
43        // fails, or the enviornment variable doesn't exist, try using
44        // `TimeZone::local`.  If that fails, then just return the UTC date.
45        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        // if we can't find the local time type, just return the UTC date
56        let Ok(local_time_type) = tz.find_local_time_type(as_unix) else {
57            return as_utc;
58        };
59
60        // if we can't convert the unix timestamp to a DateTime, just return the UTC date
61        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        // if we can't then convert to unix time (with the timezone) and then
77        // back into an OffsetDateTime, then return the UTC date
78        let Ok(date) = OffsetDateTime::from_unix_timestamp(date.unix_time()) else {
79            return as_utc;
80        };
81
82        date
83    }
84
85    /// Assumes the local offset. Default to UTC if unable to get local offset.
86    #[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/// The response from `az account get-access-token --output json`.
101#[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    /// The token's expiry time formatted in the local timezone.
108    /// Unfortunately, this requires additional timezone dependencies.
109    /// See https://github.com/Azure/azure-cli/issues/19700 for details.
110    pub local_expires_on: OffsetDateTime,
111    #[serde(rename = "expires_on")]
112    /// The token's expiry time in seconds since the epoch, a unix timestamp.
113    /// Available in Azure CLI 2.54.0 or newer.
114    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/// Enables authentication to Azure Active Directory using Azure CLI to obtain an access token.
147#[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        // TODO check `az version` to see if it's installed
161        Ok(AzureCliCredential::new())
162    }
163
164    /// Create a new `AzureCliCredential`
165    pub fn new() -> Self {
166        Self {
167            cache: TokenCache::new(),
168        }
169    }
170
171    /// Get an access token for an optional resource
172    async fn get_access_token(scopes: Option<&[&str]>) -> azure_core::Result<CliTokenResponse> {
173        // on window az is a cmd and it should be called like this
174        // see https://doc.rust-lang.org/nightly/std/process/struct.Command.html
175        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    /// Returns the current subscription ID from the Azure CLI.
227    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    /// Returns the current tenant ID from the Azure CLI.
233    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    /// Clear the credential's cache.
253    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    /// test the timezone conversion works as expected on unix platforms
283    ///
284    /// To validate the timezone conversion works as expected, this test
285    /// temporarily sets the timezone to PST, performs the check, then resets
286    /// the TZ enviornment variable.
287    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 `from_json` for `CliTokenResponse` for old Azure CLI
306    #[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 `from_json` for `CliTokenResponse` for current Azure CLI
326    #[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}