-
Notifications
You must be signed in to change notification settings - Fork 231
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
Changes from 12 commits
7c192e8
6a09a23
7780f0e
40c0186
d8d1c77
69eb5a5
3d18828
642ef26
7b017d6
54ae30f
7cf30e6
7a52b53
1e0ed15
f11f043
bd58766
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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, | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Broker would also need it's own There was a problem hiding this comment. Choose a reason for hiding this commentThe 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> { | ||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we take There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @demoray After reading the 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(()) | ||||||||
} | ||||||||
} |
There was a problem hiding this comment.
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