Skip to content

Commit

Permalink
Merge pull request #531 from AzureAD/release-1.21.0
Browse files Browse the repository at this point in the history
MSAL Python 1.21.0, passed Azure Identity's smoke test
  • Loading branch information
rayluo authored Jan 31, 2023
2 parents 14cbf59 + b8ff2e4 commit 5782059
Show file tree
Hide file tree
Showing 8 changed files with 71 additions and 32 deletions.
4 changes: 2 additions & 2 deletions .github/workflows/python-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,10 @@ jobs:
LAB_OBO_PUBLIC_CLIENT_ID: ${{ secrets.LAB_OBO_PUBLIC_CLIENT_ID }}

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

steps:
- uses: actions/checkout@v2
Expand Down
10 changes: 10 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,16 @@ API
===

The following section is the API Reference of MSAL Python.
The API Reference is like a dictionary. You **read this API section when and only when**:

* You already followed our sample(s) above and have your app up and running,
but want to know more on how you could tweak the authentication experience
by using other optional parameters (there are plenty of them!)
* You read the MSAL Python source code and found a helper function that is useful to you,
then you would want to double check whether that helper is documented below.
Only documented APIs are considered part of the MSAL Python public API,
which are guaranteed to be backward-compatible in MSAL Python 1.x series.
Undocumented internal helpers are subject to change anytime, without prior notice.

.. note::

Expand Down
23 changes: 7 additions & 16 deletions msal/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@


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

logger = logging.getLogger(__name__)
_AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL"
Expand Down Expand Up @@ -588,18 +588,9 @@ def _decorate_scope(
raise ValueError(
"API does not accept {} value as user-provided scopes".format(
reserved_scope))
if self.client_id in scope_set:
if len(scope_set) > 1:
# We make developers pass their client id, so that they can express
# the intent that they want the token for themselves (their own
# app).
# If we do not restrict them to passing only client id then they
# could write code where they expect an id token but end up getting
# access_token.
raise ValueError("Client Id can only be provided as a single scope")
decorated = set(reserved_scope) # Make a writable copy
else:
decorated = scope_set | reserved_scope

# client_id can also be used as a scope in B2C
decorated = scope_set | reserved_scope
decorated -= self._exclude_scopes
return list(decorated)

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

if self._enable_broker and account is not None and data.get("token_type") != "ssh-cert":
if self._enable_broker and account is not None:
from .broker import _acquire_token_silently
response = _acquire_token_silently(
"https://{}/{}".format(self.authority.instance, self.authority.tenant),
Expand Down Expand Up @@ -1799,7 +1790,7 @@ def acquire_token_interactive(
return self._acquire_token_by_cloud_shell(scopes, data=data)
claims = _merge_claims_challenge_and_capabilities(
self._client_capabilities, claims_challenge)
if self._enable_broker and data.get("token_type") != "ssh-cert":
if self._enable_broker:
if parent_window_handle is None:
raise ValueError(
"parent_window_handle is required when you opted into using broker. "
Expand Down
5 changes: 4 additions & 1 deletion msal/token_cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,11 @@ def __add(self, event, now=None):
now = int(time.time() if now is None else now)

if access_token:
default_expires_in = ( # https://www.rfc-editor.org/rfc/rfc6749#section-5.1
int(response.get("expires_on")) - now # Some Managed Identity emits this
) if response.get("expires_on") else 600
expires_in = int( # AADv1-like endpoint returns a string
response.get("expires_in", 3599))
response.get("expires_in", default_expires_in))
ext_expires_in = int( # AADv1-like endpoint returns a string
response.get("ext_expires_in", expires_in))
at = {
Expand Down
5 changes: 4 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
'Programming Language :: Python :: 3.8',
'Programming Language :: Python :: 3.9',
'Programming Language :: Python :: 3.10',
'Programming Language :: Python :: 3.11',
'License :: OSI Approved :: MIT License',
'Operating System :: OS Independent',
],
Expand Down Expand Up @@ -91,7 +92,9 @@
# The broker is defined as optional dependency,
# so that downstream apps can opt in. The opt-in is needed, partially because
# most existing MSAL Python apps do not have the redirect_uri needed by broker.
"pymsalruntime>=0.11.2,<0.14;python_version>='3.6' and platform_system=='Windows'",
# MSAL Python uses a subset of API from PyMsalRuntime 0.11.2+,
# 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)
"pymsalruntime>=0.13.2,<0.14;python_version>='3.6' and platform_system=='Windows'",
],
},
)
Expand Down
15 changes: 15 additions & 0 deletions tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -625,3 +625,18 @@ def test_organizations_authority_should_emit_warnning(self):
self._test_certain_authority_should_emit_warnning(
authority="https://login.microsoftonline.com/organizations")


class TestScopeDecoration(unittest.TestCase):
def _test_client_id_should_be_a_valid_scope(self, client_id, other_scopes):
# B2C needs this https://learn.microsoft.com/en-us/azure/active-directory-b2c/access-tokens#openid-connect-scopes
reserved_scope = ['openid', 'profile', 'offline_access']
scopes_to_use = [client_id] + other_scopes
self.assertEqual(
set(ClientApplication(client_id)._decorate_scope(scopes_to_use)),
set(scopes_to_use + reserved_scope),
"Scope decoration should return input scopes plus reserved scopes")

def test_client_id_should_be_a_valid_scope(self):
self._test_client_id_should_be_a_valid_scope("client_id", [])
self._test_client_id_should_be_a_valid_scope("client_id", ["foo"])

5 changes: 4 additions & 1 deletion tests/test_authority.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ def test_lessknown_host_will_return_a_set_of_v1_endpoints(self):
self.assertNotIn('v2.0', a.token_endpoint)

def test_unknown_host_wont_pass_instance_discovery(self):
_assert = getattr(self, "assertRaisesRegex", self.assertRaisesRegexp) # Hack
_assert = (
# Was Regexp, added alias Regex in Py 3.2, and Regexp will be gone in Py 3.12
getattr(self, "assertRaisesRegex", None) or
getattr(self, "assertRaisesRegexp", None))
with _assert(ValueError, "invalid_instance"):
Authority('https://example.com/tenant_doesnt_matter_in_this_case',
MinimalHttpClient())
Expand Down
36 changes: 25 additions & 11 deletions tests/test_e2e.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ def _test_username_password(self,
azure_region=azure_region, # Regional endpoint does not support ROPC.
# Here we just use it to test a regional app won't break ROPC.
client_credential=client_secret)
self.assertEqual(
self.app.get_accounts(username=username), [], "Cache starts empty")
result = self.app.acquire_token_by_username_password(
username, password, scopes=scope)
self.assertLoosely(result)
Expand All @@ -204,6 +206,9 @@ def _test_username_password(self,
username=username, # Our implementation works even when "profile" scope was not requested, or when profile claims is unavailable in B2C
)

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

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

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

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

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

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

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

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

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

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

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

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

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

def test_b2c_allows_using_client_id_as_scope(self):
# See also https://learn.microsoft.com/en-us/azure/active-directory-b2c/access-tokens#openid-connect-scopes
config = self.get_lab_app_object(azureenvironment="azureb2ccloud")
config["scopes"] = [config["appId"]]
self._test_username_password(
authority=self._build_b2c_authority("B2C_1_ROPC_Auth"),
client_id=config["appId"],
username="[email protected]",
password=self.get_lab_user_secret("msidlabb2c"),
scope=config["scopes"],
)


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

0 comments on commit 5782059

Please sign in to comment.