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+ } ;
58use 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+ }
0 commit comments