diff --git a/README.md b/README.md index 9088b60a..3fab9682 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Not sure whether this is the SDK you are looking for your app? There are other M Quick links: -| [Getting Started](https://docs.microsoft.com/azure/active-directory/develop/quickstart-v2-python-webapp) | [Docs](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki) | [Samples](https://aka.ms/aaddevsamplesv2) | [Support](README.md#community-help-and-support) | [Feedback](https://forms.office.com/r/TMjZkDbzjY) | +| [Getting Started](https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python)| [Docs](https://github.com/AzureAD/microsoft-authentication-library-for-python/wiki) | [Samples](https://aka.ms/aaddevsamplesv2) | [Support](README.md#community-help-and-support) | [Feedback](https://forms.office.com/r/TMjZkDbzjY) | | --- | --- | --- | --- | --- | ## Scenarios supported diff --git a/docs/index.rst b/docs/index.rst index b376f52d..8f24a58d 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -15,7 +15,7 @@ You can find high level conceptual documentations in the project Scenarios ========= -There are many `different application scenarios `_. +There are many `different application scenarios `_. MSAL Python supports some of them. **The following diagram serves as a map. Locate your application scenario on the map.** **If the corresponding icon is clickable, it will bring you to an MSAL Python sample for that scenario.** @@ -24,15 +24,15 @@ MSAL Python supports some of them. .. raw:: html - + Web app + alt="Web app" title="Web app" href="https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python"> Web app + alt="Web app" title="Web app" href="https://learn.microsoft.com/azure/active-directory/develop/web-app-quickstart?pivots=devlang-python"> Desktop App diff --git a/msal/application.py b/msal/application.py index e024252c..b3e2c209 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.21.0" # When releasing, also check and bump our dependencies's versions if needed +__version__ = "1.22.0" # When releasing, also check and bump our dependencies's versions if needed logger = logging.getLogger(__name__) _AUTHORITY_TYPE_CLOUDSHELL = "CLOUDSHELL" @@ -1182,7 +1182,7 @@ def _acquire_token_by_cloud_shell(self, scopes, data=None): client_id=self.client_id, scope=response["scope"].split() if "scope" in response else scopes, token_endpoint=self.authority.token_endpoint, - response=response.copy(), + response=response, data=data or {}, authority_type=_AUTHORITY_TYPE_CLOUDSHELL, )) @@ -1399,7 +1399,7 @@ def _process_broker_response(self, response, scopes, data): client_id=self.client_id, scope=response["scope"].split() if "scope" in response else scopes, token_endpoint=self.authority.token_endpoint, - response=response.copy(), + response=response, data=data, _account_id=response["_account_id"], )) diff --git a/msal/authority.py b/msal/authority.py index 407ff7cc..6eb294f1 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -5,8 +5,6 @@ from urlparse import urlparse import logging -from .exceptions import MsalServiceError - logger = logging.getLogger(__name__) @@ -28,7 +26,9 @@ "b2clogin.cn", "b2clogin.us", "b2clogin.de", + "ciamlogin.com", ] +_CIAM_DOMAIN_SUFFIX = ".ciamlogin.com" class AuthorityBuilder(object): @@ -52,12 +52,6 @@ class Authority(object): """ _domains_without_user_realm_discovery = set([]) - @property - def http_client(self): # Obsolete. We will remove this eventually - warnings.warn( - "authority.http_client might be removed in MSAL Python 1.21+", DeprecationWarning) - return self._http_client - def __init__( self, authority_url, http_client, validate_authority=True, @@ -80,7 +74,8 @@ def __init__( if isinstance(authority_url, AuthorityBuilder): authority_url = str(authority_url) authority, self.instance, tenant = canonicalize(authority_url) - self.is_adfs = tenant.lower() == 'adfs' + is_ciam = self.instance.endswith(_CIAM_DOMAIN_SUFFIX) + self.is_adfs = tenant.lower() == 'adfs' and not is_ciam parts = authority.path.split('/') self._is_b2c = any( self.instance.endswith("." + d) for d in WELL_KNOWN_B2C_HOSTS @@ -109,13 +104,13 @@ def __init__( % authority_url) tenant_discovery_endpoint = payload['tenant_discovery_endpoint'] else: - tenant_discovery_endpoint = ( - 'https://{}:{}{}{}/.well-known/openid-configuration'.format( - self.instance, - 443 if authority.port is None else authority.port, - authority.path, # In B2C scenario, it is "/tenant/policy" - "" if tenant == "adfs" else "/v2.0" # the AAD v2 endpoint - )) + tenant_discovery_endpoint = authority._replace( + path="{prefix}{version}/.well-known/openid-configuration".format( + prefix=tenant if is_ciam and len(authority.path) <= 1 # Path-less CIAM + else authority.path, # In B2C, it is "/tenant/policy" + version="" if self.is_adfs else "/v2.0", + ) + ).geturl() # Keeping original port and query. Query is useful for test. try: openid_config = tenant_discovery( tenant_discovery_endpoint, @@ -150,18 +145,28 @@ def user_realm_discovery(self, username, correlation_id=None, response=None): return {} # This can guide the caller to fall back normal ROPC flow -def canonicalize(authority_url): +def canonicalize(authority_or_auth_endpoint): # Returns (url_parsed_result, hostname_in_lowercase, tenant) - authority = urlparse(authority_url) - parts = authority.path.split("/") - if authority.scheme != "https" or len(parts) < 2 or not parts[1]: - raise ValueError( - "Your given address (%s) should consist of " - "an https url with a minimum of one segment in a path: e.g. " - "https://login.microsoftonline.com/ " - "or https://.b2clogin.com/.onmicrosoft.com/policy" - % authority_url) - return authority, authority.hostname, parts[1] + authority = urlparse(authority_or_auth_endpoint) + if authority.scheme == "https": + parts = authority.path.split("/") + first_part = parts[1] if len(parts) >= 2 and parts[1] else None + if authority.hostname.endswith(_CIAM_DOMAIN_SUFFIX): # CIAM + # Use path in CIAM authority. It will be validated by OIDC Discovery soon + tenant = first_part if first_part else "{}.onmicrosoft.com".format( + # Fallback to sub domain name. This variation may not be advertised + authority.hostname.rsplit(_CIAM_DOMAIN_SUFFIX, 1)[0]) + return authority, authority.hostname, tenant + # AAD + if len(parts) >= 2 and parts[1]: + return authority, authority.hostname, parts[1] + raise ValueError( + "Your given address (%s) should consist of " + "an https url with a minimum of one segment in a path: e.g. " + "https://login.microsoftonline.com/ " + "or https://.ciamlogin.com/ " + "or https://.b2clogin.com/.onmicrosoft.com/policy" + % authority_or_auth_endpoint) def _instance_discovery(url, http_client, instance_discovery_endpoint, **kwargs): resp = http_client.get( @@ -174,16 +179,14 @@ def tenant_discovery(tenant_discovery_endpoint, http_client, **kwargs): # Returns Openid Configuration resp = http_client.get(tenant_discovery_endpoint, **kwargs) if resp.status_code == 200: - payload = json.loads(resp.text) # It could raise ValueError - if 'authorization_endpoint' in payload and 'token_endpoint' in payload: - return payload # Happy path - raise ValueError("OIDC Discovery does not provide enough information") + return json.loads(resp.text) # It could raise ValueError if 400 <= resp.status_code < 500: # Nonexist tenant would hit this path # e.g. https://login.microsoftonline.com/nonexist_tenant/v2.0/.well-known/openid-configuration - raise ValueError( - "OIDC Discovery endpoint rejects our request. Error: {}".format( - resp.text # Expose it as-is b/c OIDC defines no error response format + raise ValueError("OIDC Discovery failed on {}. HTTP status: {}, Error: {}".format( + tenant_discovery_endpoint, + resp.status_code, + resp.text, # Expose it as-is b/c OIDC defines no error response format )) # Transient network error would hit this path resp.raise_for_status() diff --git a/msal/token_cache.py b/msal/token_cache.py index 0259522f..4f6d225c 100644 --- a/msal/token_cache.py +++ b/msal/token_cache.py @@ -103,29 +103,30 @@ def find(self, credential_type, target=None, query=None): def add(self, event, now=None): # type: (dict) -> None - """Handle a token obtaining event, and add tokens into cache. - - Known side effects: This function modifies the input event in place. - """ - def wipe(dictionary, sensitive_fields): # Masks sensitive info - for sensitive in sensitive_fields: - if sensitive in dictionary: - dictionary[sensitive] = "********" - wipe(event.get("data", {}), - ("password", "client_secret", "refresh_token", "assertion")) - try: - return self.__add(event, now=now) - finally: - wipe(event.get("response", {}), ( # These claims were useful during __add() + """Handle a token obtaining event, and add tokens into cache.""" + def make_clean_copy(dictionary, sensitive_fields): # Masks sensitive info + return { + k: "********" if k in sensitive_fields else v + for k, v in dictionary.items() + } + clean_event = dict( + event, + data=make_clean_copy(event.get("data", {}), ( + "password", "client_secret", "refresh_token", "assertion", + )), + response=make_clean_copy(event.get("response", {}), ( "id_token_claims", # Provided by broker - "access_token", "refresh_token", "id_token", "username")) - wipe(event, ["username"]) # Needed for federated ROPC - logger.debug("event=%s", json.dumps( - # We examined and concluded that this log won't have Log Injection risk, - # because the event payload is already in JSON so CR/LF will be escaped. - event, indent=4, sort_keys=True, - default=str, # A workaround when assertion is in bytes in Python 3 - )) + "access_token", "refresh_token", "id_token", "username", + )), + ) + logger.debug("event=%s", json.dumps( + # We examined and concluded that this log won't have Log Injection risk, + # because the event payload is already in JSON so CR/LF will be escaped. + clean_event, + indent=4, sort_keys=True, + default=str, # assertion is in bytes in Python 3 + )) + return self.__add(event, now=now) def __parse_account(self, response, id_token_claims): """Return client_info and home_account_id""" diff --git a/setup.cfg b/setup.cfg index 7e543541..013719f3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,3 +4,6 @@ universal=1 [metadata] project_urls = Changelog = https://github.com/AzureAD/microsoft-authentication-library-for-python/releases + Documentation = https://msal-python.readthedocs.io/ + Questions = https://stackoverflow.com/questions/tagged/msal+python + Feature/Bug Tracker = https://github.com/AzureAD/microsoft-authentication-library-for-python/issues diff --git a/setup.py b/setup.py index 73be693f..721baa6d 100644 --- a/setup.py +++ b/setup.py @@ -77,7 +77,7 @@ 'requests>=2.0.0,<3', 'PyJWT[crypto]>=1.0.0,<3', # MSAL does not use jwt.decode(), therefore is insusceptible to CVE-2022-29217 so no need to bump to PyJWT 2.4+ - 'cryptography>=0.6,<41', + 'cryptography>=0.6,<43', # load_pem_private_key() is available since 0.6 # https://github.com/pyca/cryptography/blob/master/CHANGELOG.rst#06---2014-09-29 # diff --git a/tests/msaltest.py b/tests/msaltest.py index cc4e5606..b1556106 100644 --- a/tests/msaltest.py +++ b/tests/msaltest.py @@ -34,7 +34,7 @@ def _select_options( return raw_data def _input_scopes(): - return _select_options([ + scopes = _select_options([ "https://graph.microsoft.com/.default", "https://management.azure.com/.default", "User.Read", @@ -42,7 +42,10 @@ def _input_scopes(): ], header="Select a scope (multiple scopes can only be input by manually typing them, delimited by space):", accept_nonempty_string=True, - ).split() + ).split() # It also converts the input string(s) into a list + if "https://pas.windows.net/CheckMyAccess/Linux/.default" in scopes: + raise ValueError("SSH Cert scope shall be tested by its dedicated functions") + return scopes def _select_account(app): accounts = app.get_accounts() @@ -183,6 +186,8 @@ def main(): ], option_renderer=lambda f: f.__doc__, header="MSAL Python APIs:") try: func(app) + except ValueError as e: + logging.error("Invalid input: %s", e) except KeyboardInterrupt: # Useful for bailing out a stuck interactive flow print("Aborted") diff --git a/tests/test_authority.py b/tests/test_authority.py index ca0bc68f..2ced23f8 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -79,6 +79,26 @@ def test_invalid_host_skipping_validation_can_be_turned_off(self): pass # Those are expected for this unittest case +@patch("msal.authority.tenant_discovery", return_value={ + "authorization_endpoint": "https://contoso.com/placeholder", + "token_endpoint": "https://contoso.com/placeholder", + }) +class TestCiamAuthority(unittest.TestCase): + http_client = MinimalHttpClient() + + def test_path_less_authority_should_work(self, oidc_discovery): + Authority('https://contoso.ciamlogin.com', self.http_client) + oidc_discovery.assert_called_once_with( + "https://contoso.ciamlogin.com/contoso.onmicrosoft.com/v2.0/.well-known/openid-configuration", + self.http_client) + + def test_authority_with_path_should_be_used_as_is(self, oidc_discovery): + Authority('https://contoso.ciamlogin.com/anything', self.http_client) + oidc_discovery.assert_called_once_with( + "https://contoso.ciamlogin.com/anything/v2.0/.well-known/openid-configuration", + self.http_client) + + class TestAuthorityInternalHelperCanonicalize(unittest.TestCase): def test_canonicalize_tenant_followed_by_extra_paths(self): diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 48ffe47a..44c1d5f2 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -897,6 +897,57 @@ def test_b2c_allows_using_client_id_as_scope(self): ) +class CiamTestCase(LabBasedTestCase): + # Test cases below show you what scenarios need to be covered for CIAM. + # Detail test behaviors have already been implemented in preexisting helpers. + + @classmethod + def setUpClass(cls): + super(CiamTestCase, cls).setUpClass() + cls.user = cls.get_lab_user( + federationProvider="ciam", signinAudience="azureadmyorg", publicClient="No") + # FYI: Only single- or multi-tenant CIAM app can have other-than-OIDC + # delegated permissions on Microsoft Graph. + cls.app_config = cls.get_lab_app_object(cls.user["client_id"]) + + def test_ciam_acquire_token_interactive(self): + self._test_acquire_token_interactive( + authority=self.app_config["authority"], + client_id=self.app_config["appId"], + scope=self.app_config["scopes"], + username=self.user["username"], + lab_name=self.user["lab_name"], + ) + + def test_ciam_acquire_token_for_client(self): + self._test_acquire_token_by_client_secret( + client_id=self.app_config["appId"], + client_secret=self.get_lab_user_secret( + self.app_config["clientSecret"].split("=")[-1]), + authority=self.app_config["authority"], + scope=["{}/.default".format(self.app_config["appId"])], # App permission + ) + + def test_ciam_acquire_token_by_ropc(self): + # Somehow, this would only work after creating a secret for the test app + # and enabling "Allow public client flows". + # Otherwise it would hit AADSTS7000218. + self._test_username_password( + authority=self.app_config["authority"], + client_id=self.app_config["appId"], + username=self.user["username"], + password=self.get_lab_user_secret(self.user["lab_name"]), + scope=self.app_config["scopes"], + ) + + def test_ciam_device_flow(self): + self._test_device_flow( + authority=self.app_config["authority"], + client_id=self.app_config["appId"], + scope=self.app_config["scopes"], + ) + + class WorldWideRegionalEndpointTestCase(LabBasedTestCase): region = "westus" timeout = 2 # Short timeout makes this test case responsive on non-VM