diff --git a/.vscode/cspell.json b/.vscode/cspell.json index 5a5594b32a..059070d8db 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -70,6 +70,7 @@ "posix", "pwsh", "reqwest", + "rsplit", "runtimes", "rustup", "schannel", diff --git a/sdk/identity/azure_identity/src/azure_cli_credential.rs b/sdk/identity/azure_identity/src/azure_cli_credential.rs index 0080b429ed..0c54520398 100644 --- a/sdk/identity/azure_identity/src/azure_cli_credential.rs +++ b/sdk/identity/azure_identity/src/azure_cli_credential.rs @@ -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, @@ -158,7 +159,9 @@ impl TokenCredential for AzureCliCredential { trace!("running Azure CLI command: {command:?}"); - shell_exec::(self.executor.clone(), &self.env, &command).await + shell_exec::(self.executor.clone(), &self.env, &command) + .await + .map_err(authentication_error::) } } diff --git a/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs b/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs index b190988ff3..f3138f0978 100644 --- a/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs +++ b/sdk/identity/azure_identity/src/azure_developer_cli_credential.rs @@ -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, @@ -128,7 +129,9 @@ impl TokenCredential for AzureDeveloperCliCredential { command.push(" --tenant-id "); command.push(tenant_id); } - shell_exec::(self.executor.clone(), &self.env, &command).await + shell_exec::(self.executor.clone(), &self.env, &command) + .await + .map_err(authentication_error::) } } diff --git a/sdk/identity/azure_identity/src/azure_pipelines_credential.rs b/sdk/identity/azure_identity/src/azure_pipelines_credential.rs index a0a1787b2d..512bd747c4 100644 --- a/sdk/identity/azure_identity/src/azure_pipelines_credential.rs +++ b/sdk/identity/azure_identity/src/azure_pipelines_credential.rs @@ -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}, @@ -123,7 +124,10 @@ impl TokenCredential for AzurePipelinesCredential { scopes: &[&str], options: Option>, ) -> azure_core::Result { - self.0.get_token(scopes, options).await + self.0 + .get_token(scopes, options) + .await + .map_err(authentication_error::) } } @@ -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, @@ -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] diff --git a/sdk/identity/azure_identity/src/client_certificate_credential.rs b/sdk/identity/azure_identity/src/client_certificate_credential.rs index 640a095dcc..c0a56dc2db 100644 --- a/sdk/identity/azure_identity/src/client_certificate_credential.rs +++ b/sdk/identity/azure_identity/src/client_certificate_credential.rs @@ -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}, @@ -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::) } } diff --git a/sdk/identity/azure_identity/src/client_secret_credential.rs b/sdk/identity/azure_identity/src/client_secret_credential.rs index a9b182106f..88cc3852c5 100644 --- a/sdk/identity/azure_identity/src/client_secret_credential.rs +++ b/sdk/identity/azure_identity/src/client_secret_credential.rs @@ -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}; @@ -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::) } } #[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, @@ -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] diff --git a/sdk/identity/azure_identity/src/lib.rs b/sdk/identity/azure_identity/src/lib.rs index 5cb4e2bd37..fc973fbbea 100644 --- a/sdk/identity/azure_identity/src/lib.rs +++ b/sdk/identity/azure_identity/src/lib.rs @@ -133,6 +133,33 @@ fn get_authority_host(env: Option, 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(e: azure_core::Error) -> azure_core::Error { + azure_core::Error::with_message_fn(e.kind().clone(), || { + let type_name = std::any::type_name::(); + 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()); diff --git a/sdk/identity/azure_identity/src/managed_identity_credential.rs b/sdk/identity/azure_identity/src/managed_identity_credential.rs index 85a0306a66..bda36bc6fc 100644 --- a/sdk/identity/azure_identity/src/managed_identity_credential.rs +++ b/sdk/identity/azure_identity/src/managed_identity_credential.rs @@ -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; @@ -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::) } } @@ -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; @@ -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) -> azure_core::Result<()> { if std::env::var("IDENTITY_IMDS_AVAILABLE").is_err() { println!("Skipped: IMDS isn't available"); diff --git a/sdk/identity/azure_identity/src/workload_identity_credential.rs b/sdk/identity/azure_identity/src/workload_identity_credential.rs index 8219339c27..640b6e0eef 100644 --- a/sdk/identity/azure_identity/src/workload_identity_credential.rs +++ b/sdk/identity/azure_identity/src/workload_identity_credential.rs @@ -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}, @@ -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::) } } @@ -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::{ @@ -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);