Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for authenticating using azureauth cli #1464

Merged
merged 15 commits into from
Nov 29, 2023
2 changes: 1 addition & 1 deletion eng/test/mock_transport/src/recorder_policy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ impl Policy for MockTransportRecorderPolicy {
{
let mut request_contents_stream = std::fs::File::create(&request_path).unwrap();
request_contents_stream
.write_all(request_contents.as_str().as_bytes())
.write_all(request_contents.as_bytes())
.context(ErrorKind::MockFramework, "cannot write request file")?;
}

Expand Down
1 change: 1 addition & 0 deletions sdk/identity/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ development = []
test_e2e = []
client_certificate = ["openssl"]
vendored_openssl = ["openssl/vendored"]
azureauth-cli = []
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we use_ in the other features


[[example]]
name="client_certificate_credentials"
Expand Down
209 changes: 209 additions & 0 deletions sdk/identity/src/token_credentials/azureauth_cli_credentials.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
use azure_core::{
auth::{AccessToken, TokenCredential, TokenResponse},
error::{Error, ErrorKind, ResultExt},
};
use oauth2::ClientId;
use serde::Deserialize;
use std::process::Command;
use std::str;
use time::OffsetDateTime;

mod unix_date_string {
use azure_core::error::{Error, ErrorKind};
use serde::{Deserialize, Deserializer};
use time::OffsetDateTime;

pub fn parse(s: &str) -> azure_core::Result<OffsetDateTime> {
let as_i64 = s.parse().map_err(|_| {
Error::with_message(ErrorKind::DataConversion, || {
format!("unable to parse expiration_date '{s}")
})
})?;

OffsetDateTime::from_unix_timestamp(as_i64).map_err(|_| {
Error::with_message(ErrorKind::DataConversion, || {
format!("unable to parse expiration_date '{s}")
})
})
}

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)
}
}

#[derive(Debug, Clone, Deserialize)]
struct CliTokenResponse {
pub token: AccessToken,
#[serde(with = "unix_date_string")]
pub expiration_date: OffsetDateTime,
}

/// Authentication Mode
///
/// Note: While the azureauth CLI supports devicecode, users wishing to use
/// devicecode should use `azure_identity::device_code_flow`
#[derive(Debug, Clone, Copy)]
pub enum AzureauthCliMode {
All,
IntegratedWindowsAuth,
demoray marked this conversation as resolved.
Show resolved Hide resolved
Broker,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Broker,
#[cfg(target_os = "windows")]
Broker,

Broker would also need it's own cfg atribute right?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@demoray ping.

Web,
}

/// Enables authentication to Azure Active Directory using Azure CLI to obtain an access token.
pub struct AzureauthCliCredential {
tenant_id: String,
client_id: ClientId,
modes: Vec<AzureauthCliMode>,
prompt_hint: Option<String>,
}

impl AzureauthCliCredential {
/// Create a new `AzureCliCredential`
pub fn new<T, C>(tenant_id: T, client_id: C) -> Self
where
T: Into<String>,
C: Into<ClientId>,
{
Self {
tenant_id: tenant_id.into(),
client_id: client_id.into(),
modes: Vec::new(),
prompt_hint: None,
}
}

pub fn add_mode(mut self, mode: AzureauthCliMode) -> Self {
self.modes.push(mode);
self
}

pub fn with_modes(mut self, modes: Vec<AzureauthCliMode>) -> Self {
self.modes = modes;
self
}

pub fn with_prompt_hint<S>(mut self, hint: S) -> Self
where
S: Into<String>,
{
self.prompt_hint = Some(hint.into());
self
}

fn get_access_token(&self, resource: &str) -> azure_core::Result<CliTokenResponse> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we take scopes: &Vec<String> or similar here instead of a single resource?

Copy link
Contributor

@kyle-rader-msft kyle-rader-msft Nov 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@demoray After reading the TokenCredential trait source, I see that resource here is actually a space separated list of scopes, or potentially the concept of a resource as parsed by the az CLI. But MSAL only takes a list of scopes, which AzureAuth will turn --resource into, but we recommend passing --scope repeatedly for each scope needed.

I think we need to test this a little further and likely split this "resource" (maybe rename it scopes), and pass each value as it's own --scope for azureauth to correctly handle them.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would prefer to do this as a follow-up PR, as I think the underlying trait should be updated to take a list of scopes.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So long as this doesn't ship, a follow up PR seems okay. I do not think this will work as is.

// try using azureauth.exe first, such that azureauth through WSL is
// used first if possible.
let (cmd_name, use_windows_features) = if Command::new("azureauth.exe")
.arg("--version")
.output()
.map(|x| x.status.success())
.unwrap_or(false)
{
("azureauth.exe", true)
} else {
("azureauth", false)
};

let mut cmd = Command::new(cmd_name);
cmd.args([
"aad",
"--scope",
&format!("{resource}/.default"),
resource,
"--client",
self.client_id.as_str(),
"--tenant",
self.tenant_id.as_str(),
"--output",
"json",
]);

if let Some(prompt_hint) = &self.prompt_hint {
cmd.args(["--prompt-hint", prompt_hint]);
}

for mode in &self.modes {
match mode {
AzureauthCliMode::All => {
cmd.args(["--mode", "all"]);
}
AzureauthCliMode::IntegratedWindowsAuth => {
if use_windows_features {
cmd.args(["--mode", "iwa"]);
}
}
AzureauthCliMode::Broker => {
if use_windows_features {
cmd.args(["--mode", "broker"]);
}
}
AzureauthCliMode::Web => {
cmd.args(["--mode", "web"]);
}
};
}

let result = cmd.output();

let output = result.map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => {
Error::message(ErrorKind::Other, "azureauth CLI not installed")
}
error_kind => Error::with_message(ErrorKind::Other, || {
format!("Unknown error of kind: {error_kind:?}")
}),
})?;

if !output.status.success() {
let output = String::from_utf8_lossy(&output.stderr);
heaths marked this conversation as resolved.
Show resolved Hide resolved
return Err(Error::with_message(ErrorKind::Credential, || {
format!("'azureauth' command failed: {output}")
}));
}

let output = String::from_utf8(output.stdout)?;

let token_response = serde_json::from_str::<CliTokenResponse>(&output)
.map_kind(ErrorKind::DataConversion)?;
Ok(token_response)
}
}

#[cfg_attr(target_arch = "wasm32", async_trait::async_trait(?Send))]
#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)]
impl TokenCredential for AzureauthCliCredential {
async fn get_token(&self, resource: &str) -> azure_core::Result<TokenResponse> {
let tr = self.get_access_token(resource)?;
Ok(TokenResponse::new(tr.token, tr.expiration_date))
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn parse_example() -> azure_core::Result<()> {
let src = r#"{
"user": "example@contoso.com",
"display_name": "Example User",
"token": "security token here",
"expiration_date": "1700166595"
}"#;

let response: CliTokenResponse = serde_json::from_str(src)?;
assert_eq!(response.token.secret(), "security token here");
assert_eq!(
response.expiration_date,
OffsetDateTime::from_unix_timestamp(1700166595).expect("known valid date")
);

Ok(())
}
}
4 changes: 4 additions & 0 deletions sdk/identity/src/token_credentials/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
//! * Client secret
mod auto_refreshing_credentials;
mod azure_cli_credentials;
#[cfg(feature = "azureauth-cli")]
mod azureauth_cli_credentials;
#[cfg(feature = "client_certificate")]
mod client_certificate_credentials;
mod client_secret_credentials;
Expand All @@ -17,6 +19,8 @@ mod workload_identity_credentials;

pub use auto_refreshing_credentials::*;
pub use azure_cli_credentials::*;
#[cfg(feature = "azureauth-cli")]
pub use azureauth_cli_credentials::*;
demoray marked this conversation as resolved.
Show resolved Hide resolved
#[cfg(feature = "client_certificate")]
pub use client_certificate_credentials::*;
pub use client_secret_credentials::*;
Expand Down