Skip to content

Commit f5aaf74

Browse files
authored
Link get_token errors to azure_identity troubleshooting guide (#3128)
1 parent 5ef60d9 commit f5aaf74

9 files changed

+175
-22
lines changed

.vscode/cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"posix",
7171
"pwsh",
7272
"reqwest",
73+
"rsplit",
7374
"runtimes",
7475
"rustup",
7576
"schannel",

sdk/identity/azure_identity/src/azure_cli_credential.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
use crate::{
5+
authentication_error,
56
env::Env,
67
process::{new_executor, shell_exec, Executor, OutputProcessor},
78
validate_scope, validate_subscription, validate_tenant_id,
@@ -158,7 +159,9 @@ impl TokenCredential for AzureCliCredential {
158159

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

161-
shell_exec::<CliTokenResponse>(self.executor.clone(), &self.env, &command).await
162+
shell_exec::<CliTokenResponse>(self.executor.clone(), &self.env, &command)
163+
.await
164+
.map_err(authentication_error::<Self>)
162165
}
163166
}
164167

sdk/identity/azure_identity/src/azure_developer_cli_credential.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
use crate::{
5+
authentication_error,
56
env::Env,
67
process::{new_executor, shell_exec, Executor, OutputProcessor},
78
validate_scope, validate_tenant_id,
@@ -128,7 +129,9 @@ impl TokenCredential for AzureDeveloperCliCredential {
128129
command.push(" --tenant-id ");
129130
command.push(tenant_id);
130131
}
131-
shell_exec::<AzdTokenResponse>(self.executor.clone(), &self.env, &command).await
132+
shell_exec::<AzdTokenResponse>(self.executor.clone(), &self.env, &command)
133+
.await
134+
.map_err(authentication_error::<Self>)
132135
}
133136
}
134137

sdk/identity/azure_identity/src/azure_pipelines_credential.rs

Lines changed: 21 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// Licensed under the MIT License.
33

44
use crate::{
5-
env::Env, ClientAssertion, ClientAssertionCredential, ClientAssertionCredentialOptions,
5+
authentication_error, env::Env, ClientAssertion, ClientAssertionCredential,
6+
ClientAssertionCredentialOptions,
67
};
78
use azure_core::{
89
credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions},
@@ -123,7 +124,10 @@ impl TokenCredential for AzurePipelinesCredential {
123124
scopes: &[&str],
124125
options: Option<TokenRequestOptions<'_>>,
125126
) -> azure_core::Result<AccessToken> {
126-
self.0.get_token(scopes, options).await
127+
self.0
128+
.get_token(scopes, options)
129+
.await
130+
.map_err(authentication_error::<Self>)
127131
}
128132
}
129133

@@ -222,7 +226,7 @@ impl fmt::Display for ErrorHeaders {
222226
#[cfg(test)]
223227
mod tests {
224228
use super::*;
225-
use crate::env::Env;
229+
use crate::{env::Env, TSG_LINK_ERROR_TEXT};
226230
use azure_core::{
227231
http::{BufResponse, ClientOptions, Transport},
228232
Bytes,
@@ -284,16 +288,22 @@ mod tests {
284288
let credential =
285289
AzurePipelinesCredential::new("a".into(), "b".into(), "c", "d", Some(options))
286290
.expect("valid AzurePipelinesCredential");
291+
let err = credential
292+
.get_token(&["default"], None)
293+
.await
294+
.expect_err("expected error");
287295
assert!(matches!(
288-
credential.get_token(&["default"], None).await,
289-
Err(err) if matches!(
290-
err.kind(),
291-
ErrorKind::HttpResponse { status, .. }
292-
if *status == StatusCode::Forbidden &&
293-
err.to_string().contains("foo") &&
294-
err.to_string().contains("bar"),
295-
)
296+
err.kind(),
297+
ErrorKind::HttpResponse { status, .. }
298+
if *status == StatusCode::Forbidden &&
299+
err.to_string().contains("foo") &&
300+
err.to_string().contains("bar"),
296301
));
302+
assert!(
303+
err.to_string()
304+
.contains(&format!("{TSG_LINK_ERROR_TEXT}#apc")),
305+
"expected error to contain a link to the troubleshooting guide, got '{err}'",
306+
);
297307
}
298308

299309
#[tokio::test]

sdk/identity/azure_identity/src/client_certificate_credential.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
use crate::{get_authority_host, EntraIdTokenResponse, TokenCache};
4+
use crate::{authentication_error, get_authority_host, EntraIdTokenResponse, TokenCache};
55
use azure_core::{
66
base64,
77
credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions},
@@ -275,5 +275,6 @@ impl TokenCredential for ClientCertificateCredential {
275275
self.cache
276276
.get_token(scopes, options, |s, o| self.get_token(s, o))
277277
.await
278+
.map_err(authentication_error::<Self>)
278279
}
279280
}

sdk/identity/azure_identity/src/client_secret_credential.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// Licensed under the MIT License.
33

44
use crate::{
5-
deserialize, get_authority_host, EntraIdErrorResponse, EntraIdTokenResponse, TokenCache,
5+
authentication_error, deserialize, get_authority_host, EntraIdErrorResponse,
6+
EntraIdTokenResponse, TokenCache,
67
};
78
use azure_core::credentials::TokenRequestOptions;
89
use azure_core::http::{PipelineSendOptions, StatusCode};
@@ -159,13 +160,14 @@ impl TokenCredential for ClientSecretCredential {
159160
self.cache
160161
.get_token(scopes, options, |s, o| self.get_token_impl(s, o))
161162
.await
163+
.map_err(authentication_error::<Self>)
162164
}
163165
}
164166

165167
#[cfg(test)]
166168
mod tests {
167169
use super::*;
168-
use crate::tests::*;
170+
use crate::{tests::*, TSG_LINK_ERROR_TEXT};
169171
use azure_core::{
170172
http::{headers::Headers, BufResponse, StatusCode, Transport},
171173
Bytes, Result,
@@ -254,6 +256,11 @@ mod tests {
254256
"expected error description from the response, got '{}'",
255257
err
256258
);
259+
assert!(
260+
err.to_string()
261+
.contains(&format!("{TSG_LINK_ERROR_TEXT}#client-secret")),
262+
"expected error to contain a link to the troubleshooting guide, got '{err}'",
263+
);
257264
}
258265

259266
#[tokio::test]

sdk/identity/azure_identity/src/lib.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,33 @@ fn get_authority_host(env: Option<Env>, cloud: Option<&CloudConfiguration>) -> R
133133
Ok(url)
134134
}
135135

136+
const TSG_LINK_ERROR_TEXT: &str =
137+
". To troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot";
138+
139+
/// Map an error from a credential's get_token() method to an ErrorKind::Credential error, appending
140+
/// a link to the troubleshooting guide entry for that credential, if it has one.
141+
///
142+
/// TODO: decide whether to map to ErrorKind::Credential here (https://github.com/Azure/azure-sdk-for-rust/issues/3127)
143+
fn authentication_error<T: 'static>(e: azure_core::Error) -> azure_core::Error {
144+
azure_core::Error::with_message_fn(e.kind().clone(), || {
145+
let type_name = std::any::type_name::<T>();
146+
let short_name = type_name.rsplit("::").next().unwrap_or(type_name); // cspell:ignore rsplit
147+
let link = match short_name {
148+
"AzureCliCredential" => format!("{TSG_LINK_ERROR_TEXT}#azure-cli"),
149+
"AzureDeveloperCliCredential" => format!("{TSG_LINK_ERROR_TEXT}#azd"),
150+
"AzurePipelinesCredential" => format!("{TSG_LINK_ERROR_TEXT}#apc"),
151+
#[cfg(feature = "client_certificate")]
152+
"ClientCertificateCredential" => format!("{TSG_LINK_ERROR_TEXT}#client-cert"),
153+
"ClientSecretCredential" => format!("{TSG_LINK_ERROR_TEXT}#client-secret"),
154+
"ManagedIdentityCredential" => format!("{TSG_LINK_ERROR_TEXT}#managed-id"),
155+
"WorkloadIdentityCredential" => format!("{TSG_LINK_ERROR_TEXT}#workload"),
156+
_ => "".to_string(),
157+
};
158+
159+
format!("{short_name} authentication failed: {e}{link}")
160+
})
161+
}
162+
136163
#[test]
137164
fn test_validate_not_empty() {
138165
assert!(validate_not_empty("", "it's empty").is_err());

sdk/identity/azure_identity/src/managed_identity_credential.rs

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
// Licensed under the MIT License.
33

44
use crate::{
5-
env::Env, AppServiceManagedIdentityCredential, ImdsId, VirtualMachineManagedIdentityCredential,
5+
authentication_error, env::Env, AppServiceManagedIdentityCredential, ImdsId,
6+
VirtualMachineManagedIdentityCredential,
67
};
78
use azure_core::credentials::{AccessToken, TokenCredential, TokenRequestOptions};
89
use azure_core::http::ClientOptions;
@@ -104,7 +105,10 @@ impl TokenCredential for ManagedIdentityCredential {
104105
|| "ManagedIdentityCredential requires exactly one scope".to_string(),
105106
));
106107
}
107-
self.credential.get_token(scopes, options).await
108+
self.credential
109+
.get_token(scopes, options)
110+
.await
111+
.map_err(authentication_error::<Self>)
108112
}
109113
}
110114

@@ -161,8 +165,11 @@ fn get_source(env: &Env) -> ManagedIdentitySource {
161165
#[cfg(test)]
162166
mod tests {
163167
use super::*;
164-
use crate::env::Env;
165-
use crate::tests::{LIVE_TEST_RESOURCE, LIVE_TEST_SCOPES};
168+
use crate::{
169+
env::Env,
170+
tests::{LIVE_TEST_RESOURCE, LIVE_TEST_SCOPES},
171+
TSG_LINK_ERROR_TEXT,
172+
};
166173
use azure_core::http::headers::Headers;
167174
use azure_core::http::{BufResponse, Method, Request, StatusCode, Transport, Url};
168175
use azure_core::time::OffsetDateTime;
@@ -423,6 +430,44 @@ mod tests {
423430
);
424431
}
425432

433+
#[tokio::test]
434+
async fn get_token_error() {
435+
let mock_client = MockHttpClient::new(|_| {
436+
async move {
437+
Ok(BufResponse::from_bytes(
438+
StatusCode::BadRequest,
439+
Headers::default(),
440+
Bytes::new(),
441+
))
442+
}
443+
.boxed()
444+
});
445+
let options = ManagedIdentityCredentialOptions {
446+
client_options: ClientOptions {
447+
transport: Some(Transport::new(Arc::new(mock_client))),
448+
..Default::default()
449+
},
450+
..Default::default()
451+
};
452+
let credential = ManagedIdentityCredential::new(Some(options)).expect("credential");
453+
let err = credential
454+
.get_token(LIVE_TEST_SCOPES, None)
455+
.await
456+
.expect_err("expected error");
457+
assert!(matches!(
458+
err.kind(),
459+
azure_core::error::ErrorKind::Credential
460+
));
461+
assert!(err
462+
.to_string()
463+
.contains("the requested identity has not been assigned to this resource"));
464+
assert!(
465+
err.to_string()
466+
.contains(&format!("{TSG_LINK_ERROR_TEXT}#managed-id")),
467+
"expected error to contain a link to the troubleshooting guide, got '{err}'",
468+
);
469+
}
470+
426471
async fn run_imds_live_test(id: Option<UserAssignedId>) -> azure_core::Result<()> {
427472
if std::env::var("IDENTITY_IMDS_AVAILABLE").is_err() {
428473
println!("Skipped: IMDS isn't available");

sdk/identity/azure_identity/src/workload_identity_credential.rs

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT License.
33

4-
use crate::env::Env;
4+
use crate::{authentication_error, env::Env};
55
use async_lock::{RwLock, RwLockUpgradableReadGuard};
66
use azure_core::{
77
credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions},
@@ -106,7 +106,10 @@ impl TokenCredential for WorkloadIdentityCredential {
106106
"no scopes specified",
107107
));
108108
}
109-
self.0.get_token(scopes, options).await
109+
self.0
110+
.get_token(scopes, options)
111+
.await
112+
.map_err(authentication_error::<Self>)
110113
}
111114
}
112115

@@ -188,6 +191,7 @@ mod tests {
188191
client_assertion_credential::tests::{is_valid_request, FAKE_ASSERTION},
189192
env::Env,
190193
tests::*,
194+
TSG_LINK_ERROR_TEXT,
191195
};
192196
use azure_core::{
193197
http::{
@@ -270,6 +274,58 @@ mod tests {
270274
assert!(token.expires_on > SystemTime::now());
271275
}
272276

277+
#[tokio::test]
278+
async fn get_token_error() {
279+
let temp_file = TempFile::new(FAKE_ASSERTION);
280+
let description = "invalid assertion";
281+
let mock = MockSts::new(
282+
vec![BufResponse::from_bytes(
283+
StatusCode::BadRequest,
284+
Headers::default(),
285+
Bytes::from(format!(
286+
r#"{{"error":"invalid_request","error_description":"{}"}}"#,
287+
description
288+
)),
289+
)],
290+
Some(Arc::new(is_valid_request(
291+
FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
292+
))),
293+
);
294+
let cred = WorkloadIdentityCredential::new(Some(WorkloadIdentityCredentialOptions {
295+
credential_options: ClientAssertionCredentialOptions {
296+
client_options: ClientOptions {
297+
transport: Some(Transport::new(Arc::new(mock))),
298+
..Default::default()
299+
},
300+
..Default::default()
301+
},
302+
env: Env::from(
303+
&[
304+
(AZURE_CLIENT_ID, FAKE_CLIENT_ID),
305+
(AZURE_TENANT_ID, FAKE_TENANT_ID),
306+
(AZURE_FEDERATED_TOKEN_FILE, temp_file.path.to_str().unwrap()),
307+
][..],
308+
),
309+
..Default::default()
310+
}))
311+
.expect("valid credential");
312+
313+
let err = cred
314+
.get_token(LIVE_TEST_SCOPES, None)
315+
.await
316+
.expect_err("expected error");
317+
assert!(matches!(
318+
err.kind(),
319+
azure_core::error::ErrorKind::Credential
320+
));
321+
assert!(err.to_string().contains(description));
322+
assert!(
323+
err.to_string()
324+
.contains(&format!("{TSG_LINK_ERROR_TEXT}#workload")),
325+
"expected error to contain a link to the troubleshooting guide, got '{err}'",
326+
);
327+
}
328+
273329
#[test]
274330
fn invalid_tenant_id() {
275331
let temp_file = TempFile::new(FAKE_ASSERTION);

0 commit comments

Comments
 (0)