azure_identity/token_credentials/
azure_cli_credentials.rs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
use crate::token_credentials::cache::TokenCache;
use async_process::Command;
use azure_core::{
    auth::{AccessToken, Secret, TokenCredential},
    error::{Error, ErrorKind, ResultExt},
    from_json,
};
use serde::Deserialize;
use std::str;
use time::OffsetDateTime;
use tracing::trace;

#[cfg(feature = "old_azure_cli")]
mod az_cli_date_format {
    use azure_core::error::{ErrorKind, ResultExt};
    use serde::{Deserialize, Deserializer};
    use time::format_description::FormatItem;
    use time::macros::format_description;
    #[cfg(not(unix))]
    use time::UtcOffset;
    use time::{OffsetDateTime, PrimitiveDateTime};

    const FORMAT: &[FormatItem] =
        format_description!("[year]-[month]-[day] [hour]:[minute]:[second].[subsecond digits:6]");

    pub fn parse(s: &str) -> azure_core::Result<OffsetDateTime> {
        // expiresOn from azure cli uses the local timezone and needs to be converted to UTC
        let dt = PrimitiveDateTime::parse(s, FORMAT)
            .with_context(ErrorKind::DataConversion, || {
                format!("unable to parse expiresOn '{s}")
            })?;
        Ok(assume_local(&dt))
    }

    #[cfg(unix)]
    /// attempt to convert `PrimitiveDateTime` to `OffsetDate` using
    /// `tz::TimeZone`.  If any part of the conversion fails, such as if no
    /// timezone can be found, then use use the value as UTC.
    pub(crate) fn assume_local(date: &PrimitiveDateTime) -> OffsetDateTime {
        let as_utc = date.assume_utc();

        // try parsing the timezone from `TZ` enviornment variable.  If that
        // fails, or the enviornment variable doesn't exist, try using
        // `TimeZone::local`.  If that fails, then just return the UTC date.
        let Some(tz) = std::env::var("TZ")
            .ok()
            .and_then(|x| tz::TimeZone::from_posix_tz(&x).ok())
            .or_else(|| tz::TimeZone::local().ok())
        else {
            return as_utc;
        };

        let as_unix = as_utc.unix_timestamp();

        // if we can't find the local time type, just return the UTC date
        let Ok(local_time_type) = tz.find_local_time_type(as_unix) else {
            return as_utc;
        };

        // if we can't convert the unix timestamp to a DateTime, just return the UTC date
        let date = as_utc.date();
        let time = as_utc.time();
        let Ok(date) = tz::DateTime::new(
            date.year(),
            u8::from(date.month()),
            date.day(),
            time.hour(),
            time.minute(),
            time.second(),
            time.nanosecond(),
            *local_time_type,
        ) else {
            return as_utc;
        };

        // if we can't then convert to unix time (with the timezone) and then
        // back into an OffsetDateTime, then return the UTC date
        let Ok(date) = OffsetDateTime::from_unix_timestamp(date.unix_time()) else {
            return as_utc;
        };

        date
    }

    /// Assumes the local offset. Default to UTC if unable to get local offset.
    #[cfg(not(unix))]
    pub(crate) fn assume_local(date: &PrimitiveDateTime) -> OffsetDateTime {
        date.assume_offset(UtcOffset::current_local_offset().unwrap_or(UtcOffset::UTC))
    }

    pub fn deserialize<'de, D>(deserializer: D) -> Result<OffsetDateTime, D::Error>
    where
        D: Deserializer<'de>,
    {
        let s = String::deserialize(deserializer)?;
        parse(&s).map_err(serde::de::Error::custom)
    }
}

/// The response from `az account get-access-token --output json`.
#[derive(Debug, Clone, Deserialize)]
struct CliTokenResponse {
    #[serde(rename = "accessToken")]
    pub access_token: Secret,
    #[cfg(feature = "old_azure_cli")]
    #[serde(rename = "expiresOn", with = "az_cli_date_format")]
    /// The token's expiry time formatted in the local timezone.
    /// Unfortunately, this requires additional timezone dependencies.
    /// See https://github.com/Azure/azure-cli/issues/19700 for details.
    pub local_expires_on: OffsetDateTime,
    #[serde(rename = "expires_on")]
    /// The token's expiry time in seconds since the epoch, a unix timestamp.
    /// Available in Azure CLI 2.54.0 or newer.
    pub expires_on: Option<i64>,
    pub subscription: String,
    pub tenant: String,
    #[allow(unused)]
    #[serde(rename = "tokenType")]
    pub token_type: String,
}

impl CliTokenResponse {
    pub fn expires_on(&self) -> azure_core::Result<OffsetDateTime> {
        match self.expires_on {
            Some(timestamp) => Ok(OffsetDateTime::from_unix_timestamp(timestamp)
                .with_context(ErrorKind::DataConversion, || {
                    format!("unable to parse expires_on '{timestamp}'")
                })?),
            None => {
                #[cfg(feature = "old_azure_cli")]
                {
                    Ok(self.local_expires_on)
                }
                #[cfg(not(feature = "old_azure_cli"))]
                {
                    Err(Error::message(
                        ErrorKind::DataConversion,
                        "expires_on field not found. Please use Azure CLI 2.54.0 or newer.",
                    ))
                }
            }
        }
    }
}

/// Enables authentication to Azure Active Directory using Azure CLI to obtain an access token.
#[derive(Debug)]
pub struct AzureCliCredential {
    cache: TokenCache,
}

impl Default for AzureCliCredential {
    fn default() -> Self {
        Self::new()
    }
}

impl AzureCliCredential {
    pub fn create() -> azure_core::Result<Self> {
        // TODO check `az version` to see if it's installed
        Ok(AzureCliCredential::new())
    }

    /// Create a new `AzureCliCredential`
    pub fn new() -> Self {
        Self {
            cache: TokenCache::new(),
        }
    }

    /// Get an access token for an optional resource
    async fn get_access_token(scopes: Option<&[&str]>) -> azure_core::Result<CliTokenResponse> {
        // on window az is a cmd and it should be called like this
        // see https://doc.rust-lang.org/nightly/std/process/struct.Command.html
        let program = if cfg!(target_os = "windows") {
            "cmd"
        } else {
            "az"
        };
        let mut args = Vec::new();
        if cfg!(target_os = "windows") {
            args.push("/C");
            args.push("az");
        }
        args.push("account");
        args.push("get-access-token");
        args.push("--output");
        args.push("json");

        let scopes = scopes.map(|x| x.join(" "));

        if let Some(scopes) = &scopes {
            args.push("--scope");
            args.push(scopes);
        }

        trace!(
            "fetching credential via Azure CLI: {program} {}",
            args.join(" "),
        );

        match Command::new(program).args(args).output().await {
            Ok(az_output) if az_output.status.success() => {
                let output = str::from_utf8(&az_output.stdout)?;

                let access_token = from_json(output)?;
                Ok(access_token)
            }
            Ok(az_output) => {
                let output = String::from_utf8_lossy(&az_output.stderr);
                Err(Error::with_message(ErrorKind::Credential, || {
                    format!("'az account get-access-token' command failed: {output}")
                }))
            }
            Err(e) => match e.kind() {
                std::io::ErrorKind::NotFound => {
                    Err(Error::message(ErrorKind::Other, "Azure CLI not installed"))
                }
                error_kind => Err(Error::with_message(ErrorKind::Other, || {
                    format!("Unknown error of kind: {error_kind:?}")
                })),
            },
        }
    }

    /// Returns the current subscription ID from the Azure CLI.
    pub async fn get_subscription() -> azure_core::Result<String> {
        let tr = Self::get_access_token(None).await?;
        Ok(tr.subscription)
    }

    /// Returns the current tenant ID from the Azure CLI.
    pub async fn get_tenant() -> azure_core::Result<String> {
        let tr = Self::get_access_token(None).await?;
        Ok(tr.tenant)
    }

    async fn get_token(&self, scopes: &[&str]) -> azure_core::Result<AccessToken> {
        let tr = Self::get_access_token(Some(scopes)).await?;
        let expires_on = tr.expires_on()?;
        Ok(AccessToken::new(tr.access_token, expires_on))
    }
}

#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl TokenCredential for AzureCliCredential {
    async fn get_token(&self, scopes: &[&str]) -> azure_core::Result<AccessToken> {
        self.cache.get_token(scopes, self.get_token(scopes)).await
    }

    /// Clear the credential's cache.
    async fn clear_cache(&self) -> azure_core::Result<()> {
        self.cache.clear().await
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    #[cfg(feature = "old_azure_cli")]
    use serial_test::serial;
    #[cfg(feature = "old_azure_cli")]
    use time::macros::datetime;

    #[cfg(feature = "old_azure_cli")]
    #[test]
    #[serial]
    fn can_parse_expires_on() -> azure_core::Result<()> {
        let expires_on = "2022-07-30 12:12:53.919110";
        assert_eq!(
            az_cli_date_format::parse(expires_on)?,
            az_cli_date_format::assume_local(&datetime!(2022-07-30 12:12:53.919110))
        );

        Ok(())
    }

    #[cfg(all(feature = "old_azure_cli", unix))]
    #[test]
    #[serial]
    /// test the timezone conversion works as expected on unix platforms
    ///
    /// To validate the timezone conversion works as expected, this test
    /// temporarily sets the timezone to PST, performs the check, then resets
    /// the TZ enviornment variable.
    fn check_timezone() -> azure_core::Result<()> {
        let before = std::env::var("TZ").ok();
        std::env::set_var("TZ", "US/Pacific");
        let expires_on = "2022-11-30 12:12:53.919110";
        let result = az_cli_date_format::parse(expires_on);

        if let Some(before) = before {
            std::env::set_var("TZ", before);
        } else {
            std::env::remove_var("TZ");
        }

        let expected = datetime!(2022-11-30 20:12:53.0).assume_utc();
        assert_eq!(expected, result?);

        Ok(())
    }

    /// Test `from_json` for `CliTokenResponse` for old Azure CLI
    #[test]
    fn read_old_cli_token_response() -> azure_core::Result<()> {
        let json = br#"
        {
            "accessToken": "MuchLonger_NotTheRealOne_Sv8Orn0Wq0OaXuQEg",
            "expiresOn": "2024-01-01 19:23:16.000000",
            "subscription": "33b83be5-faf7-42ea-a712-320a5f9dd111",
            "tenant": "065e9f5e-870d-4ed1-af2b-1b58092353f3",
            "tokenType": "Bearer"
          }
        "#;
        let token_response: CliTokenResponse = from_json(json)?;
        assert_eq!(
            token_response.tenant,
            "065e9f5e-870d-4ed1-af2b-1b58092353f3"
        );
        Ok(())
    }

    /// Test `from_json` for `CliTokenResponse` for current Azure CLI
    #[test]
    fn read_cli_token_response() -> azure_core::Result<()> {
        let json = br#"
        {
            "accessToken": "MuchLonger_NotTheRealOne_Sv8Orn0Wq0OaXuQEg",
            "expiresOn": "2024-01-01 19:23:16.000000",
            "expires_on": 1704158596,
            "subscription": "33b83be5-faf7-42ea-a712-320a5f9dd111",
            "tenant": "065e9f5e-870d-4ed1-af2b-1b58092353f3",
            "tokenType": "Bearer"
        }
        "#;
        let token_response: CliTokenResponse = from_json(json)?;
        assert_eq!(
            token_response.tenant,
            "065e9f5e-870d-4ed1-af2b-1b58092353f3"
        );
        assert_eq!(token_response.expires_on, Some(1704158596));
        assert_eq!(token_response.expires_on()?.unix_timestamp(), 1704158596);
        Ok(())
    }
}