Skip to content

Commit 5782059

Browse files
authored
Merge pull request #531 from AzureAD/release-1.21.0
MSAL Python 1.21.0, passed Azure Identity's smoke test
2 parents 14cbf59 + b8ff2e4 commit 5782059

File tree

8 files changed

+71
-32
lines changed

8 files changed

+71
-32
lines changed

.github/workflows/python-package.yml

+2-2
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,10 @@ jobs:
2323
LAB_OBO_PUBLIC_CLIENT_ID: ${{ secrets.LAB_OBO_PUBLIC_CLIENT_ID }}
2424

2525
# Derived from https://docs.github.com/en/actions/guides/building-and-testing-python#starting-with-the-python-workflow-template
26-
runs-on: ubuntu-latest
26+
runs-on: ubuntu-latest # It switched to 22.04 shortly after 2022-Nov-8
2727
strategy:
2828
matrix:
29-
python-version: [2.7, 3.5, 3.6, 3.7, 3.8, 3.9, "3.10", "3.11-dev"]
29+
python-version: [2.7, 3.7, 3.8, 3.9, "3.10", "3.11", "3.12-dev"]
3030

3131
steps:
3232
- uses: actions/checkout@v2

docs/index.rst

+10
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,16 @@ API
6262
===
6363

6464
The following section is the API Reference of MSAL Python.
65+
The API Reference is like a dictionary. You **read this API section when and only when**:
66+
67+
* You already followed our sample(s) above and have your app up and running,
68+
but want to know more on how you could tweak the authentication experience
69+
by using other optional parameters (there are plenty of them!)
70+
* You read the MSAL Python source code and found a helper function that is useful to you,
71+
then you would want to double check whether that helper is documented below.
72+
Only documented APIs are considered part of the MSAL Python public API,
73+
which are guaranteed to be backward-compatible in MSAL Python 1.x series.
74+
Undocumented internal helpers are subject to change anytime, without prior notice.
6575

6676
.. note::
6777

msal/application.py

+7-16
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525

2626

2727
# The __init__.py will import this. Not the other way around.
28-
__version__ = "1.20.0" # When releasing, also check and bump our dependencies's versions if needed
28+
__version__ = "1.21.0" # When releasing, also check and bump our dependencies's versions if needed
2929

3030
logger = logging.getLogger(__name__)
3131
_AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL"
@@ -588,18 +588,9 @@ def _decorate_scope(
588588
raise ValueError(
589589
"API does not accept {} value as user-provided scopes".format(
590590
reserved_scope))
591-
if self.client_id in scope_set:
592-
if len(scope_set) > 1:
593-
# We make developers pass their client id, so that they can express
594-
# the intent that they want the token for themselves (their own
595-
# app).
596-
# If we do not restrict them to passing only client id then they
597-
# could write code where they expect an id token but end up getting
598-
# access_token.
599-
raise ValueError("Client Id can only be provided as a single scope")
600-
decorated = set(reserved_scope) # Make a writable copy
601-
else:
602-
decorated = scope_set | reserved_scope
591+
592+
# client_id can also be used as a scope in B2C
593+
decorated = scope_set | reserved_scope
603594
decorated -= self._exclude_scopes
604595
return list(decorated)
605596

@@ -622,7 +613,7 @@ def _get_regional_authority(self, central_authority):
622613
else self._region_configured) # It will retain the None i.e. opted out
623614
logger.debug('Region to be used: {}'.format(repr(region_to_use)))
624615
if region_to_use:
625-
regional_host = ("{}.r.login.microsoftonline.com".format(region_to_use)
616+
regional_host = ("{}.login.microsoft.com".format(region_to_use)
626617
if central_authority.instance in (
627618
# The list came from point 3 of the algorithm section in this internal doc
628619
# https://identitydivision.visualstudio.com/DevEx/_git/AuthLibrariesApiReview?path=/PinAuthToRegion/AAD%20SDK%20Proposal%20to%20Pin%20Auth%20to%20region.md&anchor=algorithm&_a=preview
@@ -1375,7 +1366,7 @@ def _acquire_token_silent_from_cache_and_possibly_refresh_it(
13751366
if account and account.get("authority_type") == _AUTHORITY_TYPE_CLOUDSHELL:
13761367
return self._acquire_token_by_cloud_shell(scopes, data=data)
13771368

1378-
if self._enable_broker and account is not None and data.get("token_type") != "ssh-cert":
1369+
if self._enable_broker and account is not None:
13791370
from .broker import _acquire_token_silently
13801371
response = _acquire_token_silently(
13811372
"https://{}/{}".format(self.authority.instance, self.authority.tenant),
@@ -1799,7 +1790,7 @@ def acquire_token_interactive(
17991790
return self._acquire_token_by_cloud_shell(scopes, data=data)
18001791
claims = _merge_claims_challenge_and_capabilities(
18011792
self._client_capabilities, claims_challenge)
1802-
if self._enable_broker and data.get("token_type") != "ssh-cert":
1793+
if self._enable_broker:
18031794
if parent_window_handle is None:
18041795
raise ValueError(
18051796
"parent_window_handle is required when you opted into using broker. "

msal/token_cache.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -164,8 +164,11 @@ def __add(self, event, now=None):
164164
now = int(time.time() if now is None else now)
165165

166166
if access_token:
167+
default_expires_in = ( # https://www.rfc-editor.org/rfc/rfc6749#section-5.1
168+
int(response.get("expires_on")) - now # Some Managed Identity emits this
169+
) if response.get("expires_on") else 600
167170
expires_in = int( # AADv1-like endpoint returns a string
168-
response.get("expires_in", 3599))
171+
response.get("expires_in", default_expires_in))
169172
ext_expires_in = int( # AADv1-like endpoint returns a string
170173
response.get("ext_expires_in", expires_in))
171174
at = {

setup.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
'Programming Language :: Python :: 3.8',
6565
'Programming Language :: Python :: 3.9',
6666
'Programming Language :: Python :: 3.10',
67+
'Programming Language :: Python :: 3.11',
6768
'License :: OSI Approved :: MIT License',
6869
'Operating System :: OS Independent',
6970
],
@@ -91,7 +92,9 @@
9192
# The broker is defined as optional dependency,
9293
# so that downstream apps can opt in. The opt-in is needed, partially because
9394
# most existing MSAL Python apps do not have the redirect_uri needed by broker.
94-
"pymsalruntime>=0.11.2,<0.14;python_version>='3.6' and platform_system=='Windows'",
95+
# MSAL Python uses a subset of API from PyMsalRuntime 0.11.2+,
96+
# but we still bump the lower bound to 0.13.2+ for its important bugfix (https://github.com/AzureAD/microsoft-authentication-library-for-cpp/pull/3244)
97+
"pymsalruntime>=0.13.2,<0.14;python_version>='3.6' and platform_system=='Windows'",
9598
],
9699
},
97100
)

tests/test_application.py

+15
Original file line numberDiff line numberDiff line change
@@ -625,3 +625,18 @@ def test_organizations_authority_should_emit_warnning(self):
625625
self._test_certain_authority_should_emit_warnning(
626626
authority="https://login.microsoftonline.com/organizations")
627627

628+
629+
class TestScopeDecoration(unittest.TestCase):
630+
def _test_client_id_should_be_a_valid_scope(self, client_id, other_scopes):
631+
# B2C needs this https://learn.microsoft.com/en-us/azure/active-directory-b2c/access-tokens#openid-connect-scopes
632+
reserved_scope = ['openid', 'profile', 'offline_access']
633+
scopes_to_use = [client_id] + other_scopes
634+
self.assertEqual(
635+
set(ClientApplication(client_id)._decorate_scope(scopes_to_use)),
636+
set(scopes_to_use + reserved_scope),
637+
"Scope decoration should return input scopes plus reserved scopes")
638+
639+
def test_client_id_should_be_a_valid_scope(self):
640+
self._test_client_id_should_be_a_valid_scope("client_id", [])
641+
self._test_client_id_should_be_a_valid_scope("client_id", ["foo"])
642+

tests/test_authority.py

+4-1
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,10 @@ def test_lessknown_host_will_return_a_set_of_v1_endpoints(self):
5959
self.assertNotIn('v2.0', a.token_endpoint)
6060

6161
def test_unknown_host_wont_pass_instance_discovery(self):
62-
_assert = getattr(self, "assertRaisesRegex", self.assertRaisesRegexp) # Hack
62+
_assert = (
63+
# Was Regexp, added alias Regex in Py 3.2, and Regexp will be gone in Py 3.12
64+
getattr(self, "assertRaisesRegex", None) or
65+
getattr(self, "assertRaisesRegexp", None))
6366
with _assert(ValueError, "invalid_instance"):
6467
Authority('https://example.com/tenant_doesnt_matter_in_this_case',
6568
MinimalHttpClient())

tests/test_e2e.py

+25-11
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ def _test_username_password(self,
196196
azure_region=azure_region, # Regional endpoint does not support ROPC.
197197
# Here we just use it to test a regional app won't break ROPC.
198198
client_credential=client_secret)
199+
self.assertEqual(
200+
self.app.get_accounts(username=username), [], "Cache starts empty")
199201
result = self.app.acquire_token_by_username_password(
200202
username, password, scopes=scope)
201203
self.assertLoosely(result)
@@ -204,6 +206,9 @@ def _test_username_password(self,
204206
username=username, # Our implementation works even when "profile" scope was not requested, or when profile claims is unavailable in B2C
205207
)
206208

209+
@unittest.skipIf(
210+
os.getenv("TRAVIS"), # It is set when running on TravisCI or Github Actions
211+
"Although it is doable, we still choose to skip device flow to save time")
207212
def _test_device_flow(
208213
self, client_id=None, authority=None, scope=None, **ignored):
209214
assert client_id and authority and scope
@@ -229,6 +234,7 @@ def _test_device_flow(
229234
logger.info(
230235
"%s obtained tokens: %s", self.id(), json.dumps(result, indent=4))
231236

237+
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
232238
def _test_acquire_token_interactive(
233239
self, client_id=None, authority=None, scope=None, port=None,
234240
username=None, lab_name=None,
@@ -289,7 +295,6 @@ def test_ssh_cert_for_service_principal(self):
289295
result.get("error"), result.get("error_description")))
290296
self.assertEqual("ssh-cert", result["token_type"])
291297

292-
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
293298
def test_ssh_cert_for_user_should_work_with_any_account(self):
294299
result = self._test_acquire_token_interactive(
295300
client_id="04b07795-8ddb-461a-bbee-02f9e1bf7b46", # Azure CLI is one
@@ -524,8 +529,8 @@ def tearDownClass(cls):
524529
cls.session.close()
525530

526531
@classmethod
527-
def get_lab_app_object(cls, **query): # https://msidlab.com/swagger/index.html
528-
url = "https://msidlab.com/api/app"
532+
def get_lab_app_object(cls, client_id=None, **query): # https://msidlab.com/swagger/index.html
533+
url = "https://msidlab.com/api/app/{}".format(client_id or "")
529534
resp = cls.session.get(url, params=query)
530535
result = resp.json()[0]
531536
result["scopes"] = [ # Raw data has extra space, such as "s1, s2"
@@ -546,6 +551,8 @@ def get_lab_user_secret(cls, lab_name="msidlab4"):
546551
def get_lab_user(cls, **query): # https://docs.msidlab.com/labapi/userapi.html
547552
resp = cls.session.get("https://msidlab.com/api/user", params=query)
548553
result = resp.json()[0]
554+
assert result.get("upn"), "Found no test user but {}".format(
555+
json.dumps(result, indent=2))
549556
_env = query.get("azureenvironment", "").lower()
550557
authority_base = {
551558
"azureusgovernment": "https://login.microsoftonline.us/"
@@ -561,6 +568,7 @@ def get_lab_user(cls, **query): # https://docs.msidlab.com/labapi/userapi.html
561568
"scope": scope,
562569
}
563570

571+
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
564572
def _test_acquire_token_by_auth_code(
565573
self, client_id=None, authority=None, port=None, scope=None,
566574
**ignored):
@@ -583,6 +591,7 @@ def _test_acquire_token_by_auth_code(
583591
error_description=result.get("error_description")))
584592
self.assertCacheWorksForUser(result, scope, username=None)
585593

594+
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
586595
def _test_acquire_token_by_auth_code_flow(
587596
self, client_id=None, authority=None, port=None, scope=None,
588597
username=None, lab_name=None,
@@ -723,11 +732,9 @@ def test_adfs2019_fed_user(self):
723732
self.skipTest("MEX endpoint in our test environment tends to fail")
724733
raise
725734

726-
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
727735
def test_cloud_acquire_token_interactive(self):
728736
self._test_acquire_token_interactive(**self.get_lab_user(usertype="cloud"))
729737

730-
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
731738
def test_msa_pt_app_signin_via_organizations_authority_without_login_hint(self):
732739
"""There is/was an upstream bug. See test case full docstring for the details.
733740
@@ -751,7 +758,6 @@ def test_ropc_adfs2019_onprem(self):
751758
config["password"] = self.get_lab_user_secret(config["lab_name"])
752759
self._test_username_password(**config)
753760

754-
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
755761
def test_adfs2019_onprem_acquire_token_by_auth_code(self):
756762
"""When prompted, you can manually login using this account:
757763
@@ -765,7 +771,6 @@ def test_adfs2019_onprem_acquire_token_by_auth_code(self):
765771
config["port"] = 8080
766772
self._test_acquire_token_by_auth_code(**config)
767773

768-
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
769774
def test_adfs2019_onprem_acquire_token_by_auth_code_flow(self):
770775
config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019")
771776
self._test_acquire_token_by_auth_code_flow(**dict(
@@ -775,7 +780,6 @@ def test_adfs2019_onprem_acquire_token_by_auth_code_flow(self):
775780
port=8080,
776781
))
777782

778-
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
779783
def test_adfs2019_onprem_acquire_token_interactive(self):
780784
config = self.get_lab_user(usertype="onprem", federationProvider="ADFSv2019")
781785
self._test_acquire_token_interactive(**dict(
@@ -846,7 +850,6 @@ def _build_b2c_authority(self, policy):
846850
base = "https://msidlabb2c.b2clogin.com/msidlabb2c.onmicrosoft.com"
847851
return base + "/" + policy # We do not support base + "?p=" + policy
848852

849-
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
850853
def test_b2c_acquire_token_by_auth_code(self):
851854
"""
852855
When prompted, you can manually login using this account:
@@ -863,7 +866,6 @@ def test_b2c_acquire_token_by_auth_code(self):
863866
scope=config["scopes"],
864867
)
865868

866-
@unittest.skipIf(os.getenv("TRAVIS"), "Browser automation is not yet implemented")
867869
def test_b2c_acquire_token_by_auth_code_flow(self):
868870
self._test_acquire_token_by_auth_code_flow(**dict(
869871
self.get_lab_user(usertype="b2c", b2cprovider="local"),
@@ -882,6 +884,18 @@ def test_b2c_acquire_token_by_ropc(self):
882884
scope=config["scopes"],
883885
)
884886

887+
def test_b2c_allows_using_client_id_as_scope(self):
888+
# See also https://learn.microsoft.com/en-us/azure/active-directory-b2c/access-tokens#openid-connect-scopes
889+
config = self.get_lab_app_object(azureenvironment="azureb2ccloud")
890+
config["scopes"] = [config["appId"]]
891+
self._test_username_password(
892+
authority=self._build_b2c_authority("B2C_1_ROPC_Auth"),
893+
client_id=config["appId"],
894+
username="b2clocal@msidlabb2c.onmicrosoft.com",
895+
password=self.get_lab_user_secret("msidlabb2c"),
896+
scope=config["scopes"],
897+
)
898+
885899

886900
class WorldWideRegionalEndpointTestCase(LabBasedTestCase):
887901
region = "westus"
@@ -904,7 +918,7 @@ def _test_acquire_token_for_client(self, configured_region, expected_region):
904918
self.app.http_client, "post", return_value=MinimalResponse(
905919
status_code=400, text='{"error": "mock"}')) as mocked_method:
906920
self.app.acquire_token_for_client(scopes)
907-
expected_host = '{}.r.login.microsoftonline.com'.format(
921+
expected_host = '{}.login.microsoft.com'.format(
908922
expected_region) if expected_region else 'login.microsoftonline.com'
909923
mocked_method.assert_called_with(
910924
'https://{}/{}/oauth2/v2.0/token'.format(

0 commit comments

Comments
 (0)