Skip to content

Commit e37e379

Browse files
roeaptustvold
andauthored
object_store: azure cli authorization (#3698)
* fix: pass bearer token credential as auth header * feat: add azure cli credential * fix: clippy * Update object_store/src/azure/client.rs Co-authored-by: Raphael Taylor-Davies <[email protected]> * chore: PR feedback * docs: add azure cli link --------- Co-authored-by: Raphael Taylor-Davies <[email protected]>
1 parent d82298f commit e37e379

File tree

3 files changed

+164
-3
lines changed

3 files changed

+164
-3
lines changed

object_store/src/azure/client.rs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,18 @@ impl AzureClient {
169169
CredentialProvider::AccessKey(key) => {
170170
Ok(AzureCredential::AccessKey(key.to_owned()))
171171
}
172+
CredentialProvider::BearerToken(token) => {
173+
Ok(AzureCredential::AuthorizationToken(
174+
// we do the conversion to a HeaderValue here, since it is fallible
175+
// and we want to use it in an infallible function
176+
HeaderValue::from_str(&format!("Bearer {token}")).map_err(|err| {
177+
crate::Error::Generic {
178+
store: "MicrosoftAzure",
179+
source: Box::new(err),
180+
}
181+
})?,
182+
))
183+
}
172184
CredentialProvider::TokenCredential(cache, cred) => {
173185
let token = cache
174186
.get_or_insert_with(|| {
@@ -178,7 +190,7 @@ impl AzureClient {
178190
.context(AuthorizationSnafu)?;
179191
Ok(AzureCredential::AuthorizationToken(
180192
// we do the conversion to a HeaderValue here, since it is fallible
181-
// and we wna to use it in an infallible function
193+
// and we want to use it in an infallible function
182194
HeaderValue::from_str(&format!("Bearer {token}")).map_err(|err| {
183195
crate::Error::Generic {
184196
store: "MicrosoftAzure",

object_store/src/azure/credential.rs

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ use crate::util::hmac_sha256;
2121
use crate::RetryConfig;
2222
use base64::prelude::BASE64_STANDARD;
2323
use base64::Engine;
24-
use chrono::Utc;
24+
use chrono::{DateTime, Utc};
2525
use reqwest::header::ACCEPT;
2626
use reqwest::{
2727
header::{
@@ -34,6 +34,7 @@ use reqwest::{
3434
use serde::Deserialize;
3535
use snafu::{ResultExt, Snafu};
3636
use std::borrow::Cow;
37+
use std::process::Command;
3738
use std::str;
3839
use std::time::{Duration, Instant};
3940
use url::Url;
@@ -61,6 +62,12 @@ pub enum Error {
6162

6263
#[snafu(display("Error reading federated token file "))]
6364
FederatedTokenFile,
65+
66+
#[snafu(display("'az account get-access-token' command failed: {message}"))]
67+
AzureCli { message: String },
68+
69+
#[snafu(display("Failed to parse azure cli response: {source}"))]
70+
AzureCliResponse { source: serde_json::Error },
6471
}
6572

6673
pub type Result<T, E = Error> = std::result::Result<T, E>;
@@ -69,6 +76,7 @@ pub type Result<T, E = Error> = std::result::Result<T, E>;
6976
#[derive(Debug)]
7077
pub enum CredentialProvider {
7178
AccessKey(String),
79+
BearerToken(String),
7280
SASToken(Vec<(String, String)>),
7381
TokenCredential(TokenCache<String>, Box<dyn TokenCredential>),
7482
}
@@ -540,6 +548,122 @@ impl TokenCredential for WorkloadIdentityOAuthProvider {
540548
}
541549
}
542550

551+
mod az_cli_date_format {
552+
use chrono::{DateTime, TimeZone};
553+
use serde::{self, Deserialize, Deserializer};
554+
555+
pub fn deserialize<'de, D>(
556+
deserializer: D,
557+
) -> Result<DateTime<chrono::Local>, D::Error>
558+
where
559+
D: Deserializer<'de>,
560+
{
561+
let s = String::deserialize(deserializer)?;
562+
// expiresOn from azure cli uses the local timezone
563+
let date = chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S.%6f")
564+
.map_err(serde::de::Error::custom)?;
565+
chrono::Local
566+
.from_local_datetime(&date)
567+
.single()
568+
.ok_or(serde::de::Error::custom(
569+
"azure cli returned ambiguous expiry date",
570+
))
571+
}
572+
}
573+
574+
#[derive(Debug, Clone, Deserialize)]
575+
#[serde(rename_all = "camelCase")]
576+
struct AzureCliTokenResponse {
577+
pub access_token: String,
578+
#[serde(with = "az_cli_date_format")]
579+
pub expires_on: DateTime<chrono::Local>,
580+
pub token_type: String,
581+
}
582+
583+
#[derive(Default, Debug)]
584+
pub struct AzureCliCredential {
585+
_private: (),
586+
}
587+
588+
impl AzureCliCredential {
589+
pub fn new() -> Self {
590+
Self::default()
591+
}
592+
}
593+
594+
#[async_trait::async_trait]
595+
impl TokenCredential for AzureCliCredential {
596+
/// Fetch a token
597+
async fn fetch_token(
598+
&self,
599+
_client: &Client,
600+
_retry: &RetryConfig,
601+
) -> Result<TemporaryToken<String>> {
602+
// on window az is a cmd and it should be called like this
603+
// see https://doc.rust-lang.org/nightly/std/process/struct.Command.html
604+
let program = if cfg!(target_os = "windows") {
605+
"cmd"
606+
} else {
607+
"az"
608+
};
609+
let mut args = Vec::new();
610+
if cfg!(target_os = "windows") {
611+
args.push("/C");
612+
args.push("az");
613+
}
614+
args.push("account");
615+
args.push("get-access-token");
616+
args.push("--output");
617+
args.push("json");
618+
args.push("--scope");
619+
args.push(AZURE_STORAGE_SCOPE);
620+
621+
match Command::new(program).args(args).output() {
622+
Ok(az_output) if az_output.status.success() => {
623+
let output =
624+
str::from_utf8(&az_output.stdout).map_err(|_| Error::AzureCli {
625+
message: "az response is not a valid utf-8 string".to_string(),
626+
})?;
627+
628+
let token_response =
629+
serde_json::from_str::<AzureCliTokenResponse>(output)
630+
.context(AzureCliResponseSnafu)?;
631+
if !token_response.token_type.eq_ignore_ascii_case("bearer") {
632+
return Err(Error::AzureCli {
633+
message: format!(
634+
"got unexpected token type from azure cli: {0}",
635+
token_response.token_type
636+
),
637+
});
638+
}
639+
let duration = token_response.expires_on.naive_local()
640+
- chrono::Local::now().naive_local();
641+
Ok(TemporaryToken {
642+
token: token_response.access_token,
643+
expiry: Instant::now()
644+
+ duration.to_std().map_err(|_| Error::AzureCli {
645+
message: "az returned invalid lifetime".to_string(),
646+
})?,
647+
})
648+
}
649+
Ok(az_output) => {
650+
let message = String::from_utf8_lossy(&az_output.stderr);
651+
Err(Error::AzureCli {
652+
message: message.into(),
653+
})
654+
}
655+
Err(e) => match e.kind() {
656+
std::io::ErrorKind::NotFound => Err(Error::AzureCli {
657+
message: "Azure Cli not installed".into(),
658+
}),
659+
error_kind => Err(Error::AzureCli {
660+
message: format!("io error: {error_kind:?}"),
661+
}),
662+
},
663+
}
664+
}
665+
}
666+
543667
#[cfg(test)]
544668
mod tests {
545669
use super::*;

object_store/src/azure/mod.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ pub struct MicrosoftAzureBuilder {
400400
object_id: Option<String>,
401401
msi_resource_id: Option<String>,
402402
federated_token_file: Option<String>,
403+
use_azure_cli: bool,
403404
retry_config: RetryConfig,
404405
client_options: ClientOptions,
405406
}
@@ -533,6 +534,13 @@ pub enum AzureConfigKey {
533534
/// - `azure_federated_token_file`
534535
/// - `federated_token_file`
535536
FederatedTokenFile,
537+
538+
/// Use azure cli for acquiring access token
539+
///
540+
/// Supported keys:
541+
/// - `azure_use_azure_cli`
542+
/// - `use_azure_cli`
543+
UseAzureCli,
536544
}
537545

538546
impl AsRef<str> for AzureConfigKey {
@@ -550,6 +558,7 @@ impl AsRef<str> for AzureConfigKey {
550558
Self::ObjectId => "azure_object_id",
551559
Self::MsiResourceId => "azure_msi_resource_id",
552560
Self::FederatedTokenFile => "azure_federated_token_file",
561+
Self::UseAzureCli => "azure_use_azure_cli",
553562
}
554563
}
555564
}
@@ -593,6 +602,7 @@ impl FromStr for AzureConfigKey {
593602
"azure_federated_token_file" | "federated_token_file" => {
594603
Ok(Self::FederatedTokenFile)
595604
}
605+
"azure_use_azure_cli" | "use_azure_cli" => Ok(Self::UseAzureCli),
596606
_ => Err(Error::UnknownConfigurationKey { key: s.into() }.into()),
597607
}
598608
}
@@ -704,6 +714,9 @@ impl MicrosoftAzureBuilder {
704714
AzureConfigKey::FederatedTokenFile => {
705715
self.federated_token_file = Some(value.into())
706716
}
717+
AzureConfigKey::UseAzureCli => {
718+
self.use_azure_cli = str_is_truthy(&value.into())
719+
}
707720
AzureConfigKey::UseEmulator => {
708721
self.use_emulator = str_is_truthy(&value.into())
709722
}
@@ -887,6 +900,13 @@ impl MicrosoftAzureBuilder {
887900
self
888901
}
889902

903+
/// Set if the Azure Cli should be used for acquiring access token
904+
/// <https://learn.microsoft.com/en-us/cli/azure/account?view=azure-cli-latest#az-account-get-access-token>
905+
pub fn with_use_azure_cli(mut self, use_azure_cli: bool) -> Self {
906+
self.use_azure_cli = use_azure_cli;
907+
self
908+
}
909+
890910
/// Configure a connection to container with given name on Microsoft Azure
891911
/// Blob store.
892912
pub fn build(mut self) -> Result<MicrosoftAzure> {
@@ -916,7 +936,7 @@ impl MicrosoftAzureBuilder {
916936
let url = Url::parse(&account_url)
917937
.context(UnableToParseUrlSnafu { url: account_url })?;
918938
let credential = if let Some(bearer_token) = self.bearer_token {
919-
credential::CredentialProvider::AccessKey(bearer_token)
939+
credential::CredentialProvider::BearerToken(bearer_token)
920940
} else if let Some(access_key) = self.access_key {
921941
credential::CredentialProvider::AccessKey(access_key)
922942
} else if let (Some(client_id), Some(tenant_id), Some(federated_token_file)) =
@@ -949,6 +969,11 @@ impl MicrosoftAzureBuilder {
949969
credential::CredentialProvider::SASToken(query_pairs)
950970
} else if let Some(sas) = self.sas_key {
951971
credential::CredentialProvider::SASToken(split_sas(&sas)?)
972+
} else if self.use_azure_cli {
973+
credential::CredentialProvider::TokenCredential(
974+
TokenCache::default(),
975+
Box::new(credential::AzureCliCredential::new()),
976+
)
952977
} else {
953978
let client =
954979
self.client_options.clone().with_allow_http(true).client()?;

0 commit comments

Comments
 (0)