Skip to content

Commit 3b9b6aa

Browse files
authored
Merge pull request #321 from AzureAD/release-1.10.0
MSAL Python 1.10.0
2 parents 72a7250 + 896fbed commit 3b9b6aa

16 files changed

+213
-90
lines changed

.github/workflows/python-package.yml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@ on:
88
pull_request:
99
branches: [ dev ]
1010

11+
# This guards against unknown PR until a community member vet it and label it.
12+
types: [ labeled ]
13+
1114
jobs:
1215
ci:
1316
env:

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,8 @@ src/build
4545

4646
# Virtual Environments
4747
/env*
48-
48+
.venv/
49+
docs/_build/
4950
# Visual Studio Files
5051
/.vs/*
5152
/tests/.vs/*

docs/conf.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
# add these directories to sys.path here. If the directory is relative to the
1313
# documentation root, use os.path.abspath to make it absolute, like shown here.
1414
#
15+
from datetime import date
1516
import os
1617
import sys
1718
sys.path.insert(0, os.path.abspath('..'))
@@ -20,7 +21,7 @@
2021
# -- Project information -----------------------------------------------------
2122

2223
project = u'MSAL Python'
23-
copyright = u'2018, Microsoft'
24+
copyright = u'{0}, Microsoft'.format(date.today().year)
2425
author = u'Microsoft'
2526

2627
# The short X.Y version
@@ -77,13 +78,18 @@
7778
# a list of builtin themes.
7879
#
7980
# html_theme = 'alabaster'
80-
html_theme = 'sphinx_rtd_theme'
81+
html_theme = 'furo'
8182

8283
# Theme options are theme-specific and customize the look and feel of a theme
8384
# further. For a list of options available for each theme, see the
8485
# documentation.
8586
#
86-
# html_theme_options = {}
87+
html_theme_options = {
88+
"light_css_variables": {
89+
"font-stack": "'Segoe UI', SegoeUI, 'Helvetica Neue', Helvetica, Arial, sans-serif",
90+
"font-stack--monospace": "SFMono-Regular, Consolas, 'Liberation Mono', Menlo, Courier, monospace",
91+
},
92+
}
8793

8894
# Add any paths that contain custom static files (such as style sheets) here,
8995
# relative to this directory. They are copied after the builtin static files,
@@ -176,4 +182,4 @@
176182
epub_exclude_files = ['search.html']
177183

178184

179-
# -- Extension configuration -------------------------------------------------
185+
# -- Extension configuration -------------------------------------------------

docs/index.rst

Lines changed: 14 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
1-
.. MSAL Python documentation master file, created by
2-
sphinx-quickstart on Tue Dec 18 10:53:22 2018.
3-
You can adapt this file completely to your liking, but it should at least
4-
contain the root `toctree` directive.
5-
6-
.. This file is also inspired by
7-
https://pythonhosted.org/an_example_pypi_project/sphinx.html#full-code-example
8-
9-
Welcome to MSAL Python's documentation!
10-
=======================================
1+
MSAL Python documentation
2+
=========================
113

124
.. toctree::
135
:maxdepth: 2
146
:caption: Contents:
7+
:hidden:
8+
9+
MSAL Documentation <https://docs.microsoft.com/en-au/azure/active-directory/develop/msal-authentication-flows>
10+
GitHub Repository <https://github.com/AzureAD/microsoft-authentication-library-for-python>
1511

1612
You can find high level conceptual documentations in the project
1713
`README <https://github.com/AzureAD/microsoft-authentication-library-for-python>`_
@@ -22,9 +18,8 @@ and
2218

2319
The documentation hosted here is for API Reference.
2420

25-
26-
PublicClientApplication and ConfidentialClientApplication
27-
=========================================================
21+
API
22+
===
2823

2924
MSAL proposes a clean separation between
3025
`public client applications and confidential client applications
@@ -35,31 +30,22 @@ with different methods for different authentication scenarios.
3530

3631
PublicClientApplication
3732
-----------------------
33+
3834
.. autoclass:: msal.PublicClientApplication
3935
:members:
36+
:inherited-members:
4037

4138
ConfidentialClientApplication
4239
-----------------------------
43-
.. autoclass:: msal.ConfidentialClientApplication
44-
:members:
45-
4640

47-
Shared Methods
48-
--------------
49-
Both PublicClientApplication and ConfidentialClientApplication
50-
have following methods inherited from their base class.
51-
You typically do not need to initiate this base class, though.
52-
53-
.. autoclass:: msal.ClientApplication
41+
.. autoclass:: msal.ConfidentialClientApplication
5442
:members:
55-
56-
.. automethod:: __init__
57-
43+
:inherited-members:
5844

5945
TokenCache
60-
==========
46+
----------
6147

62-
One of the parameter accepted by
48+
One of the parameters accepted by
6349
both `PublicClientApplication` and `ConfidentialClientApplication`
6450
is the `TokenCache`.
6551

@@ -71,11 +57,3 @@ See `SerializableTokenCache` for example.
7157

7258
.. autoclass:: msal.SerializableTokenCache
7359
:members:
74-
75-
76-
Indices and tables
77-
==================
78-
79-
* :ref:`genindex`
80-
* :ref:`search`
81-

docs/requirements.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
furo
2+
-r ../requirements.txt

msal/application.py

Lines changed: 50 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121

2222

2323
# The __init__.py will import this. Not the other way around.
24-
__version__ = "1.9.0"
24+
__version__ = "1.10.0"
2525

2626
logger = logging.getLogger(__name__)
2727

@@ -100,6 +100,12 @@ def _str2bytes(raw):
100100
return raw
101101

102102

103+
def _clean_up(result):
104+
if isinstance(result, dict):
105+
result.pop("refresh_in", None) # MSAL handled refresh_in, customers need not
106+
return result
107+
108+
103109
class ClientApplication(object):
104110

105111
ACQUIRE_TOKEN_SILENT_ID = "84"
@@ -507,7 +513,7 @@ def authorize(): # A controller in a web app
507513
return redirect(url_for("index"))
508514
"""
509515
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
510-
return self.client.obtain_token_by_auth_code_flow(
516+
return _clean_up(self.client.obtain_token_by_auth_code_flow(
511517
auth_code_flow,
512518
auth_response,
513519
scope=decorate_scope(scopes, self.client_id) if scopes else None,
@@ -521,7 +527,7 @@ def authorize(): # A controller in a web app
521527
claims=_merge_claims_challenge_and_capabilities(
522528
self._client_capabilities,
523529
auth_code_flow.pop("claims_challenge", None))),
524-
**kwargs)
530+
**kwargs))
525531

526532
def acquire_token_by_authorization_code(
527533
self,
@@ -580,7 +586,7 @@ def acquire_token_by_authorization_code(
580586
"Change your acquire_token_by_authorization_code() "
581587
"to acquire_token_by_auth_code_flow()", DeprecationWarning)
582588
with warnings.catch_warnings(record=True):
583-
return self.client.obtain_token_by_authorization_code(
589+
return _clean_up(self.client.obtain_token_by_authorization_code(
584590
code, redirect_uri=redirect_uri,
585591
scope=decorate_scope(scopes, self.client_id),
586592
headers={
@@ -593,7 +599,7 @@ def acquire_token_by_authorization_code(
593599
claims=_merge_claims_challenge_and_capabilities(
594600
self._client_capabilities, claims_challenge)),
595601
nonce=nonce,
596-
**kwargs)
602+
**kwargs))
597603

598604
def get_accounts(self, username=None):
599605
"""Get a list of accounts which previously signed in, i.e. exists in cache.
@@ -822,6 +828,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
822828
force_refresh=False, # type: Optional[boolean]
823829
claims_challenge=None,
824830
**kwargs):
831+
access_token_from_cache = None
825832
if not (force_refresh or claims_challenge): # Bypass AT when desired or using claims
826833
query={
827834
"client_id": self.client_id,
@@ -839,17 +846,27 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
839846
now = time.time()
840847
for entry in matches:
841848
expires_in = int(entry["expires_on"]) - now
842-
if expires_in < 5*60:
849+
if expires_in < 5*60: # Then consider it expired
843850
continue # Removal is not necessary, it will be overwritten
844851
logger.debug("Cache hit an AT")
845-
return { # Mimic a real response
852+
access_token_from_cache = { # Mimic a real response
846853
"access_token": entry["secret"],
847854
"token_type": entry.get("token_type", "Bearer"),
848855
"expires_in": int(expires_in), # OAuth2 specs defines it as int
849856
}
850-
return self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
857+
if "refresh_on" in entry and int(entry["refresh_on"]) < now: # aging
858+
break # With a fallback in hand, we break here to go refresh
859+
return access_token_from_cache # It is still good as new
860+
try:
861+
result = self._acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
851862
authority, decorate_scope(scopes, self.client_id), account,
852863
force_refresh=force_refresh, claims_challenge=claims_challenge, **kwargs)
864+
result = _clean_up(result)
865+
if (result and "error" not in result) or (not access_token_from_cache):
866+
return result
867+
except: # The exact HTTP exception is transportation-layer dependent
868+
logger.exception("Refresh token failed") # Potential AAD outage?
869+
return access_token_from_cache
853870

854871
def _acquire_token_silent_by_finding_rt_belongs_to_me_or_my_family(
855872
self, authority, scopes, account, **kwargs):
@@ -907,11 +924,17 @@ def _acquire_token_silent_by_finding_specific_refresh_token(
907924
client = self._build_client(self.client_credential, authority)
908925

909926
response = None # A distinguishable value to mean cache is empty
910-
for entry in matches:
927+
for entry in sorted( # Since unfit RTs would not be aggressively removed,
928+
# we start from newer RTs which are more likely fit.
929+
matches,
930+
key=lambda e: int(e.get("last_modification_time", "0")),
931+
reverse=True):
911932
logger.debug("Cache attempts an RT")
912933
response = client.obtain_token_by_refresh_token(
913934
entry, rt_getter=lambda token_item: token_item["secret"],
914-
on_removing_rt=rt_remover or self.token_cache.remove_rt,
935+
on_removing_rt=lambda rt_item: None, # Disable RT removal,
936+
# because an invalid_grant could be caused by new MFA policy,
937+
# the RT could still be useful for other MFA-less scope or tenant
915938
on_obtaining_tokens=lambda event: self.token_cache.add(dict(
916939
event,
917940
environment=authority.instance,
@@ -976,7 +999,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs):
976999
* A dict contains no "error" key means migration was successful.
9771000
"""
9781001
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
979-
return self.client.obtain_token_by_refresh_token(
1002+
return _clean_up(self.client.obtain_token_by_refresh_token(
9801003
refresh_token,
9811004
scope=decorate_scope(scopes, self.client_id),
9821005
headers={
@@ -987,7 +1010,7 @@ def acquire_token_by_refresh_token(self, refresh_token, scopes, **kwargs):
9871010
rt_getter=lambda rt: rt,
9881011
on_updating_rt=False,
9891012
on_removing_rt=lambda rt_item: None, # No OP
990-
**kwargs)
1013+
**kwargs))
9911014

9921015

9931016
class PublicClientApplication(ClientApplication): # browser app or mobile app
@@ -1013,6 +1036,9 @@ def acquire_token_interactive(
10131036
**kwargs):
10141037
"""Acquire token interactively i.e. via a local browser.
10151038
1039+
Prerequisite: In Azure Portal, configure the Redirect URI of your
1040+
"Mobile and Desktop application" as ``http://localhost``.
1041+
10161042
:param list scope:
10171043
It is a list of case-sensitive strings.
10181044
:param str prompt:
@@ -1061,7 +1087,7 @@ def acquire_token_interactive(
10611087
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
10621088
claims = _merge_claims_challenge_and_capabilities(
10631089
self._client_capabilities, claims_challenge)
1064-
return self.client.obtain_token_by_browser(
1090+
return _clean_up(self.client.obtain_token_by_browser(
10651091
scope=decorate_scope(scopes, self.client_id) if scopes else None,
10661092
extra_scope_to_consent=extra_scopes_to_consent,
10671093
redirect_uri="http://localhost:{port}".format(
@@ -1080,7 +1106,7 @@ def acquire_token_interactive(
10801106
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
10811107
self.ACQUIRE_TOKEN_INTERACTIVE),
10821108
},
1083-
**kwargs)
1109+
**kwargs))
10841110

10851111
def initiate_device_flow(self, scopes=None, **kwargs):
10861112
"""Initiate a Device Flow instance,
@@ -1123,7 +1149,7 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs):
11231149
- A successful response would contain "access_token" key,
11241150
- an error response would contain "error" and usually "error_description".
11251151
"""
1126-
return self.client.obtain_token_by_device_flow(
1152+
return _clean_up(self.client.obtain_token_by_device_flow(
11271153
flow,
11281154
data=dict(
11291155
kwargs.pop("data", {}),
@@ -1139,7 +1165,7 @@ def acquire_token_by_device_flow(self, flow, claims_challenge=None, **kwargs):
11391165
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
11401166
self.ACQUIRE_TOKEN_BY_DEVICE_FLOW_ID),
11411167
},
1142-
**kwargs)
1168+
**kwargs))
11431169

11441170
def acquire_token_by_username_password(
11451171
self, username, password, scopes, claims_challenge=None, **kwargs):
@@ -1177,15 +1203,15 @@ def acquire_token_by_username_password(
11771203
user_realm_result = self.authority.user_realm_discovery(
11781204
username, correlation_id=headers[CLIENT_REQUEST_ID])
11791205
if user_realm_result.get("account_type") == "Federated":
1180-
return self._acquire_token_by_username_password_federated(
1206+
return _clean_up(self._acquire_token_by_username_password_federated(
11811207
user_realm_result, username, password, scopes=scopes,
11821208
data=data,
1183-
headers=headers, **kwargs)
1184-
return self.client.obtain_token_by_username_password(
1209+
headers=headers, **kwargs))
1210+
return _clean_up(self.client.obtain_token_by_username_password(
11851211
username, password, scope=scopes,
11861212
headers=headers,
11871213
data=data,
1188-
**kwargs)
1214+
**kwargs))
11891215

11901216
def _acquire_token_by_username_password_federated(
11911217
self, user_realm_result, username, password, scopes=None, **kwargs):
@@ -1245,7 +1271,7 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
12451271
"""
12461272
# TBD: force_refresh behavior
12471273
self._validate_ssh_cert_input_data(kwargs.get("data", {}))
1248-
return self.client.obtain_token_for_client(
1274+
return _clean_up(self.client.obtain_token_for_client(
12491275
scope=scopes, # This grant flow requires no scope decoration
12501276
headers={
12511277
CLIENT_REQUEST_ID: _get_new_correlation_id(),
@@ -1256,7 +1282,7 @@ def acquire_token_for_client(self, scopes, claims_challenge=None, **kwargs):
12561282
kwargs.pop("data", {}),
12571283
claims=_merge_claims_challenge_and_capabilities(
12581284
self._client_capabilities, claims_challenge)),
1259-
**kwargs)
1285+
**kwargs))
12601286

12611287
def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=None, **kwargs):
12621288
"""Acquires token using on-behalf-of (OBO) flow.
@@ -1286,7 +1312,7 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No
12861312
"""
12871313
# The implementation is NOT based on Token Exchange
12881314
# https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16
1289-
return self.client.obtain_token_by_assertion( # bases on assertion RFC 7521
1315+
return _clean_up(self.client.obtain_token_by_assertion( # bases on assertion RFC 7521
12901316
user_assertion,
12911317
self.client.GRANT_TYPE_JWT, # IDTs and AAD ATs are all JWTs
12921318
scope=decorate_scope(scopes, self.client_id), # Decoration is used for:
@@ -1305,4 +1331,4 @@ def acquire_token_on_behalf_of(self, user_assertion, scopes, claims_challenge=No
13051331
CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
13061332
self.ACQUIRE_TOKEN_ON_BEHALF_OF_ID),
13071333
},
1308-
**kwargs)
1334+
**kwargs))

0 commit comments

Comments
 (0)