Skip to content
Open
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
2 changes: 2 additions & 0 deletions sdk/identity/azure_identity/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

### Features Added

- A `get_token()` error motivated by an HTTP response carries that response. See the [troubleshooting guide](https://aka.ms/azsdk/rust/identity/troubleshoot#find-relevant-information-in-errors) for example code showing how to access the response.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"motivated". Perhaps "caused"? Even if that's an arcane but valid use - which I normally appreciate - I imagine users might be scratching their heads as well.


### Breaking Changes

- `ClientCertificateCredential::new()`:
Expand Down
33 changes: 33 additions & 0 deletions sdk/identity/azure_identity/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,39 @@ This error contains several pieces of information:

- __Correlation ID and Timestamp__: The correlation ID and timestamp identify the request in server-side logs. This information can be useful to support engineers diagnosing unexpected Microsoft Entra ID failures.

Many credential errors also carry the HTTP response that motivated them. The example below demonstrates how to access that response in such a case.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
Many credential errors also carry the HTTP response that motivated them. The example below demonstrates how to access that response in such a case.
Many credential errors also carry the HTTP response that caused them. The example below demonstrates how to access that response in such a case.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

But they still get the message you want by default, right? This is just to get more details for...why? You might put that in the comments and even explain in what scenarios they may want to drill into details. Ideally, the message you formatted above is what they see if they just print the error e.g., eprintln!("{err}").

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes; printing the error produces a message like the above which probably contains everything developers need. The raw response is useful mainly because it includes headers. I'll refocus this example on that to avoid implying the response is more generally useful. Really I just want to publish this snippet so that the rare developer who wants the response has some guidance toward getting it

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the snippet is fine. I just wanted to make it clear that they probably don't need to, and maybe telling them for what reasons they might do so, like getting headers, like you said. You might just keep the code as-is, but explain a reason they might want to do this i.e., it shouldn't be the norm.


```rust
use azure_core::error::ErrorKind;

let result = client.method().await;
if let Err(err) = result {
match err.kind() {
// ErrorKind::Credential indicates an authentication problem
ErrorKind::Credential => {
// a credential error may wrap another error having an HTTP response
if let Some(inner) = err.downcast_ref::<azure_core::Error>() {
if let ErrorKind::HttpResponse {
raw_response: Some(response),
status,
..
} = inner.kind()
{
let headers = response.headers();
let body = String::from_utf8_lossy(response.body());
eprintln!("status: {status}");
eprintln!("headers: {headers:?}");
eprintln!("body: {body}");
}
}
}
_ => {
// TODO: handle other kinds of error
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tip: idiomatic thing here is todo!("handle other kinds of errors").

}
}
}
```

<a id="client-secret"></a>
## Troubleshoot ClientSecretCredential authentication issues

Expand Down
91 changes: 53 additions & 38 deletions sdk/identity/azure_identity/src/azure_pipelines_credential.rs
Original file line number Diff line number Diff line change
Expand Up @@ -163,16 +163,20 @@ impl ClientAssertion for Client {
}),
)
.await?;
if resp.status() != StatusCode::Ok {
let status_code = resp.status();
let status = resp.status();
if status != StatusCode::Ok {
let err_headers: ErrorHeaders = resp.headers().get()?;

return Err(
azure_core::Error::with_message(
ErrorKind::HttpResponse { status: status_code, error_code: Some(status_code.canonical_reason().to_string()), raw_response: None },
format!("{status_code} response from the OIDC endpoint. Check service connection ID and pipeline configuration. {err_headers}"),
)
let message = format!(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm assuming this is because status and err_headers are references to resp and thus you can't call format! in the with_message call, right?

I had to spend a couple of seconds to figure that out so it might be worth a comment.

"{status} response from the OIDC endpoint. Check service connection ID and pipeline configuration. {err_headers}"
);
return Err(azure_core::Error::with_message(
ErrorKind::HttpResponse {
status,
error_code: Some(status.canonical_reason().to_string()),
raw_response: Some(Box::new(resp)),
},
message,
));
}

let assertion: Assertion = resp.into_body().json()?;
Expand Down Expand Up @@ -226,9 +230,9 @@ impl fmt::Display for ErrorHeaders {
#[cfg(test)]
mod tests {
use super::*;
use crate::{env::Env, TSG_LINK_ERROR_TEXT};
use crate::env::Env;
use azure_core::{
http::{AsyncRawResponse, ClientOptions, Transport},
http::{AsyncRawResponse, ClientOptions, RawResponse, Transport},
Bytes,
};
use azure_core_test::http::MockHttpClient;
Expand All @@ -254,24 +258,25 @@ mod tests {
}

#[tokio::test]
async fn error_headers() {
let mock_client = MockHttpClient::new(|req| {
async fn error_response() {
let expected_status = StatusCode::Forbidden;
let body = Bytes::from_static(b"content");
let mut headers = Headers::new();
headers.insert(MSEDGE_REF, "foo");
headers.insert(VSS_E2EID, "bar");
let expected_response =
RawResponse::from_bytes(expected_status, headers.clone(), body.clone());
let headers_for_mock = headers.clone();
let body_for_mock = body.clone();
let mock_client = MockHttpClient::new(move |req| {
assert_eq!(
req.url().as_str(),
"http://localhost/get_token?api-version=7.1&serviceConnectionId=c"
);
let mut headers = Headers::new();
headers.insert(MSEDGE_REF, "foo");
headers.insert(VSS_E2EID, "bar");
let headers = headers_for_mock.clone();
let body = body_for_mock.clone();

async move {
Ok(AsyncRawResponse::from_bytes(
StatusCode::Forbidden,
headers,
Vec::new(),
))
}
.boxed()
async move { Ok(AsyncRawResponse::from_bytes(expected_status, headers, body)) }.boxed()
});
let options = AzurePipelinesCredentialOptions {
credential_options: ClientAssertionCredentialOptions {
Expand All @@ -285,25 +290,35 @@ mod tests {
&[(OIDC_VARIABLE_NAME, "http://localhost/get_token")][..],
)),
};
let credential =
AzurePipelinesCredential::new("a".into(), "b".into(), "c", "d", Some(options))
.expect("valid AzurePipelinesCredential");
let err = credential
let err = AzurePipelinesCredential::new("a".into(), "b".into(), "c", "d", Some(options))
.expect("credential")
.get_token(&["default"], None)
.await
.expect_err("expected error");
assert!(matches!(
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}'",

assert!(matches!(err.kind(), ErrorKind::Credential));
assert_eq!(
r#"AzurePipelinesCredential authentication failed. 403 response from the OIDC endpoint. Check service connection ID and pipeline configuration. Headers { x-msedge-ref: "foo", x-vss-e2eid: "bar" }
To troubleshoot, visit https://aka.ms/azsdk/rust/identity/troubleshoot#apc"#,
err.to_string(),
);
match err
.downcast_ref::<azure_core::Error>()
.expect("returned error should wrap an azure_core::Error")
.kind()
{
ErrorKind::HttpResponse {
error_code: Some(reason),
raw_response: Some(response),
status,
..
} => {
assert_eq!(status.canonical_reason(), reason.as_str());
assert_eq!(&expected_response, response.as_ref());
assert_eq!(expected_status, *status);
}
err => panic!("unexpected {:?}", err),
};
}

#[tokio::test]
Expand Down
85 changes: 37 additions & 48 deletions sdk/identity/azure_identity/src/client_assertion_credential.rs
Original file line number Diff line number Diff line change
@@ -1,26 +1,19 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

use crate::{
deserialize, get_authority_host, validate_not_empty, validate_tenant_id, EntraIdErrorResponse,
EntraIdTokenResponse, TokenCache,
};
use crate::{get_authority_host, validate_not_empty, validate_tenant_id, TokenCache};
use azure_core::{
credentials::{AccessToken, TokenCredential, TokenRequestOptions},
error::{ErrorKind, ResultExt},
http::{
headers::{self, content_type},
ClientMethodOptions, ClientOptions, Method, Pipeline, PipelineSendOptions, Request,
StatusCode, Url,
ClientMethodOptions, ClientOptions, Method, Pipeline, PipelineSendOptions, Request, Url,
},
time::{Duration, OffsetDateTime},
Error,
};
use std::{fmt::Debug, str, sync::Arc};
use url::form_urlencoded;

const ASSERTION_TYPE: &str = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer";
const CLIENT_ASSERTION_CREDENTIAL: &str = "ClientAssertionCredential";

/// Enables authentication of a Microsoft Entra service principal using a signed client assertion.
#[derive(Debug)]
Expand Down Expand Up @@ -144,29 +137,7 @@ impl<C: ClientAssertion> ClientAssertionCredential<C> {
)
.await?;

match res.status() {
StatusCode::Ok => {
let token_response: EntraIdTokenResponse =
deserialize(CLIENT_ASSERTION_CREDENTIAL, res)?;
Ok(AccessToken::new(
token_response.access_token,
OffsetDateTime::now_utc() + Duration::seconds(token_response.expires_in),
))
}
_ => {
let error_response: EntraIdErrorResponse =
deserialize(CLIENT_ASSERTION_CREDENTIAL, res)?;
let message = if error_response.error_description.is_empty() {
format!("{} authentication failed.", CLIENT_ASSERTION_CREDENTIAL)
} else {
format!(
"{} authentication failed. {}",
CLIENT_ASSERTION_CREDENTIAL, error_response.error_description
)
};
Err(Error::with_message(ErrorKind::Credential, message))
}
}
crate::handle_entra_response(res)
}
}

Expand All @@ -181,6 +152,7 @@ impl<C: ClientAssertion> TokenCredential for ClientAssertionCredential<C> {
self.cache
.get_token(scopes, options, |s, o| self.get_token_impl(s, o))
.await
.map_err(crate::authentication_error::<ClientAssertionCredential<C>>)
}
}

Expand All @@ -191,7 +163,7 @@ pub(crate) mod tests {
use azure_core::{
http::{
headers::{self, content_type, Headers},
AsyncRawResponse, Body, Method, Request, Transport,
AsyncRawResponse, Body, Method, RawResponse, Request, StatusCode, Transport,
},
Bytes,
};
Expand Down Expand Up @@ -261,16 +233,18 @@ pub(crate) mod tests {

#[tokio::test]
async fn get_token_error() {
let expected = "error description from the response";
let body = Bytes::from(
r#"{"error":"invalid_request","error_description":"error description from the response","error_codes":[50027],"timestamp":"2025-04-18 16:04:37Z","trace_id":"...","correlation_id":"...","error_uri":"https://login.microsoftonline.com/error?code=50027"}"#,
);
let mut headers = Headers::default();
headers.insert("key", "value");
let expected_status = StatusCode::BadRequest;
let expected_response =
RawResponse::from_bytes(expected_status, headers.clone(), body.clone());
let mock_response = AsyncRawResponse::from_bytes(expected_status, headers, body);

let mock = MockSts::new(
vec![AsyncRawResponse::from_bytes(
StatusCode::BadRequest,
Headers::default(),
Bytes::from(format!(
r#"{{"error":"invalid_request","error_description":"{}","error_codes":[50027],"timestamp":"2025-04-18 16:04:37Z","trace_id":"...","correlation_id":"...","error_uri":"https://login.microsoftonline.com/error?code=50027"}}"#,
expected
)),
)],
vec![mock_response],
Some(Arc::new(is_valid_request(
FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
Some(FAKE_ASSERTION.to_string()),
Expand All @@ -290,16 +264,31 @@ pub(crate) mod tests {
)
.expect("valid credential");

let error = credential
let err = credential
.get_token(LIVE_TEST_SCOPES, None)
.await
.expect_err("authentication error");
assert!(matches!(error.kind(), ErrorKind::Credential));
assert!(
error.to_string().contains(expected),
"expected error description from the response, got '{}'",
error
assert!(matches!(err.kind(), ErrorKind::Credential));
assert_eq!(
"ClientAssertionCredential authentication failed. error description from the response",
err.to_string(),
);
match err
.downcast_ref::<azure_core::Error>()
.expect("returned error should wrap an azure_core::Error")
.kind()
{
ErrorKind::HttpResponse {
error_code: Some(error_code),
raw_response: Some(response),
status,
} => {
assert_eq!("50027", error_code);
assert_eq!(&expected_response, response.as_ref());
assert_eq!(expected_status, *status);
}
err => panic!("unexpected {:?}", err),
};
}

#[tokio::test]
Expand Down
Loading