Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .vscode/cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@
"posix",
"pwsh",
"reqwest",
"rsplit",
"runtimes",
"rustup",
"schannel",
Expand Down
5 changes: 4 additions & 1 deletion sdk/identity/azure_identity/src/azure_cli_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

use crate::{
authentication_error,
env::Env,
process::{new_executor, shell_exec, Executor, OutputProcessor},
validate_scope, validate_subscription, validate_tenant_id,
Expand Down Expand Up @@ -158,7 +159,9 @@ impl TokenCredential for AzureCliCredential {

trace!("running Azure CLI command: {command:?}");

shell_exec::<CliTokenResponse>(self.executor.clone(), &self.env, &command).await
shell_exec::<CliTokenResponse>(self.executor.clone(), &self.env, &command)
.await
.map_err(authentication_error::<Self>)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

use crate::{
authentication_error,
env::Env,
process::{new_executor, shell_exec, Executor, OutputProcessor},
validate_scope, validate_tenant_id,
Expand Down Expand Up @@ -128,7 +129,9 @@ impl TokenCredential for AzureDeveloperCliCredential {
command.push(" --tenant-id ");
command.push(tenant_id);
}
shell_exec::<AzdTokenResponse>(self.executor.clone(), &self.env, &command).await
shell_exec::<AzdTokenResponse>(self.executor.clone(), &self.env, &command)
.await
.map_err(authentication_error::<Self>)
}
}

Expand Down
32 changes: 21 additions & 11 deletions sdk/identity/azure_identity/src/azure_pipelines_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// Licensed under the MIT License.

use crate::{
env::Env, ClientAssertion, ClientAssertionCredential, ClientAssertionCredentialOptions,
authentication_error, env::Env, ClientAssertion, ClientAssertionCredential,
ClientAssertionCredentialOptions,
};
use azure_core::{
credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions},
Expand Down Expand Up @@ -123,7 +124,10 @@ impl TokenCredential for AzurePipelinesCredential {
scopes: &[&str],
options: Option<TokenRequestOptions<'_>>,
) -> azure_core::Result<AccessToken> {
self.0.get_token(scopes, options).await
self.0
.get_token(scopes, options)
.await
.map_err(authentication_error::<Self>)
}
}

Expand Down Expand Up @@ -222,7 +226,7 @@ impl fmt::Display for ErrorHeaders {
#[cfg(test)]
mod tests {
use super::*;
use crate::env::Env;
use crate::{env::Env, TSG_LINK_ERROR_TEXT};
use azure_core::{
http::{BufResponse, ClientOptions, Transport},
Bytes,
Expand Down Expand Up @@ -284,16 +288,22 @@ mod tests {
let credential =
AzurePipelinesCredential::new("a".into(), "b".into(), "c", "d", Some(options))
.expect("valid AzurePipelinesCredential");
let err = credential
.get_token(&["default"], None)
.await
.expect_err("expected error");
assert!(matches!(
credential.get_token(&["default"], None).await,
Err(err) if matches!(
err.kind(),
ErrorKind::HttpResponse { status, .. }
if *status == StatusCode::Forbidden &&
err.to_string().contains("foo") &&
err.to_string().contains("bar"),
)
err.kind(),
ErrorKind::HttpResponse { status, .. }
if *status == StatusCode::Forbidden &&
err.to_string().contains("foo") &&
err.to_string().contains("bar"),
));
assert!(
err.to_string()
.contains(&format!("{TSG_LINK_ERROR_TEXT}#apc")),
"expected error to contain a link to the troubleshooting guide, got '{err}'",
);
}

#[tokio::test]
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

use crate::{get_authority_host, EntraIdTokenResponse, TokenCache};
use crate::{authentication_error, get_authority_host, EntraIdTokenResponse, TokenCache};
use azure_core::{
base64,
credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions},
Expand Down Expand Up @@ -275,5 +275,6 @@ impl TokenCredential for ClientCertificateCredential {
self.cache
.get_token(scopes, options, |s, o| self.get_token(s, o))
.await
.map_err(authentication_error::<Self>)
}
}
11 changes: 9 additions & 2 deletions sdk/identity/azure_identity/src/client_secret_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// Licensed under the MIT License.

use crate::{
deserialize, get_authority_host, EntraIdErrorResponse, EntraIdTokenResponse, TokenCache,
authentication_error, deserialize, get_authority_host, EntraIdErrorResponse,
EntraIdTokenResponse, TokenCache,
};
use azure_core::credentials::TokenRequestOptions;
use azure_core::http::{PipelineSendOptions, StatusCode};
Expand Down Expand Up @@ -159,13 +160,14 @@ impl TokenCredential for ClientSecretCredential {
self.cache
.get_token(scopes, options, |s, o| self.get_token_impl(s, o))
.await
.map_err(authentication_error::<Self>)
}
}

#[cfg(test)]
mod tests {
use super::*;
use crate::tests::*;
use crate::{tests::*, TSG_LINK_ERROR_TEXT};
use azure_core::{
http::{headers::Headers, BufResponse, StatusCode, Transport},
Bytes, Result,
Expand Down Expand Up @@ -254,6 +256,11 @@ mod tests {
"expected error description from the response, got '{}'",
err
);
assert!(
err.to_string()
.contains(&format!("{TSG_LINK_ERROR_TEXT}#client-secret")),
"expected error to contain a link to the troubleshooting guide, got '{err}'",
);
}

#[tokio::test]
Expand Down
27 changes: 27 additions & 0 deletions sdk/identity/azure_identity/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,33 @@ fn get_authority_host(env: Option<Env>, cloud: Option<&CloudConfiguration>) -> R
Ok(url)
}

const TSG_LINK_ERROR_TEXT: &str =
". To troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot";

/// Map an error from a credential's get_token() method to an ErrorKind::Credential error, appending
/// a link to the troubleshooting guide entry for that credential, if it has one.
///
/// TODO: decide whether to map to ErrorKind::Credential here (https://github.com/Azure/azure-sdk-for-rust/issues/3127)
fn authentication_error<T: 'static>(e: azure_core::Error) -> azure_core::Error {
azure_core::Error::with_message_fn(e.kind().clone(), || {
let type_name = std::any::type_name::<T>();
let short_name = type_name.rsplit("::").next().unwrap_or(type_name); // cspell:ignore rsplit
let link = match short_name {
"AzureCliCredential" => format!("{TSG_LINK_ERROR_TEXT}#azure-cli"),
"AzureDeveloperCliCredential" => format!("{TSG_LINK_ERROR_TEXT}#azd"),
"AzurePipelinesCredential" => format!("{TSG_LINK_ERROR_TEXT}#apc"),
#[cfg(feature = "client_certificate")]
"ClientCertificateCredential" => format!("{TSG_LINK_ERROR_TEXT}#client-cert"),
"ClientSecretCredential" => format!("{TSG_LINK_ERROR_TEXT}#client-secret"),
"ManagedIdentityCredential" => format!("{TSG_LINK_ERROR_TEXT}#managed-id"),
"WorkloadIdentityCredential" => format!("{TSG_LINK_ERROR_TEXT}#workload"),
_ => "".to_string(),
};

format!("{short_name} authentication failed: {e}{link}")
})
}

#[test]
fn test_validate_not_empty() {
assert!(validate_not_empty("", "it's empty").is_err());
Expand Down
53 changes: 49 additions & 4 deletions sdk/identity/azure_identity/src/managed_identity_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@
// Licensed under the MIT License.

use crate::{
env::Env, AppServiceManagedIdentityCredential, ImdsId, VirtualMachineManagedIdentityCredential,
authentication_error, env::Env, AppServiceManagedIdentityCredential, ImdsId,
VirtualMachineManagedIdentityCredential,
};
use azure_core::credentials::{AccessToken, TokenCredential, TokenRequestOptions};
use azure_core::http::ClientOptions;
Expand Down Expand Up @@ -104,7 +105,10 @@ impl TokenCredential for ManagedIdentityCredential {
|| "ManagedIdentityCredential requires exactly one scope".to_string(),
));
}
self.credential.get_token(scopes, options).await
self.credential
.get_token(scopes, options)
.await
.map_err(authentication_error::<Self>)
}
}

Expand Down Expand Up @@ -161,8 +165,11 @@ fn get_source(env: &Env) -> ManagedIdentitySource {
#[cfg(test)]
mod tests {
use super::*;
use crate::env::Env;
use crate::tests::{LIVE_TEST_RESOURCE, LIVE_TEST_SCOPES};
use crate::{
env::Env,
tests::{LIVE_TEST_RESOURCE, LIVE_TEST_SCOPES},
TSG_LINK_ERROR_TEXT,
};
use azure_core::http::headers::Headers;
use azure_core::http::{BufResponse, Method, Request, StatusCode, Transport, Url};
use azure_core::time::OffsetDateTime;
Expand Down Expand Up @@ -423,6 +430,44 @@ mod tests {
);
}

#[tokio::test]
async fn get_token_error() {
let mock_client = MockHttpClient::new(|_| {
async move {
Ok(BufResponse::from_bytes(
StatusCode::BadRequest,
Headers::default(),
Bytes::new(),
))
}
.boxed()
});
let options = ManagedIdentityCredentialOptions {
client_options: ClientOptions {
transport: Some(Transport::new(Arc::new(mock_client))),
..Default::default()
},
..Default::default()
};
let credential = ManagedIdentityCredential::new(Some(options)).expect("credential");
let err = credential
.get_token(LIVE_TEST_SCOPES, None)
.await
.expect_err("expected error");
assert!(matches!(
err.kind(),
azure_core::error::ErrorKind::Credential
));
assert!(err
.to_string()
.contains("the requested identity has not been assigned to this resource"));
assert!(
err.to_string()
.contains(&format!("{TSG_LINK_ERROR_TEXT}#managed-id")),
"expected error to contain a link to the troubleshooting guide, got '{err}'",
);
}

async fn run_imds_live_test(id: Option<UserAssignedId>) -> azure_core::Result<()> {
if std::env::var("IDENTITY_IMDS_AVAILABLE").is_err() {
println!("Skipped: IMDS isn't available");
Expand Down
60 changes: 58 additions & 2 deletions sdk/identity/azure_identity/src/workload_identity_credential.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

use crate::env::Env;
use crate::{authentication_error, env::Env};
use async_lock::{RwLock, RwLockUpgradableReadGuard};
use azure_core::{
credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions},
Expand Down Expand Up @@ -106,7 +106,10 @@ impl TokenCredential for WorkloadIdentityCredential {
"no scopes specified",
));
}
self.0.get_token(scopes, options).await
self.0
.get_token(scopes, options)
.await
.map_err(authentication_error::<Self>)
}
}

Expand Down Expand Up @@ -188,6 +191,7 @@ mod tests {
client_assertion_credential::tests::{is_valid_request, FAKE_ASSERTION},
env::Env,
tests::*,
TSG_LINK_ERROR_TEXT,
};
use azure_core::{
http::{
Expand Down Expand Up @@ -270,6 +274,58 @@ mod tests {
assert!(token.expires_on > SystemTime::now());
}

#[tokio::test]
async fn get_token_error() {
let temp_file = TempFile::new(FAKE_ASSERTION);
let description = "invalid assertion";
let mock = MockSts::new(
vec![BufResponse::from_bytes(
StatusCode::BadRequest,
Headers::default(),
Bytes::from(format!(
r#"{{"error":"invalid_request","error_description":"{}"}}"#,
description
)),
)],
Some(Arc::new(is_valid_request(
FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
))),
);
let cred = WorkloadIdentityCredential::new(Some(WorkloadIdentityCredentialOptions {
credential_options: ClientAssertionCredentialOptions {
client_options: ClientOptions {
transport: Some(Transport::new(Arc::new(mock))),
..Default::default()
},
..Default::default()
},
env: Env::from(
&[
(AZURE_CLIENT_ID, FAKE_CLIENT_ID),
(AZURE_TENANT_ID, FAKE_TENANT_ID),
(AZURE_FEDERATED_TOKEN_FILE, temp_file.path.to_str().unwrap()),
][..],
),
..Default::default()
}))
.expect("valid credential");

let err = cred
.get_token(LIVE_TEST_SCOPES, None)
.await
.expect_err("expected error");
assert!(matches!(
err.kind(),
azure_core::error::ErrorKind::Credential
));
assert!(err.to_string().contains(description));
assert!(
err.to_string()
.contains(&format!("{TSG_LINK_ERROR_TEXT}#workload")),
"expected error to contain a link to the troubleshooting guide, got '{err}'",
);
}

#[test]
fn invalid_tenant_id() {
let temp_file = TempFile::new(FAKE_ASSERTION);
Expand Down