Skip to content

Commit 7766d7c

Browse files
authored
Merge pull request #143 from AzureAD/acquire_token_silent_with_error
New method acquire_token_silent_with_error()
2 parents c15b929 + 23c2ed5 commit 7766d7c

File tree

2 files changed

+118
-6
lines changed

2 files changed

+118
-6
lines changed

msal/application.py

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -435,6 +435,41 @@ def acquire_token_silent(
435435
or by finding a valid refresh token from cache and then automatically
436436
use it to redeem a new access token.
437437
438+
This method will combine the cache empty and refresh error
439+
into one return value, `None`.
440+
If your app does not care about the exact token refresh error during
441+
token cache look-up, then this method is easier and recommended.
442+
443+
Internally, this method calls :func:`~acquire_token_silent_with_error`.
444+
445+
:return:
446+
- A dict containing no "error" key,
447+
and typically contains an "access_token" key,
448+
if cache lookup succeeded.
449+
- None when cache lookup does not yield a token.
450+
"""
451+
result = self.acquire_token_silent_with_error(
452+
scopes, account, authority, force_refresh, **kwargs)
453+
return result if result and "error" not in result else None
454+
455+
def acquire_token_silent_with_error(
456+
self,
457+
scopes, # type: List[str]
458+
account, # type: Optional[Account]
459+
authority=None, # See get_authorization_request_url()
460+
force_refresh=False, # type: Optional[boolean]
461+
**kwargs):
462+
"""Acquire an access token for given account, without user interaction.
463+
464+
It is done either by finding a valid access token from cache,
465+
or by finding a valid refresh token from cache and then automatically
466+
use it to redeem a new access token.
467+
468+
This method will differentiate cache empty from token refresh error.
469+
If your app cares the exact token refresh error during
470+
token cache look-up, then this method is suitable.
471+
Otherwise, the other method :func:`~acquire_token_silent` is recommended.
472+
438473
:param list[str] scopes: (Required)
439474
Scopes requested to access a protected API (a resource).
440475
:param account:
@@ -444,8 +479,11 @@ def acquire_token_silent(
444479
If True, it will skip Access Token look-up,
445480
and try to find a Refresh Token to obtain a new Access Token.
446481
:return:
447-
- A dict containing "access_token" key, when cache lookup succeeds.
448-
- None when cache lookup does not yield anything.
482+
- A dict containing no "error" key,
483+
and typically contains an "access_token" key,
484+
if cache lookup succeeded.
485+
- None when there is simply no token in the cache.
486+
- A dict containing an "error" key, when token refresh failed.
449487
"""
450488
assert isinstance(scopes, list), "Invalid parameter type"
451489
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
@@ -460,8 +498,9 @@ def acquire_token_silent(
460498
scopes, account, self.authority, force_refresh=force_refresh,
461499
correlation_id=correlation_id,
462500
**kwargs)
463-
if result:
501+
if result and "error" not in result:
464502
return result
503+
final_result = result
465504
for alias in self._get_authority_aliases(self.authority.instance):
466505
the_authority = Authority(
467506
"https://" + alias + "/" + self.authority.tenant,
@@ -472,7 +511,18 @@ def acquire_token_silent(
472511
correlation_id=correlation_id,
473512
**kwargs)
474513
if result:
475-
return result
514+
if "error" not in result:
515+
return result
516+
final_result = result
517+
if final_result and final_result.get("suberror"):
518+
final_result["classification"] = { # Suppress these suberrors, per #57
519+
"bad_token": "",
520+
"token_expired": "",
521+
"protection_policy_required": "",
522+
"client_mismatch": "",
523+
"device_authentication_failed": "",
524+
}.get(final_result["suberror"], final_result["suberror"])
525+
return final_result
476526

477527
def _acquire_token_silent_from_cache_and_possibly_refresh_it(
478528
self,
@@ -533,13 +583,13 @@ def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
533583
# https://msazure.visualstudio.com/One/_git/ESTS-Docs/pullrequest/1138595
534584
"client_mismatch" in response.get("error_additional_info", []),
535585
**kwargs)
536-
if at:
586+
if at and "error" not in at:
537587
return at
538588
if app_metadata.get("family_id"): # Meaning this app belongs to this family
539589
at = self._acquire_token_silent_by_finding_specific_refresh_token(
540590
authority, scopes, dict(query, family_id=app_metadata["family_id"]),
541591
**kwargs)
542-
if at:
592+
if at and "error" not in at:
543593
return at
544594
# Either this app is an orphan, so we will naturally use its own RT;
545595
# or all attempts above have failed, so we fall back to non-foci behavior.
@@ -562,6 +612,8 @@ def _acquire_token_silent_by_finding_specific_refresh_token(
562612
query=query)
563613
logger.debug("Found %d RTs matching %s", len(matches), query)
564614
client = self._build_client(self.client_credential, authority)
615+
616+
response = None # A distinguishable value to mean cache is empty
565617
for entry in matches:
566618
logger.debug("Cache attempts an RT")
567619
response = client.obtain_token_by_refresh_token(
@@ -582,6 +634,7 @@ def _acquire_token_silent_by_finding_specific_refresh_token(
582634
))
583635
if break_condition(response):
584636
break
637+
return response # Returns the latest error (if any), or just None
585638

586639
def _validate_ssh_cert_input_data(self, data):
587640
if data.get("token_type") == "ssh-cert":

tests/test_application.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,65 @@ def test_extract_multiple_tag_enclosed_certs(self):
4646
self.assertEqual(["my_cert1", "my_cert2"], extract_certs(pem))
4747

4848

49+
class TestClientApplicationAcquireTokenSilentErrorBehaviors(unittest.TestCase):
50+
51+
def setUp(self):
52+
self.authority_url = "https://login.microsoftonline.com/common"
53+
self.authority = msal.authority.Authority(self.authority_url)
54+
self.scopes = ["s1", "s2"]
55+
self.uid = "my_uid"
56+
self.utid = "my_utid"
57+
self.account = {"home_account_id": "{}.{}".format(self.uid, self.utid)}
58+
self.rt = "this is a rt"
59+
self.cache = msal.SerializableTokenCache()
60+
self.client_id = "my_app"
61+
self.cache.add({ # Pre-populate the cache
62+
"client_id": self.client_id,
63+
"scope": self.scopes,
64+
"token_endpoint": "{}/oauth2/v2.0/token".format(self.authority_url),
65+
"response": TokenCacheTestCase.build_response(
66+
access_token="an expired AT to trigger refresh", expires_in=-99,
67+
uid=self.uid, utid=self.utid, refresh_token=self.rt),
68+
}) # The add(...) helper populates correct home_account_id for future searching
69+
self.app = ClientApplication(
70+
self.client_id, authority=self.authority_url, token_cache=self.cache)
71+
72+
def test_cache_empty_will_be_returned_as_None(self):
73+
self.assertEqual(
74+
None, self.app.acquire_token_silent(['cache_miss'], self.account))
75+
self.assertEqual(
76+
None, self.app.acquire_token_silent_with_error(['cache_miss'], self.account))
77+
78+
def test_acquire_token_silent_will_suppress_error(self):
79+
error_response = {"error": "invalid_grant", "suberror": "xyz"}
80+
def tester(url, **kwargs):
81+
return Mock(status_code=400, json=Mock(return_value=error_response))
82+
self.assertEqual(None, self.app.acquire_token_silent(
83+
self.scopes, self.account, post=tester))
84+
85+
def test_acquire_token_silent_with_error_will_return_error(self):
86+
error_response = {"error": "invalid_grant", "error_description": "xyz"}
87+
def tester(url, **kwargs):
88+
return Mock(status_code=400, json=Mock(return_value=error_response))
89+
self.assertEqual(error_response, self.app.acquire_token_silent_with_error(
90+
self.scopes, self.account, post=tester))
91+
92+
def test_atswe_will_map_some_suberror_to_classification_as_is(self):
93+
error_response = {"error": "invalid_grant", "suberror": "basic_action"}
94+
def tester(url, **kwargs):
95+
return Mock(status_code=400, json=Mock(return_value=error_response))
96+
result = self.app.acquire_token_silent_with_error(
97+
self.scopes, self.account, post=tester)
98+
self.assertEqual("basic_action", result.get("classification"))
99+
100+
def test_atswe_will_map_some_suberror_to_classification_to_empty_string(self):
101+
error_response = {"error": "invalid_grant", "suberror": "client_mismatch"}
102+
def tester(url, **kwargs):
103+
return Mock(status_code=400, json=Mock(return_value=error_response))
104+
result = self.app.acquire_token_silent_with_error(
105+
self.scopes, self.account, post=tester)
106+
self.assertEqual("", result.get("classification"))
107+
49108
class TestClientApplicationAcquireTokenSilentFociBehaviors(unittest.TestCase):
50109

51110
def setUp(self):

0 commit comments

Comments
 (0)