Skip to content

Commit fd513f1

Browse files
authored
Add ClientCertificateCredential tests (Azure#3184)
1 parent e26ca03 commit fd513f1

File tree

5 files changed

+215
-17
lines changed

5 files changed

+215
-17
lines changed

.vscode/cspell.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"**/test-resources.json",
88
"**/test-resources-post.ps1",
99
"**/assets.json",
10+
"**/*.pfx",
1011
".config",
1112
".devcontainer/devcontainer.json",
1213
".devcontainer/Dockerfile",

sdk/identity/azure_identity/src/client_assertion_credential.rs

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ pub(crate) mod tests {
203203

204204
pub fn is_valid_request(
205205
expected_authority: String,
206+
expected_assertion: Option<String>,
206207
) -> impl Fn(&Request) -> azure_core::Result<()> {
207208
let expected_url = format!("{expected_authority}/oauth2/v2.0/token");
208209
move |req: &Request| {
@@ -212,20 +213,29 @@ pub(crate) mod tests {
212213
content_type::APPLICATION_X_WWW_FORM_URLENCODED.as_str(),
213214
req.headers().get_str(&headers::CONTENT_TYPE).unwrap()
214215
);
215-
let expected_params = [
216-
("client_assertion", FAKE_ASSERTION),
217-
("client_assertion_type", ASSERTION_TYPE),
218-
("client_id", FAKE_CLIENT_ID),
219-
("grant_type", "client_credentials"),
220-
("scope", &LIVE_TEST_SCOPES.join(" ")),
221-
];
222216
let body = match req.body() {
223217
Body::Bytes(bytes) => str::from_utf8(bytes).unwrap(),
224218
_ => panic!("unexpected body type"),
225219
};
226220
let actual_params: HashMap<String, String> = form_urlencoded::parse(body.as_bytes())
227221
.map(|(k, v)| (k.to_string(), v.to_string()))
228222
.collect();
223+
let assertion = actual_params
224+
.get("client_assertion")
225+
.expect("request body should contain client_assertion");
226+
match &expected_assertion {
227+
Some(expected) => assert_eq!(expected, assertion),
228+
None => assert!(
229+
!assertion.is_empty(),
230+
"expected client_assertion to be present"
231+
),
232+
}
233+
let expected_params = [
234+
("client_assertion_type", ASSERTION_TYPE),
235+
("client_id", FAKE_CLIENT_ID),
236+
("grant_type", "client_credentials"),
237+
("scope", &LIVE_TEST_SCOPES.join(" ")),
238+
];
229239
for (key, value) in expected_params.iter() {
230240
assert_eq!(
231241
*value,
@@ -263,6 +273,7 @@ pub(crate) mod tests {
263273
)],
264274
Some(Arc::new(is_valid_request(
265275
FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
276+
Some(FAKE_ASSERTION.to_string()),
266277
))),
267278
);
268279
let credential = ClientAssertionCredential::new(
@@ -297,6 +308,7 @@ pub(crate) mod tests {
297308
vec![token_response()],
298309
Some(Arc::new(is_valid_request(
299310
FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
311+
Some(FAKE_ASSERTION.to_string()),
300312
))),
301313
);
302314
let credential = ClientAssertionCredential::new(
@@ -335,7 +347,10 @@ pub(crate) mod tests {
335347
for (cloud, expected_authority) in cloud_configuration_cases() {
336348
let mock = MockSts::new(
337349
vec![token_response()],
338-
Some(Arc::new(is_valid_request(expected_authority))),
350+
Some(Arc::new(is_valid_request(
351+
expected_authority,
352+
Some(FAKE_ASSERTION.to_string()),
353+
))),
339354
);
340355
let credential = ClientAssertionCredential::new(
341356
FAKE_TENANT_ID.to_string(),

sdk/identity/azure_identity/src/client_certificate_credential.rs

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

4-
use crate::{authentication_error, get_authority_host, EntraIdTokenResponse, TokenCache};
4+
use crate::{
5+
authentication_error, deserialize, get_authority_host, EntraIdErrorResponse,
6+
EntraIdTokenResponse, TokenCache,
7+
};
58
use azure_core::{
69
base64,
710
credentials::{AccessToken, Secret, TokenCredential, TokenRequestOptions},
811
error::{Error, ErrorKind, ResultExt},
912
http::{
1013
headers::{self, content_type},
1114
request::Request,
12-
ClientOptions, Method, Pipeline, PipelineSendOptions, Url,
15+
ClientOptions, Method, Pipeline, PipelineSendOptions, StatusCode, Url,
1316
},
1417
time::{Duration, OffsetDateTime},
1518
Uuid,
@@ -128,7 +131,7 @@ impl ClientCertificateCredential {
128131
base64::encode_url_safe(part)
129132
}
130133

131-
async fn get_token(
134+
async fn get_token_impl(
132135
&self,
133136
scopes: &[&str],
134137
options: Option<TokenRequestOptions<'_>>,
@@ -245,11 +248,27 @@ impl ClientCertificateCredential {
245248
}),
246249
)
247250
.await?;
248-
let response: EntraIdTokenResponse = rsp.into_body().json()?;
249-
Ok(AccessToken::new(
250-
response.access_token,
251-
OffsetDateTime::now_utc() + Duration::seconds(response.expires_in),
252-
))
251+
252+
match rsp.status() {
253+
StatusCode::Ok => {
254+
let response: EntraIdTokenResponse =
255+
deserialize(stringify!(ClientCertificateCredential), rsp)?;
256+
Ok(AccessToken::new(
257+
response.access_token,
258+
OffsetDateTime::now_utc() + Duration::seconds(response.expires_in),
259+
))
260+
}
261+
_ => {
262+
let error_response: EntraIdErrorResponse =
263+
deserialize(stringify!(ClientCertificateCredential), rsp)?;
264+
let message = if error_response.error_description.is_empty() {
265+
"authentication failed".to_string()
266+
} else {
267+
error_response.error_description.clone()
268+
};
269+
Err(Error::with_message(ErrorKind::Credential, message))
270+
}
271+
}
253272
}
254273
}
255274

@@ -273,8 +292,168 @@ impl TokenCredential for ClientCertificateCredential {
273292
options: Option<TokenRequestOptions<'_>>,
274293
) -> azure_core::Result<AccessToken> {
275294
self.cache
276-
.get_token(scopes, options, |s, o| self.get_token(s, o))
295+
.get_token(scopes, options, |s, o| self.get_token_impl(s, o))
277296
.await
278297
.map_err(authentication_error::<Self>)
279298
}
280299
}
300+
301+
#[cfg(test)]
302+
mod tests {
303+
use super::*;
304+
use crate::{
305+
client_assertion_credential::tests::is_valid_request, tests::*, TSG_LINK_ERROR_TEXT,
306+
};
307+
use azure_core::{
308+
http::{headers::Headers, BufResponse, StatusCode, Transport},
309+
Bytes,
310+
};
311+
use std::sync::{Arc, LazyLock};
312+
313+
static TEST_CERT: LazyLock<String> = LazyLock::new(|| {
314+
let pfx = std::fs::read(concat!(
315+
env!("CARGO_MANIFEST_DIR"),
316+
"/tests/certificate.pfx"
317+
))
318+
.expect("failed to read test certificate");
319+
base64::encode(pfx)
320+
});
321+
322+
#[tokio::test]
323+
async fn cloud_configuration() {
324+
for (cloud, expected_authority) in cloud_configuration_cases() {
325+
let sts = MockSts::new(
326+
vec![token_response()],
327+
Some(Arc::new(is_valid_request(expected_authority, None))),
328+
);
329+
let credential = ClientCertificateCredential::new(
330+
FAKE_TENANT_ID.to_string(),
331+
FAKE_CLIENT_ID.to_string(),
332+
Secret::new(TEST_CERT.to_string()),
333+
Secret::new(""),
334+
Some(ClientCertificateCredentialOptions {
335+
client_options: ClientOptions {
336+
transport: Some(Transport::new(Arc::new(sts))),
337+
cloud: Some(Arc::new(cloud)),
338+
..Default::default()
339+
},
340+
..Default::default()
341+
}),
342+
)
343+
.expect("valid credential");
344+
345+
credential
346+
.get_token(LIVE_TEST_SCOPES, None)
347+
.await
348+
.expect("token");
349+
}
350+
}
351+
352+
#[tokio::test]
353+
async fn get_token_error() {
354+
let description = "AADSTS7000215: Invalid client certificate.";
355+
let sts = MockSts::new(
356+
vec![BufResponse::from_bytes(
357+
StatusCode::BadRequest,
358+
Headers::default(),
359+
Bytes::from(format!(
360+
r#"{{"error":"invalid_client","error_description":"{description}","error_codes":[7000215],"timestamp":"2025-04-04 21:10:04Z","trace_id":"...","correlation_id":"...","error_uri":"https://login.microsoftonline.com/error?code=7000215"}}"#,
361+
)),
362+
)],
363+
Some(Arc::new(is_valid_request(
364+
FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
365+
None,
366+
))),
367+
);
368+
let credential = ClientCertificateCredential::new(
369+
FAKE_TENANT_ID.to_string(),
370+
FAKE_CLIENT_ID.to_string(),
371+
TEST_CERT.to_string(),
372+
Secret::new(""),
373+
Some(ClientCertificateCredentialOptions {
374+
client_options: ClientOptions {
375+
transport: Some(Transport::new(Arc::new(sts))),
376+
..Default::default()
377+
},
378+
..Default::default()
379+
}),
380+
)
381+
.expect("valid credential");
382+
383+
let err = credential
384+
.get_token(LIVE_TEST_SCOPES, None)
385+
.await
386+
.expect_err("expected error");
387+
assert!(matches!(err.kind(), ErrorKind::Credential));
388+
assert!(
389+
err.to_string().contains(description),
390+
"expected error description from the response, got '{}'",
391+
err
392+
);
393+
assert!(
394+
err.to_string()
395+
.contains(&format!("{TSG_LINK_ERROR_TEXT}#client-cert")),
396+
"expected error to contain a link to the troubleshooting guide, got '{err}'",
397+
);
398+
}
399+
400+
#[tokio::test]
401+
async fn get_token_success() {
402+
let sts = MockSts::new(
403+
vec![token_response()],
404+
Some(Arc::new(is_valid_request(
405+
FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
406+
None,
407+
))),
408+
);
409+
let credential = ClientCertificateCredential::new(
410+
FAKE_TENANT_ID.to_string(),
411+
FAKE_CLIENT_ID.to_string(),
412+
TEST_CERT.to_string(),
413+
Secret::new(""),
414+
Some(ClientCertificateCredentialOptions {
415+
client_options: ClientOptions {
416+
transport: Some(Transport::new(Arc::new(sts))),
417+
..Default::default()
418+
},
419+
..Default::default()
420+
}),
421+
)
422+
.expect("valid credential");
423+
let token = credential
424+
.get_token(LIVE_TEST_SCOPES, None)
425+
.await
426+
.expect("token");
427+
428+
assert_eq!(FAKE_TOKEN, token.token.secret());
429+
let lifetime =
430+
token.expires_on.unix_timestamp() - OffsetDateTime::now_utc().unix_timestamp();
431+
assert!(
432+
(3600..3601).contains(&lifetime),
433+
"token should expire in ~3600 seconds but actually expires in {} seconds",
434+
lifetime
435+
);
436+
437+
let cached_token = credential
438+
.get_token(LIVE_TEST_SCOPES, None)
439+
.await
440+
.expect("cached token");
441+
assert_eq!(token.token.secret(), cached_token.token.secret());
442+
assert_eq!(token.expires_on, cached_token.expires_on);
443+
}
444+
445+
#[tokio::test]
446+
async fn no_scopes() {
447+
ClientCertificateCredential::new(
448+
FAKE_TENANT_ID.to_string(),
449+
FAKE_CLIENT_ID.to_string(),
450+
TEST_CERT.to_string(),
451+
Secret::new(""),
452+
None,
453+
)
454+
.expect("valid credential")
455+
.get_token(&[], None)
456+
.await
457+
.expect_err("no scopes provided");
458+
}
459+
}

sdk/identity/azure_identity/src/workload_identity_credential.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ mod tests {
248248
)],
249249
Some(Arc::new(is_valid_request(
250250
FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
251+
Some(FAKE_ASSERTION.to_string()),
251252
))),
252253
);
253254
let cred = WorkloadIdentityCredential::new(Some(WorkloadIdentityCredentialOptions {
@@ -289,6 +290,7 @@ mod tests {
289290
)],
290291
Some(Arc::new(is_valid_request(
291292
FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
293+
Some(FAKE_ASSERTION.to_string()),
292294
))),
293295
);
294296
let cred = WorkloadIdentityCredential::new(Some(WorkloadIdentityCredentialOptions {
@@ -402,6 +404,7 @@ mod tests {
402404
)],
403405
Some(Arc::new(is_valid_request(
404406
FAKE_PUBLIC_CLOUD_AUTHORITY.to_string(),
407+
Some(FAKE_ASSERTION.to_string()),
405408
))),
406409
);
407410
let cred = WorkloadIdentityCredential::new(Some(WorkloadIdentityCredentialOptions {
2.57 KB
Binary file not shown.

0 commit comments

Comments
 (0)