diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index 8950c15a..cf56cb2a 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -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 diff --git a/docs/index.rst b/docs/index.rst index 95b89b98..b376f52d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -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:: diff --git a/msal/application.py b/msal/application.py index 916f7170..e024252c 100644 --- a/msal/application.py +++ b/msal/application.py @@ -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" @@ -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) @@ -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 @@ -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), @@ -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. " diff --git a/msal/token_cache.py b/msal/token_cache.py index dc26e843..0259522f 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -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 = { diff --git a/setup.py b/setup.py index effc825c..73be693f 100644 --- a/setup.py +++ b/setup.py @@ -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', ], @@ -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'", ], }, ) diff --git a/tests/test_application.py b/tests/test_application.py index 804ccb82..b62f41d5 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -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"]) + diff --git a/tests/test_authority.py b/tests/test_authority.py index dd91afbb..ca0bc68f 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -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()) diff --git a/tests/test_e2e.py b/tests/test_e2e.py index cd3ee467..48ffe47a 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -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) @@ -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 @@ -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, @@ -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 @@ -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" @@ -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/" @@ -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): @@ -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, @@ -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. @@ -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: @@ -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( @@ -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( @@ -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: @@ -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"), @@ -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="b2clocal@msidlabb2c.onmicrosoft.com", + password=self.get_lab_user_secret("msidlabb2c"), + scope=config["scopes"], + ) + class WorldWideRegionalEndpointTestCase(LabBasedTestCase): region = "westus" @@ -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(