diff --git a/.vscode/cspell.json b/.vscode/cspell.json
index 7427d865905..9f6694fdb9d 100644
--- a/.vscode/cspell.json
+++ b/.vscode/cspell.json
@@ -24,6 +24,7 @@
"aarch",
"accountendpoint",
"accountkey",
+ "addrs",
"agentic",
"amqp",
"asyncoperation",
@@ -35,6 +36,7 @@
"checkpointstore",
"clippy",
"codeowners",
+ "commonname",
"contoso",
"cplusplus",
"cpptools",
@@ -79,6 +81,7 @@
"pageables",
"pkce",
"pkcs",
+ "pkey",
"posix",
"pullrequest",
"pwsh",
@@ -159,4 +162,4 @@
]
}
]
-}
+}
\ No newline at end of file
diff --git a/Cargo.lock b/Cargo.lock
index fe9695bfd9c..f5a58e159dd 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -790,6 +790,7 @@ dependencies = [
"openssl",
"pin-project",
"rand 0.10.2",
+ "reqwest",
"serde",
"serde_json",
"serde_test",
diff --git a/sdk/identity/azure_identity/CHANGELOG.md b/sdk/identity/azure_identity/CHANGELOG.md
index 567514b74fb..705b03be622 100644
--- a/sdk/identity/azure_identity/CHANGELOG.md
+++ b/sdk/identity/azure_identity/CHANGELOG.md
@@ -5,6 +5,7 @@
### Features Added
- Added support for Arc-connected servers when using the `ManagedIdentityCredential`.
+- Added opt-in AKS identity binding support to `WorkloadIdentityCredentialOptions` through `enable_azure_proxy`.
### Breaking Changes
diff --git a/sdk/identity/azure_identity/Cargo.toml b/sdk/identity/azure_identity/Cargo.toml
index 2f0344d30c6..14a08e32065 100644
--- a/sdk/identity/azure_identity/Cargo.toml
+++ b/sdk/identity/azure_identity/Cargo.toml
@@ -19,6 +19,7 @@ azure_core = { path = "../../core/azure_core", version = "1.2.0-beta.1", default
futures.workspace = true
openssl = { workspace = true, optional = true }
pin-project.workspace = true
+reqwest = { workspace = true, optional = true, features = ["rustls"] }
serde.workspace = true
serde_json.workspace = true
time.workspace = true
@@ -39,7 +40,8 @@ tokio.workspace = true
tracing-subscriber.workspace = true
[features]
-default = ["azure_core/default"]
+default = ["azure_core/default", "azure_proxy"]
+azure_proxy = ["dep:reqwest", "azure_core/reqwest_rustls"]
tokio = ["dep:tokio", "azure_core/tokio", "tokio/process"]
client_certificate = ["openssl"]
diff --git a/sdk/identity/azure_identity/TROUBLESHOOTING.md b/sdk/identity/azure_identity/TROUBLESHOOTING.md
index 73395c7050e..8a10eebe348 100644
--- a/sdk/identity/azure_identity/TROUBLESHOOTING.md
+++ b/sdk/identity/azure_identity/TROUBLESHOOTING.md
@@ -197,6 +197,10 @@ azd auth token --output json --scope https://management.core.windows.net/.defaul
| Error Message |Description| Mitigation |
|---|---|---|
|no client ID/tenant ID/token file specified|Incomplete configuration|In most cases these values are provided via environment variables set by Azure Workload Identity.
- If your application runs on Azure Kubernetes Service (AKS) or a cluster that has deployed the Azure Workload Identity admission webhook, check pod labels and service account configuration. See the [AKS documentation](https://learn.microsoft.com/azure/aks/workload-identity-deploy-cluster#disable-workload-identity) and [Azure Workload Identity troubleshooting guide](https://azure.github.io/azure-workload-identity/docs/troubleshooting.html) for more details.
- If your application isn't running on AKS or your cluster hasn't deployed the Workload Identity admission webhook, set these values in `WorkloadIdentityCredentialOptions`
+|invalid `AZURE_KUBERNETES_TOKEN_PROXY`|The AKS identity binding proxy endpoint is malformed or doesn't use HTTPS.|Use an HTTPS URL with a host and no user information, query, or fragment. Set `enable_azure_proxy` to `true` only when using [AKS identity bindings](https://learn.microsoft.com/azure/aks/identity-bindings-concepts).|
+|`AZURE_KUBERNETES_TOKEN_PROXY` is not set but another proxy variable is present|The proxy has auxiliary TLS configuration but no endpoint.|Set `AZURE_KUBERNETES_TOKEN_PROXY`, or remove `AZURE_KUBERNETES_SNI_NAME`, `AZURE_KUBERNETES_CA_FILE`, and `AZURE_KUBERNETES_CA_DATA`. These variables are ignored when `enable_azure_proxy` is `false`.|
+|invalid `AZURE_KUBERNETES_CA_FILE` or `AZURE_KUBERNETES_CA_DATA`|The configured CA certificate can't be read or parsed, or both CA sources are set.|Set at most one CA source. Ensure `AZURE_KUBERNETES_CA_FILE` points to a readable, nonempty PEM certificate bundle or provide the PEM bundle in `AZURE_KUBERNETES_CA_DATA`.|
+|failed to resolve `AZURE_KUBERNETES_TOKEN_PROXY` host|The proxy host can't be resolved while custom SNI is configured.|Verify the proxy hostname is resolvable from the pod and that `AZURE_KUBERNETES_SNI_NAME` contains the TLS server name presented by the cluster endpoint.|
## Troubleshoot `AzurePipelinesCredential` authentication issues
diff --git a/sdk/identity/azure_identity/src/custom_token_proxy.rs b/sdk/identity/azure_identity/src/custom_token_proxy.rs
new file mode 100644
index 00000000000..419b42308a2
--- /dev/null
+++ b/sdk/identity/azure_identity/src/custom_token_proxy.rs
@@ -0,0 +1,927 @@
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License.
+
+use crate::env::Env;
+#[cfg(feature = "azure_proxy")]
+use async_lock::{RwLock, RwLockUpgradableReadGuard};
+use azure_core::{error::ErrorKind, http::ClientOptions, Error};
+#[cfg(feature = "azure_proxy")]
+use azure_core::{
+ error::ResultExt,
+ http::{AsyncRawResponse, HttpClient, Request, Transport},
+};
+use std::path::PathBuf;
+#[cfg(feature = "azure_proxy")]
+use std::{
+ fs,
+ net::{SocketAddr, ToSocketAddrs},
+ sync::Arc,
+};
+use url::Url;
+
+const AZURE_KUBERNETES_CA_DATA: &str = "AZURE_KUBERNETES_CA_DATA";
+const AZURE_KUBERNETES_CA_FILE: &str = "AZURE_KUBERNETES_CA_FILE";
+const AZURE_KUBERNETES_SNI_NAME: &str = "AZURE_KUBERNETES_SNI_NAME";
+const AZURE_KUBERNETES_TOKEN_PROXY: &str = "AZURE_KUBERNETES_TOKEN_PROXY";
+
+#[derive(Debug)]
+pub(crate) struct CustomTokenProxyConfig {
+ pub(crate) ca: Option,
+ pub(crate) proxy_url: Option,
+ pub(crate) sni_name: Option,
+}
+
+#[cfg_attr(not(feature = "azure_proxy"), allow(dead_code))]
+#[derive(Debug)]
+pub(crate) enum CertificateAuthority {
+ Data(String),
+ File(PathBuf),
+}
+
+impl CustomTokenProxyConfig {
+ pub(crate) fn from_env(env: &Env) -> azure_core::Result {
+ let proxy = optional_env(env, AZURE_KUBERNETES_TOKEN_PROXY);
+ let sni_name = optional_env(env, AZURE_KUBERNETES_SNI_NAME);
+ let ca_file = optional_env(env, AZURE_KUBERNETES_CA_FILE);
+ let ca_data = optional_env(env, AZURE_KUBERNETES_CA_DATA);
+
+ if proxy.is_none() {
+ for (name, value) in [
+ (AZURE_KUBERNETES_SNI_NAME, sni_name.as_ref()),
+ (AZURE_KUBERNETES_CA_FILE, ca_file.as_ref()),
+ (AZURE_KUBERNETES_CA_DATA, ca_data.as_ref()),
+ ] {
+ if value.is_some() {
+ return Err(invalid_configuration(
+ name,
+ format!("{AZURE_KUBERNETES_TOKEN_PROXY} is not set"),
+ ));
+ }
+ }
+
+ return Ok(Self {
+ ca: None,
+ proxy_url: None,
+ sni_name: None,
+ });
+ }
+
+ if ca_file.is_some() && ca_data.is_some() {
+ return Err(invalid_configuration(
+ AZURE_KUBERNETES_CA_FILE,
+ format!("cannot be set with {AZURE_KUBERNETES_CA_DATA}"),
+ ));
+ }
+
+ let proxy = proxy.expect("proxy is checked above");
+ let proxy_url = Url::parse(&proxy)
+ .map_err(|err| invalid_configuration(AZURE_KUBERNETES_TOKEN_PROXY, err.to_string()))?;
+ if proxy_url.scheme() != "https" {
+ return Err(invalid_configuration(
+ AZURE_KUBERNETES_TOKEN_PROXY,
+ "must use HTTPS",
+ ));
+ }
+ if proxy_url.host_str().is_none() {
+ return Err(invalid_configuration(
+ AZURE_KUBERNETES_TOKEN_PROXY,
+ "must contain a host",
+ ));
+ }
+ if !proxy_url.username().is_empty() || proxy_url.password().is_some() {
+ return Err(invalid_configuration(
+ AZURE_KUBERNETES_TOKEN_PROXY,
+ "must not contain user information",
+ ));
+ }
+ if proxy_url.query().is_some() {
+ return Err(invalid_configuration(
+ AZURE_KUBERNETES_TOKEN_PROXY,
+ "must not contain a query",
+ ));
+ }
+ if proxy_url.fragment().is_some() {
+ return Err(invalid_configuration(
+ AZURE_KUBERNETES_TOKEN_PROXY,
+ "must not contain a fragment",
+ ));
+ }
+
+ let ca = match (ca_file, ca_data) {
+ (Some(path), None) => Some(CertificateAuthority::File(path.into())),
+ (None, Some(data)) => Some(CertificateAuthority::Data(data)),
+ (None, None) => None,
+ (Some(_), Some(_)) => unreachable!("mutually exclusive CA sources checked above"),
+ };
+
+ Ok(Self {
+ ca,
+ proxy_url: Some(proxy_url),
+ sni_name,
+ })
+ }
+
+ pub(crate) fn configure(self, options: &mut ClientOptions) -> azure_core::Result<()> {
+ let Some(proxy_url) = self.proxy_url else {
+ return Ok(());
+ };
+
+ #[cfg(feature = "azure_proxy")]
+ {
+ let transport = CustomTokenProxy::new(proxy_url, self.sni_name, self.ca)?;
+ options.transport = Some(Transport::new(Arc::new(transport)));
+ Ok(())
+ }
+
+ #[cfg(not(feature = "azure_proxy"))]
+ {
+ let _ = (proxy_url, self.sni_name, self.ca, options);
+ Err(Error::with_message(
+ ErrorKind::Credential,
+ "AKS identity binding support requires the azure_proxy feature",
+ ))
+ }
+ }
+
+ #[cfg(all(test, feature = "azure_proxy"))]
+ pub(crate) fn configure_with_client(
+ self,
+ options: &mut ClientOptions,
+ client: Arc,
+ ) -> azure_core::Result<()> {
+ let Some(proxy_url) = self.proxy_url else {
+ return Ok(());
+ };
+ let transport = CustomTokenProxy::new_with_client(proxy_url, client);
+ options.transport = Some(Transport::new(Arc::new(transport)));
+ Ok(())
+ }
+}
+
+#[cfg(feature = "azure_proxy")]
+#[derive(Debug)]
+struct CustomTokenProxy {
+ proxy_url: Url,
+ request_url: Url,
+ host_header: Option,
+ resolved_addrs: Vec,
+ ca_file: Option,
+ cache: RwLock,
+}
+
+#[cfg(feature = "azure_proxy")]
+#[derive(Debug)]
+struct ClientCache {
+ client: Arc,
+ ca_data: Option>,
+}
+
+#[cfg(feature = "azure_proxy")]
+impl CustomTokenProxy {
+ fn new(
+ proxy_url: Url,
+ sni_name: Option,
+ ca: Option,
+ ) -> azure_core::Result {
+ let (request_url, host_header, resolved_addrs) =
+ prepare_sni_target(&proxy_url, sni_name.as_deref())?;
+ let (ca_file, ca_data) = match ca {
+ Some(CertificateAuthority::Data(data)) => (None, Some(data.into_bytes())),
+ Some(CertificateAuthority::File(path)) => {
+ let data = read_ca_file(&path)?;
+ (Some(path), Some(data))
+ }
+ None => (None, None),
+ };
+ let client = build_client(
+ &request_url,
+ &resolved_addrs,
+ ca_data.as_deref(),
+ ca_file.as_deref(),
+ )?;
+
+ Ok(Self {
+ proxy_url,
+ request_url,
+ host_header,
+ resolved_addrs,
+ ca_file,
+ cache: RwLock::new(ClientCache { client, ca_data }),
+ })
+ }
+
+ #[cfg(test)]
+ fn new_with_client(proxy_url: Url, client: Arc) -> Self {
+ Self {
+ request_url: proxy_url.clone(),
+ proxy_url,
+ host_header: None,
+ resolved_addrs: Vec::new(),
+ ca_file: None,
+ cache: RwLock::new(ClientCache {
+ client,
+ ca_data: None,
+ }),
+ }
+ }
+
+ async fn client(&self) -> azure_core::Result> {
+ let cache = self.cache.upgradable_read().await;
+ let Some(ca_file) = self.ca_file.as_deref() else {
+ return Ok(cache.client.clone());
+ };
+ let data = fs::read(ca_file).with_context_fn(ErrorKind::Credential, || {
+ format!(
+ "failed to read {AZURE_KUBERNETES_CA_FILE} {}",
+ ca_file.display()
+ )
+ })?;
+ if data.is_empty() {
+ return Ok(cache.client.clone());
+ }
+ if data != cache.ca_data.as_deref().unwrap_or_default() {
+ let mut cache = RwLockUpgradableReadGuard::upgrade(cache).await;
+ cache.client = build_client(
+ &self.request_url,
+ &self.resolved_addrs,
+ Some(&data),
+ Some(ca_file),
+ )?;
+ cache.ca_data = Some(data);
+ return Ok(cache.client.clone());
+ }
+ Ok(cache.client.clone())
+ }
+}
+
+#[cfg(feature = "azure_proxy")]
+#[async_trait::async_trait]
+impl HttpClient for CustomTokenProxy {
+ async fn execute_request(&self, request: &Request) -> azure_core::Result {
+ let mut request = request.clone();
+ *request.url_mut() = rewrite_proxy_url(&self.proxy_url, request.url())?;
+ if let Some(host) = &self.host_header {
+ request
+ .url_mut()
+ .set_host(self.request_url.host_str())
+ .map_err(|_| {
+ invalid_configuration(AZURE_KUBERNETES_SNI_NAME, "is not a valid host name")
+ })?;
+ request.headers_mut().insert("host", host.clone());
+ }
+ self.client().await?.execute_request(&request).await
+ }
+}
+
+#[cfg(feature = "azure_proxy")]
+fn build_client(
+ request_url: &Url,
+ resolved_addrs: &[SocketAddr],
+ ca_data: Option<&[u8]>,
+ ca_file: Option<&std::path::Path>,
+) -> azure_core::Result> {
+ let mut builder = reqwest::Client::builder()
+ .tls_backend_rustls()
+ .https_only(true)
+ .redirect(reqwest::redirect::Policy::none());
+ if let Some(data) = ca_data {
+ let certificates = reqwest::tls::Certificate::from_pem_bundle(data).map_err(|err| {
+ let variable = if ca_file.is_some() {
+ AZURE_KUBERNETES_CA_FILE
+ } else {
+ AZURE_KUBERNETES_CA_DATA
+ };
+ invalid_configuration(variable, format!("failed to parse certificate: {err}"))
+ })?;
+ if certificates.is_empty() {
+ return Err(invalid_configuration(
+ if ca_file.is_some() {
+ AZURE_KUBERNETES_CA_FILE
+ } else {
+ AZURE_KUBERNETES_CA_DATA
+ },
+ "contains no certificates",
+ ));
+ }
+
+ builder = builder.tls_certs_only(certificates);
+ }
+ if !resolved_addrs.is_empty() {
+ let host = request_url.host_str().ok_or_else(|| {
+ invalid_configuration(AZURE_KUBERNETES_SNI_NAME, "is not a valid host name")
+ })?;
+ builder = builder.resolve_to_addrs(host, resolved_addrs);
+ }
+ builder
+ .build()
+ .map(|client| Arc::new(client) as Arc)
+ .with_context(
+ ErrorKind::Credential,
+ "failed to create AKS identity binding HTTP client",
+ )
+}
+
+#[cfg(feature = "azure_proxy")]
+fn prepare_sni_target(
+ proxy_url: &Url,
+ sni_name: Option<&str>,
+) -> azure_core::Result<(Url, Option, Vec)> {
+ let Some(sni_name) = sni_name else {
+ return Ok((proxy_url.clone(), None, Vec::new()));
+ };
+ let host = proxy_url.host_str().ok_or_else(|| {
+ invalid_configuration(AZURE_KUBERNETES_TOKEN_PROXY, "must contain a host")
+ })?;
+ let port = proxy_url.port_or_known_default().ok_or_else(|| {
+ invalid_configuration(AZURE_KUBERNETES_TOKEN_PROXY, "must contain a valid port")
+ })?;
+ let resolved_addrs = (host, port)
+ .to_socket_addrs()
+ .with_context_fn(ErrorKind::Credential, || {
+ format!("failed to resolve {AZURE_KUBERNETES_TOKEN_PROXY} host {host}")
+ })?
+ .collect::>();
+ if resolved_addrs.is_empty() {
+ return Err(invalid_configuration(
+ AZURE_KUBERNETES_TOKEN_PROXY,
+ format!("host {host} resolved to no addresses"),
+ ));
+ }
+
+ let mut request_url = proxy_url.clone();
+ request_url.set_host(Some(sni_name)).map_err(|_| {
+ invalid_configuration(AZURE_KUBERNETES_SNI_NAME, "is not a valid host name")
+ })?;
+ let host_header = proxy_url[url::Position::BeforeHost..url::Position::AfterPort].to_string();
+ Ok((request_url, Some(host_header), resolved_addrs))
+}
+
+#[cfg(feature = "azure_proxy")]
+fn read_ca_file(path: &std::path::Path) -> azure_core::Result> {
+ let data = fs::read(path).with_context_fn(ErrorKind::Credential, || {
+ format!(
+ "failed to read {AZURE_KUBERNETES_CA_FILE} {}",
+ path.display()
+ )
+ })?;
+ if data.is_empty() {
+ return Err(invalid_configuration(
+ AZURE_KUBERNETES_CA_FILE,
+ format!("{} is empty", path.display()),
+ ));
+ }
+ Ok(data)
+}
+
+#[cfg(feature = "azure_proxy")]
+fn rewrite_proxy_url(proxy_url: &Url, request_url: &Url) -> azure_core::Result {
+ let proxy_path = proxy_url.path().trim_end_matches('/');
+ let request_path = request_url.path().trim_start_matches('/');
+ let path = if request_path.is_empty() {
+ format!("{proxy_path}/")
+ } else {
+ format!("{proxy_path}/{request_path}")
+ };
+ let mut value = proxy_url[url::Position::BeforeScheme..url::Position::AfterPort].to_string();
+ value.push_str(&path);
+ if let Some(query) = request_url.query() {
+ value.push('?');
+ value.push_str(query);
+ }
+ Url::parse(&value).with_context(
+ ErrorKind::DataConversion,
+ "failed to construct AKS identity binding proxy URL",
+ )
+}
+
+fn optional_env(env: &Env, name: &str) -> Option {
+ env.var(name).ok().filter(|value| !value.is_empty())
+}
+
+fn invalid_configuration(name: &'static str, message: impl Into) -> Error {
+ Error::with_message(
+ ErrorKind::Credential,
+ format!("invalid {name}: {}", message.into()),
+ )
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ #[cfg(feature = "azure_proxy")]
+ use futures::future::join_all;
+ #[cfg(all(feature = "azure_proxy", feature = "client_certificate"))]
+ use openssl::{
+ asn1::{Asn1Integer, Asn1Time},
+ bn::{BigNum, MsbOption},
+ hash::MessageDigest,
+ nid::Nid,
+ pkey::PKey,
+ rsa::Rsa,
+ ssl::{NameType, SslAcceptor, SslMethod},
+ x509::{
+ extension::{BasicConstraints, ExtendedKeyUsage, KeyUsage, SubjectAlternativeName},
+ X509NameBuilder, X509,
+ },
+ };
+ #[cfg(feature = "azure_proxy")]
+ use std::{
+ env,
+ fs::File,
+ io::Write,
+ sync::atomic::{AtomicUsize, Ordering},
+ };
+ #[cfg(all(feature = "azure_proxy", feature = "client_certificate"))]
+ use std::{io::Read, net::TcpListener, sync::mpsc, thread};
+
+ #[cfg(feature = "azure_proxy")]
+ const TEST_CA: &str = "-----BEGIN CERTIFICATE-----\n\
+MIIDZzCCAk+gAwIBAgIUPXdgRBlS4T18QnYJ/+yPV70GOEEwDQYJKoZIhvcNAQEL\n\
+BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDcyMTIwNTYxOVoXDTI3MDcy\n\
+MTIwNTYxOVowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF\n\
+AAOCAQ8AMIIBCgKCAQEAohtW1OHr/XIAlhxXq+vhvbosa/MvCptI8Pb1eJApnhYk\n\
+Zt3wGGMfjPPga4z+a7NSz5v2xD9qhHyMVNrlnt6becBCLm8Az3Q7zdpu6Cp+mEAc\n\
+VMLY/ttiPQfMKdj33aJxXZfqtFw++jm5kUCawW6OlvfcmZCVhMp5LQvDbVWULa5v\n\
+nsdzAoghf1RPZWyMXSme0vkfZaDN6LuLxhbXQOz9AVHnfX4eXvXO8UAhCV3xTsXU\n\
+KqkzzxPZX5Bt6/PEo1Nmp9YhmCYaLrljAr9ShTHdczfCPWJvGtYnSnbzCtapVffe\n\
+u/YK2l4uBWP6Nx0xjoXrrA3hM7qZdhinmmQsz870AQIDAQABo4GwMIGtMA8GA1Ud\n\
+EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGmMBYGA1UdJQEB/wQMMAoGCCsGAQUF\n\
+BwMBMBcGA1UdEQEB/wQNMAuCCWxvY2FsaG9zdDA6BgorBgEEAYI3VAEBBCwMKkFT\n\
+UC5ORVQgQ29yZSBIVFRQUyBkZXZlbG9wbWVudCBjZXJ0aWZpY2F0ZTAdBgNVHQ4E\n\
+FgQUndm3u54Kli+UWZSuG6zjDMf07r0wDQYJKoZIhvcNAQELBQADggEBAIx4ssZM\n\
+ET31rNiqhcArt0RP7Yxe59RxIPVWlsh0O3Bh/cT1Q5ESmSs9CA6jaVSkNhJQFF3x\n\
+qKz/PaG1an8f6YDTZfb1Eu1xL5E9t26GkjKovmOwZporaQm+d367sCK2Hab/5aJG\n\
+bqH23P5sbJQ+TogAf0Uykdq9rSx/5uwQBEv53tAHpSLOQXDWtNXo6AGNcyuouTgt\n\
+v/X15v4Gb9clgZpl3WXCvzOtEpaRSdf8dL76KKIiyClOzdvNP4/BpXxsYfAPU4hb\n\
+CesVElsCj5WckSkJ23gnTkzIAAeWjNnf+sOwaMgfsqh/XtKzYluV8MtbBljuOz0G\n\
+uaZPC0VV2qRwbAE=\n\
+-----END CERTIFICATE-----\n";
+
+ #[cfg(feature = "azure_proxy")]
+ static TEMP_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
+
+ #[cfg(feature = "azure_proxy")]
+ struct TempFile {
+ path: PathBuf,
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ impl TempFile {
+ fn new(content: &str) -> Self {
+ let id = TEMP_FILE_COUNTER.fetch_add(1, Ordering::SeqCst);
+ let path = env::temp_dir().join(format!(
+ "azure_identity_proxy_test_{}_{}",
+ std::process::id(),
+ id
+ ));
+ File::create(&path)
+ .expect("create CA file")
+ .write_all(content.as_bytes())
+ .expect("write CA file");
+ Self { path }
+ }
+
+ fn write(&self, content: &str) {
+ File::create(&self.path)
+ .expect("open CA file")
+ .write_all(content.as_bytes())
+ .expect("write CA file");
+ }
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ impl Drop for TempFile {
+ fn drop(&mut self) {
+ let _ = fs::remove_file(&self.path);
+ }
+ }
+
+ fn config(pairs: &[(&str, &str)]) -> azure_core::Result {
+ CustomTokenProxyConfig::from_env(&Env::from(pairs))
+ }
+
+ #[test]
+ fn no_configuration() {
+ let config = config(&[]).expect("empty configuration is valid");
+ assert!(config.proxy_url.is_none());
+ assert!(config.ca.is_none());
+ assert!(config.sni_name.is_none());
+ }
+
+ #[test]
+ fn minimal_configuration() {
+ let config = config(&[(
+ AZURE_KUBERNETES_TOKEN_PROXY,
+ "https://kubernetes.default.svc/proxy",
+ )])
+ .expect("minimal configuration is valid");
+ assert_eq!(
+ config.proxy_url.as_ref().map(Url::as_str),
+ Some("https://kubernetes.default.svc/proxy")
+ );
+ assert!(config.ca.is_none());
+ }
+
+ #[test]
+ fn rejects_invalid_proxy_urls() {
+ for value in [
+ "not a URL",
+ "http://kubernetes.default.svc",
+ "https://user@kubernetes.default.svc",
+ "https://kubernetes.default.svc?query=value",
+ "https://kubernetes.default.svc#fragment",
+ ] {
+ let error = config(&[(AZURE_KUBERNETES_TOKEN_PROXY, value)])
+ .expect_err("proxy URL should be invalid");
+ assert!(error.to_string().contains(AZURE_KUBERNETES_TOKEN_PROXY));
+ }
+ }
+
+ #[test]
+ fn rejects_auxiliary_configuration_without_proxy() {
+ for name in [
+ AZURE_KUBERNETES_SNI_NAME,
+ AZURE_KUBERNETES_CA_FILE,
+ AZURE_KUBERNETES_CA_DATA,
+ ] {
+ let error =
+ config(&[(name, "value")]).expect_err("auxiliary configuration requires a proxy");
+ assert!(error.to_string().contains(name));
+ assert!(error.to_string().contains(AZURE_KUBERNETES_TOKEN_PROXY));
+ }
+ }
+
+ #[test]
+ fn rejects_multiple_ca_sources() {
+ let error = config(&[
+ (AZURE_KUBERNETES_TOKEN_PROXY, "https://localhost"),
+ (AZURE_KUBERNETES_CA_FILE, "/ca.pem"),
+ (AZURE_KUBERNETES_CA_DATA, "certificate"),
+ ])
+ .expect_err("CA sources are mutually exclusive");
+ assert!(error.to_string().contains(AZURE_KUBERNETES_CA_FILE));
+ assert!(error.to_string().contains(AZURE_KUBERNETES_CA_DATA));
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ #[test]
+ fn rewrites_request_url() {
+ for (proxy, request, expected) in [
+ (
+ "https://proxy.example.com",
+ "https://login.example.com/tenant/oauth2/v2.0/token?a=1&b=2",
+ "https://proxy.example.com/tenant/oauth2/v2.0/token?a=1&b=2",
+ ),
+ (
+ "https://proxy.example.com/base/",
+ "https://login.example.com/a%20b?q=1",
+ "https://proxy.example.com/base/a%20b?q=1",
+ ),
+ (
+ "https://proxy.example.com/base",
+ "https://login.example.com",
+ "https://proxy.example.com/base/",
+ ),
+ ] {
+ let actual = rewrite_proxy_url(
+ &Url::parse(proxy).expect("proxy URL"),
+ &Url::parse(request).expect("request URL"),
+ )
+ .expect("rewritten URL");
+ assert_eq!(actual.as_str(), expected);
+ }
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ #[test]
+ fn rejects_missing_and_invalid_ca_files() {
+ for path in ["/file/does/not/exist", file!()] {
+ let error = CustomTokenProxy::new(
+ Url::parse("https://proxy.example.com").expect("proxy URL"),
+ None,
+ Some(CertificateAuthority::File(path.into())),
+ )
+ .expect_err("CA file should be invalid");
+ assert!(error.to_string().contains(AZURE_KUBERNETES_CA_FILE));
+ }
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ #[test]
+ fn rejects_invalid_inline_ca_without_exposing_data() {
+ let ca_data = "not a certificate";
+ let error = CustomTokenProxy::new(
+ Url::parse("https://proxy.example.com").expect("proxy URL"),
+ None,
+ Some(CertificateAuthority::Data(ca_data.to_string())),
+ )
+ .expect_err("CA data should be invalid");
+ assert!(error.to_string().contains(AZURE_KUBERNETES_CA_DATA));
+ assert!(!error.to_string().contains(ca_data));
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ #[test]
+ fn accepts_inline_and_file_ca() {
+ CustomTokenProxy::new(
+ Url::parse("https://proxy.example.com").expect("proxy URL"),
+ None,
+ Some(CertificateAuthority::Data(TEST_CA.to_string())),
+ )
+ .expect("valid inline CA");
+
+ let file = TempFile::new(TEST_CA);
+ CustomTokenProxy::new(
+ Url::parse("https://proxy.example.com").expect("proxy URL"),
+ None,
+ Some(CertificateAuthority::File(file.path.clone())),
+ )
+ .expect("valid file CA");
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ #[tokio::test]
+ async fn reuses_and_rotates_file_ca_client() {
+ let file = TempFile::new(TEST_CA);
+ let proxy = CustomTokenProxy::new(
+ Url::parse("https://proxy.example.com").expect("proxy URL"),
+ None,
+ Some(CertificateAuthority::File(file.path.clone())),
+ )
+ .expect("valid file CA");
+ let original = proxy.client().await.expect("cached client");
+ assert!(Arc::ptr_eq(
+ &original,
+ &proxy.client().await.expect("reused client")
+ ));
+
+ file.write("");
+ let retained = proxy.client().await.expect("last good client");
+ assert!(Arc::ptr_eq(&original, &retained));
+
+ file.write(&format!("{TEST_CA}\n"));
+ let rotated = proxy.client().await.expect("rotated client");
+ assert!(!Arc::ptr_eq(&original, &rotated));
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ #[tokio::test]
+ async fn serializes_concurrent_file_ca_refresh() {
+ let file = TempFile::new(TEST_CA);
+ let proxy = CustomTokenProxy::new(
+ Url::parse("https://proxy.example.com").expect("proxy URL"),
+ None,
+ Some(CertificateAuthority::File(file.path.clone())),
+ )
+ .expect("valid file CA");
+ let original = proxy.client().await.expect("cached client");
+
+ let clients = join_all((0..16).map(|_| proxy.client())).await;
+ for client in clients {
+ assert!(Arc::ptr_eq(&original, &client.expect("refreshed client")));
+ }
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ #[tokio::test]
+ async fn surfaces_file_ca_refresh_errors_without_replacing_client() {
+ let file = TempFile::new(TEST_CA);
+ let proxy = CustomTokenProxy::new(
+ Url::parse("https://proxy.example.com").expect("proxy URL"),
+ None,
+ Some(CertificateAuthority::File(file.path.clone())),
+ )
+ .expect("valid file CA");
+ let original = proxy.client().await.expect("cached client");
+
+ file.write("not a certificate");
+ let error = proxy.client().await.expect_err("invalid rotated CA");
+ assert!(error.to_string().contains(AZURE_KUBERNETES_CA_FILE));
+ assert!(Arc::ptr_eq(&original, &proxy.cache.read().await.client));
+
+ fs::remove_file(&file.path).expect("remove CA file");
+ let error = proxy.client().await.expect_err("missing rotated CA file");
+ assert!(error.to_string().contains(AZURE_KUBERNETES_CA_FILE));
+ assert!(Arc::ptr_eq(&original, &proxy.cache.read().await.client));
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ #[test]
+ fn prepares_custom_sni_target() {
+ let proxy_url = Url::parse("https://127.0.0.1:8443/base").expect("proxy URL");
+ let (request_url, host_header, resolved_addrs) =
+ prepare_sni_target(&proxy_url, Some("cluster.example.com")).expect("custom SNI target");
+ assert_eq!(request_url.host_str(), Some("cluster.example.com"));
+ assert_eq!(request_url.port(), Some(8443));
+ assert_eq!(host_header.as_deref(), Some("127.0.0.1:8443"));
+ assert_eq!(resolved_addrs, vec!["127.0.0.1:8443".parse().unwrap()]);
+ }
+
+ #[cfg(all(feature = "azure_proxy", feature = "client_certificate"))]
+ #[tokio::test]
+ async fn sends_requests_with_custom_ca_and_sni() {
+ const SNI_NAME: &str = "cluster.example.com";
+ let (certificate, key, ca_certificate) = test_server_certificate(SNI_NAME);
+ let ca_data = String::from_utf8(ca_certificate.to_pem().expect("CA certificate PEM"))
+ .expect("PEM is UTF-8");
+ let mut acceptor =
+ SslAcceptor::mozilla_intermediate_v5(SslMethod::tls_server()).expect("TLS acceptor");
+ acceptor.set_certificate(&certificate).expect("certificate");
+ acceptor.set_private_key(&key).expect("private key");
+ acceptor.check_private_key().expect("matching private key");
+ let acceptor = acceptor.build();
+ let listener = TcpListener::bind("127.0.0.1:0").expect("bind HTTPS server");
+ let address = listener.local_addr().expect("server address");
+ let (sender, receiver) = mpsc::channel();
+ let server = thread::spawn(move || {
+ for _ in 0..2 {
+ let (stream, _) = listener.accept().expect("TLS connection");
+ let mut stream = acceptor.accept(stream).expect("TLS handshake");
+ let sni = stream
+ .ssl()
+ .servername(NameType::HOST_NAME)
+ .map(str::to_string);
+ let mut request = Vec::new();
+ let mut buffer = [0_u8; 1024];
+ while !request.windows(4).any(|value| value == b"\r\n\r\n") {
+ let read = stream.read(&mut buffer).expect("read request");
+ assert_ne!(read, 0, "connection closed before request headers");
+ request.extend_from_slice(&buffer[..read]);
+ }
+ stream
+ .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n")
+ .expect("write response");
+ sender
+ .send((sni, String::from_utf8(request).expect("HTTP is UTF-8")))
+ .expect("send captured request");
+ }
+ });
+
+ let proxy_url = Url::parse(&format!("https://{address}/base")).expect("proxy URL");
+ let inline_proxy = CustomTokenProxy::new(
+ proxy_url.clone(),
+ Some(SNI_NAME.to_string()),
+ Some(CertificateAuthority::Data(ca_data.clone())),
+ )
+ .expect("proxy transport");
+ let request = Request::new(
+ Url::parse("https://login.example.com/tenant/token?query=value").expect("request URL"),
+ azure_core::http::Method::Get,
+ );
+ let response = inline_proxy
+ .execute_request(&request)
+ .await
+ .expect("inline CA HTTPS response");
+ assert_eq!(response.status(), azure_core::http::StatusCode::Ok);
+
+ let ca_file = TempFile::new(&ca_data);
+ let file_proxy = CustomTokenProxy::new(
+ proxy_url,
+ Some(SNI_NAME.to_string()),
+ Some(CertificateAuthority::File(ca_file.path.clone())),
+ )
+ .expect("proxy transport");
+ let response = file_proxy
+ .execute_request(&request)
+ .await
+ .expect("file CA HTTPS response");
+ assert_eq!(response.status(), azure_core::http::StatusCode::Ok);
+
+ server.join().expect("HTTPS server");
+ for _ in 0..2 {
+ let (sni, request) = receiver.recv().expect("captured request");
+ assert_eq!(sni.as_deref(), Some(SNI_NAME));
+ assert!(request.starts_with("GET /base/tenant/token?query=value HTTP/1.1\r\n"));
+ assert!(request.contains(&format!("\r\nhost: {address}\r\n")));
+ }
+ }
+
+ #[cfg(all(feature = "azure_proxy", feature = "client_certificate"))]
+ fn test_server_certificate(name: &str) -> (X509, PKey, X509) {
+ let ca_key =
+ PKey::from_rsa(Rsa::generate(2048).expect("CA RSA key")).expect("CA private key");
+ let mut ca_subject = X509NameBuilder::new().expect("CA subject");
+ ca_subject
+ .append_entry_by_nid(Nid::COMMONNAME, "test CA")
+ .expect("CA common name");
+ let ca_subject = ca_subject.build();
+ let mut serial = BigNum::new().expect("serial");
+ serial
+ .rand(128, MsbOption::MAYBE_ZERO, false)
+ .expect("random serial");
+ let serial = Asn1Integer::from_bn(&serial).expect("ASN.1 serial");
+
+ let mut ca_certificate = X509::builder().expect("CA certificate builder");
+ ca_certificate
+ .set_version(2)
+ .expect("CA certificate version");
+ ca_certificate
+ .set_serial_number(&serial)
+ .expect("CA certificate serial");
+ ca_certificate
+ .set_subject_name(&ca_subject)
+ .expect("CA certificate subject");
+ ca_certificate
+ .set_issuer_name(&ca_subject)
+ .expect("CA certificate issuer");
+ ca_certificate
+ .set_pubkey(&ca_key)
+ .expect("CA certificate key");
+ ca_certificate
+ .set_not_before(&Asn1Time::days_from_now(0).expect("CA not before"))
+ .expect("CA not before");
+ ca_certificate
+ .set_not_after(&Asn1Time::days_from_now(1).expect("CA not after"))
+ .expect("CA not after");
+ ca_certificate
+ .append_extension(BasicConstraints::new().critical().ca().build().expect("CA"))
+ .expect("CA extension");
+ ca_certificate
+ .append_extension(
+ KeyUsage::new()
+ .critical()
+ .key_cert_sign()
+ .crl_sign()
+ .build()
+ .expect("CA key usage"),
+ )
+ .expect("CA key usage extension");
+ ca_certificate
+ .sign(&ca_key, MessageDigest::sha256())
+ .expect("sign CA certificate");
+ let ca_certificate = ca_certificate.build();
+
+ let key = PKey::from_rsa(Rsa::generate(2048).expect("server RSA key"))
+ .expect("server private key");
+ let mut subject = X509NameBuilder::new().expect("server subject");
+ subject
+ .append_entry_by_nid(Nid::COMMONNAME, name)
+ .expect("server common name");
+ let subject = subject.build();
+ let mut serial = BigNum::new().expect("server serial");
+ serial
+ .rand(128, MsbOption::MAYBE_ZERO, false)
+ .expect("random server serial");
+ let serial = Asn1Integer::from_bn(&serial).expect("ASN.1 server serial");
+
+ let mut certificate = X509::builder().expect("server certificate builder");
+ certificate
+ .set_version(2)
+ .expect("server certificate version");
+ certificate
+ .set_serial_number(&serial)
+ .expect("server certificate serial");
+ certificate
+ .set_subject_name(&subject)
+ .expect("server certificate subject");
+ certificate
+ .set_issuer_name(ca_certificate.subject_name())
+ .expect("server certificate issuer");
+ certificate
+ .set_pubkey(&key)
+ .expect("server certificate key");
+ certificate
+ .set_not_before(&Asn1Time::days_from_now(0).expect("not before"))
+ .expect("not before");
+ certificate
+ .set_not_after(&Asn1Time::days_from_now(1).expect("not after"))
+ .expect("not after");
+ certificate
+ .append_extension(
+ BasicConstraints::new()
+ .critical()
+ .build()
+ .expect("server constraints"),
+ )
+ .expect("server constraints extension");
+ certificate
+ .append_extension(
+ KeyUsage::new()
+ .critical()
+ .digital_signature()
+ .key_encipherment()
+ .build()
+ .expect("server key usage"),
+ )
+ .expect("server key usage extension");
+ certificate
+ .append_extension(
+ ExtendedKeyUsage::new()
+ .server_auth()
+ .build()
+ .expect("extended key usage"),
+ )
+ .expect("extended key usage extension");
+ let subject_alt_name = SubjectAlternativeName::new()
+ .dns(name)
+ .build(&certificate.x509v3_context(None, None))
+ .expect("subject alternative name");
+ certificate
+ .append_extension(subject_alt_name)
+ .expect("subject alternative name extension");
+ certificate
+ .sign(&ca_key, MessageDigest::sha256())
+ .expect("sign server certificate");
+ (certificate.build(), key, ca_certificate)
+ }
+}
diff --git a/sdk/identity/azure_identity/src/lib.rs b/sdk/identity/azure_identity/src/lib.rs
index 2d565fe6cf1..7a85ebd2ae4 100644
--- a/sdk/identity/azure_identity/src/lib.rs
+++ b/sdk/identity/azure_identity/src/lib.rs
@@ -15,6 +15,7 @@ mod client_assertion_credential;
#[cfg(feature = "client_certificate")]
mod client_certificate_credential;
mod client_secret_credential;
+mod custom_token_proxy;
mod developer_tools_credential;
mod env;
mod imds_managed_identity_credential;
diff --git a/sdk/identity/azure_identity/src/workload_identity_credential.rs b/sdk/identity/azure_identity/src/workload_identity_credential.rs
index 7c960ee145a..fa110a827c6 100644
--- a/sdk/identity/azure_identity/src/workload_identity_credential.rs
+++ b/sdk/identity/azure_identity/src/workload_identity_credential.rs
@@ -1,8 +1,10 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.
-use crate::env::Env;
+use crate::{custom_token_proxy::CustomTokenProxyConfig, env::Env};
use async_lock::{RwLock, RwLockUpgradableReadGuard};
+#[cfg(test)]
+use azure_core::http::HttpClient;
use azure_core::{
credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions},
error::{ErrorKind, ResultExt},
@@ -51,8 +53,20 @@ pub struct WorkloadIdentityCredentialOptions {
/// variable `AZURE_FEDERATED_TOKEN_FILE`.
pub token_file_path: Option,
+ /// Enables Azure Kubernetes Service (AKS) identity binding proxy support.
+ ///
+ /// When `true`, the credential reads the proxy endpoint from `AZURE_KUBERNETES_TOKEN_PROXY` and optional
+ /// TLS configuration from `AZURE_KUBERNETES_SNI_NAME`, `AZURE_KUBERNETES_CA_FILE`, and
+ /// `AZURE_KUBERNETES_CA_DATA`. When `false` (the default), the credential ignores those variables and
+ /// requests tokens directly from Microsoft Entra ID. See the
+ /// [AKS identity bindings documentation](https://learn.microsoft.com/azure/aks/identity-bindings-concepts)
+ /// for guidance about enabling this option.
+ pub enable_azure_proxy: bool,
+
#[cfg(test)]
pub(crate) env: Env,
+ #[cfg(test)]
+ pub(crate) proxy_transport: Option>,
}
impl fmt::Debug for WorkloadIdentityCredentialOptions {
@@ -60,6 +74,7 @@ impl fmt::Debug for WorkloadIdentityCredentialOptions {
f.debug_struct(type_name::())
.field("tenant_id", &self.tenant_id)
.field("client_id", &self.client_id)
+ .field("enable_azure_proxy", &self.enable_azure_proxy)
.finish_non_exhaustive()
}
}
@@ -72,6 +87,8 @@ impl WorkloadIdentityCredential {
let options = options.unwrap_or_default();
#[cfg(test)]
let env = options.env;
+ #[cfg(test)]
+ let proxy_transport = options.proxy_transport;
#[cfg(not(test))]
let env = Env::default();
let tenant_id = match options.tenant_id {
@@ -93,13 +110,27 @@ impl WorkloadIdentityCredential {
"no client id specified. Check pod configuration or set client_id in the options"
})?
};
+ let mut credential_options = options.credential_options;
+ if options.enable_azure_proxy {
+ let proxy = CustomTokenProxyConfig::from_env(&env)?;
+ #[cfg(all(test, feature = "azure_proxy"))]
+ if let Some(transport) = proxy_transport {
+ proxy.configure_with_client(&mut credential_options.client_options, transport)?;
+ } else {
+ proxy.configure(&mut credential_options.client_options)?;
+ }
+ #[cfg(all(test, not(feature = "azure_proxy")))]
+ let _ = proxy_transport;
+ #[cfg(any(not(test), all(test, not(feature = "azure_proxy"))))]
+ proxy.configure(&mut credential_options.client_options)?;
+ }
Ok(Arc::new(Self(
ClientAssertionCredential::::new_exclusive(
tenant_id,
client_id,
Token::new(path)?,
stringify!(WorkloadIdentityCredential),
- Some(options.credential_options),
+ Some(credential_options),
)?,
)))
}
@@ -202,8 +233,10 @@ mod tests {
};
use azure_core::{
http::{
- headers::Headers, AsyncRawResponse, ClientOptions, Method, RawResponse, Request,
- StatusCode, Transport, Url,
+ headers::{HeaderName, Headers},
+ policies::{Policy, PolicyResult},
+ AsyncRawResponse, ClientOptions, Context, Method, RawResponse, Request, StatusCode,
+ Transport, Url,
},
Bytes,
};
@@ -218,6 +251,22 @@ mod tests {
static TEMP_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0);
+ #[derive(Debug)]
+ struct AddHeaderPolicy;
+
+ #[async_trait::async_trait]
+ impl Policy for AddHeaderPolicy {
+ async fn send(
+ &self,
+ ctx: &Context,
+ request: &mut Request,
+ next: &[Arc],
+ ) -> PolicyResult {
+ request.headers_mut().insert("x-test-policy", "applied");
+ next[0].send(ctx, request, &next[1..]).await
+ }
+ }
+
pub struct TempFile {
pub path: PathBuf,
}
@@ -358,6 +407,111 @@ mod tests {
.expect_err("invalid tenant ID");
}
+ #[tokio::test]
+ async fn disabled_proxy_ignores_invalid_configuration() {
+ let temp_file = TempFile::new(FAKE_ASSERTION);
+ let mock = MockSts::new(
+ vec![token_response()],
+ Some(Arc::new(is_valid_request(
+ FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
+ Some(FAKE_ASSERTION.to_string()),
+ ))),
+ );
+ let credential = WorkloadIdentityCredential::new(Some(WorkloadIdentityCredentialOptions {
+ client_id: Some(FAKE_CLIENT_ID.to_string()),
+ tenant_id: Some(FAKE_TENANT_ID.to_string()),
+ token_file_path: Some(temp_file.path.clone()),
+ credential_options: ClientAssertionCredentialOptions {
+ client_options: ClientOptions {
+ transport: Some(Transport::new(Arc::new(mock))),
+ ..Default::default()
+ },
+ },
+ env: Env::from(
+ &[(
+ "AZURE_KUBERNETES_TOKEN_PROXY",
+ "http://insecure.example.com",
+ )][..],
+ ),
+ ..Default::default()
+ }))
+ .expect("disabled proxy should ignore its environment variables");
+ credential
+ .get_token(LIVE_TEST_SCOPES, None)
+ .await
+ .expect("direct Entra transport should remain configured");
+ }
+
+ #[cfg(feature = "azure_proxy")]
+ #[tokio::test]
+ async fn enabled_proxy_redirects_token_request_after_caller_policies() {
+ let temp_file = TempFile::new(FAKE_ASSERTION);
+ let validate_request = is_valid_request(
+ format!("https://proxy.example.com/base/{FAKE_TENANT_ID}"),
+ Some(FAKE_ASSERTION.to_string()),
+ );
+ let mock = MockSts::new(
+ vec![token_response()],
+ Some(Arc::new(move |request| {
+ validate_request(request)?;
+ assert_eq!(
+ request
+ .headers()
+ .get_str(&HeaderName::from_static("x-test-policy"))
+ .expect("policy header"),
+ "applied"
+ );
+ Ok(())
+ })),
+ );
+ let credential = WorkloadIdentityCredential::new(Some(WorkloadIdentityCredentialOptions {
+ client_id: Some(FAKE_CLIENT_ID.to_string()),
+ tenant_id: Some(FAKE_TENANT_ID.to_string()),
+ token_file_path: Some(temp_file.path.clone()),
+ enable_azure_proxy: true,
+ credential_options: ClientAssertionCredentialOptions {
+ client_options: ClientOptions {
+ per_call_policies: vec![Arc::new(AddHeaderPolicy)],
+ ..Default::default()
+ },
+ },
+ proxy_transport: Some(Arc::new(mock)),
+ env: Env::from(
+ &[(
+ "AZURE_KUBERNETES_TOKEN_PROXY",
+ "https://proxy.example.com/base",
+ )][..],
+ ),
+ }))
+ .expect("valid proxy credential");
+
+ let token = credential
+ .get_token(LIVE_TEST_SCOPES, None)
+ .await
+ .expect("proxy token response");
+ assert_eq!(token.token.secret(), FAKE_TOKEN);
+ }
+
+ #[test]
+ fn enabled_proxy_validates_configuration() {
+ let temp_file = TempFile::new(FAKE_ASSERTION);
+ let error = WorkloadIdentityCredential::new(Some(WorkloadIdentityCredentialOptions {
+ client_id: Some(FAKE_CLIENT_ID.to_string()),
+ tenant_id: Some(FAKE_TENANT_ID.to_string()),
+ token_file_path: Some(temp_file.path.clone()),
+ enable_azure_proxy: true,
+ env: Env::from(
+ &[(
+ "AZURE_KUBERNETES_TOKEN_PROXY",
+ "http://insecure.example.com",
+ )][..],
+ ),
+ ..Default::default()
+ }))
+ .expect_err("enabled proxy should validate its environment variables");
+ assert!(error.to_string().contains("AZURE_KUBERNETES_TOKEN_PROXY"));
+ }
+
#[recorded::test(live)]
async fn live() -> azure_core::Result<()> {
if env::var("CI_HAS_DEPLOYED_RESOURCES").is_err() {
@@ -445,6 +599,8 @@ mod tests {
),
][..],
),
+ enable_azure_proxy: false,
+ proxy_transport: None,
}))
.expect("valid credential");