From 70e09fbccb54d793e11ae0d6c5d4749c7d0c1a16 Mon Sep 17 00:00:00 2001 From: Ray Luo Date: Mon, 26 Feb 2024 23:49:30 -0800 Subject: [PATCH] Implements a new optional oidc_authority parameter --- msal/application.py | 15 +++++++++- msal/authority.py | 66 +++++++++++++++++++++++++++++------------ tests/test_authority.py | 31 +++++++++++++++++++ 3 files changed, 92 insertions(+), 20 deletions(-) diff --git a/msal/application.py b/msal/application.py index 55a23512..d6256635 100644 --- a/msal/application.py +++ b/msal/application.py @@ -204,6 +204,7 @@ def __init__( instance_discovery=None, allow_broker=None, enable_pii_log=None, + oidc_authority=None, ): """Create an instance of application. @@ -455,6 +456,15 @@ def __init__( The default behavior is False. New in version 1.24.0. + + :param str oidc_authority: + *Added in version 1.28.0*: + It is a URL that identifies an OpenID Connect (OIDC) authority of + the format ``https://contoso.com/tenant``. + MSAL will append ".well-known/openid-configuration" to the authority + and retrieve the OIDC metadata from there, to figure out the endpoints. + + Note: Broker will NOT be used for OIDC authority. """ self.client_id = client_id self.client_credential = client_credential @@ -499,6 +509,8 @@ def __init__( self.app_version = app_version # Here the self.authority will not be the same type as authority in input + if oidc_authority and authority: + raise ValueError("You can not provide both authority and oidc_authority") try: authority_to_use = authority or "https://{}/common/".format(WORLD_WIDE) self.authority = Authority( @@ -506,11 +518,12 @@ def __init__( self.http_client, validate_authority=validate_authority, instance_discovery=self._instance_discovery, + oidc_authority_url=oidc_authority, ) except ValueError: # Those are explicit authority validation errors raise except Exception: # The rest are typically connection errors - if validate_authority and azure_region: + if validate_authority and azure_region and not oidc_authority: # Since caller opts in to use region, here we tolerate connection # errors happened during authority validation at non-region endpoint self.authority = Authority( diff --git a/msal/authority.py b/msal/authority.py index 5e0131f3..de19f963 100644 --- a/msal/authority.py +++ b/msal/authority.py @@ -56,6 +56,7 @@ def __init__( self, authority_url, http_client, validate_authority=True, instance_discovery=None, + oidc_authority_url=None, ): """Creates an authority instance, and also validates it. @@ -65,12 +66,56 @@ def __init__( This parameter only controls whether an instance discovery will be performed. """ + self._http_client = http_client + if oidc_authority_url: + logger.info("Initializing with OIDC authority: %s", oidc_authority_url) + tenant_discovery_endpoint = self._initialize_oidc_authority( + oidc_authority_url) + else: + logger.info("Initializing with Entra authority: %s", authority_url) + tenant_discovery_endpoint = self._initialize_entra_authority( + authority_url, validate_authority, instance_discovery) + try: + openid_config = tenant_discovery( + tenant_discovery_endpoint, + self._http_client) + except ValueError: + error_message = ( + "Unable to get OIDC authority configuration for {url} " + "because its OIDC Discovery endpoint is unavailable at " + "{url}/.well-known/openid-configuration ".format(url=oidc_authority_url) + if oidc_authority_url else + "Unable to get authority configuration for {}. " + "Authority would typically be in a format of " + "https://login.microsoftonline.com/your_tenant " + "or https://tenant_name.ciamlogin.com " + "or https://tenant_name.b2clogin.com/tenant.onmicrosoft.com/policy. " + .format(authority_url) + ) + " Also please double check your tenant name or GUID is correct." + raise ValueError(error_message) + logger.debug( + 'openid_config("%s") = %s', tenant_discovery_endpoint, openid_config) + self.authorization_endpoint = openid_config['authorization_endpoint'] + self.token_endpoint = openid_config['token_endpoint'] + self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint') + _, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID + + def _initialize_oidc_authority(self, oidc_authority_url): + authority, self.instance, tenant = canonicalize(oidc_authority_url) + self.is_adfs = tenant.lower() == 'adfs' # As a convention + self._is_b2c = True # Not exactly true, but + # OIDC Authority was designed for CIAM which is the next gen of B2C. + # Besides, application.py uses this to bypass broker. + self._is_known_to_developer = True # Not really relevant, but application.py uses this to bypass authority validation + return oidc_authority_url + "/.well-known/openid-configuration" + + def _initialize_entra_authority( + self, authority_url, validate_authority, instance_discovery): # :param instance_discovery: # By default, the known-to-Microsoft validation will use an # instance discovery endpoint located at ``login.microsoftonline.com``. # You can customize the endpoint by providing a url as a string. # Or you can turn this behavior off by passing in a False here. - self._http_client = http_client if isinstance(authority_url, AuthorityBuilder): authority_url = str(authority_url) authority, self.instance, tenant = canonicalize(authority_url) @@ -111,24 +156,7 @@ def __init__( 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, - self._http_client) - except ValueError: - raise ValueError( - "Unable to get authority configuration for {}. " - "Authority would typically be in a format of " - "https://login.microsoftonline.com/your_tenant " - "or https://tenant_name.ciamlogin.com " - "or https://tenant_name.b2clogin.com/tenant.onmicrosoft.com/policy. " - "Also please double check your tenant name or GUID is correct.".format( - authority_url)) - logger.debug("openid_config = %s", openid_config) - self.authorization_endpoint = openid_config['authorization_endpoint'] - self.token_endpoint = openid_config['token_endpoint'] - self.device_authorization_endpoint = openid_config.get('device_authorization_endpoint') - _, _, self.tenant = canonicalize(self.token_endpoint) # Usually a GUID + return tenant_discovery_endpoint def user_realm_discovery(self, username, correlation_id=None, response=None): # It will typically return a dict containing "ver", "account_type", diff --git a/tests/test_authority.py b/tests/test_authority.py index 2ced23f8..0d6c790f 100644 --- a/tests/test_authority.py +++ b/tests/test_authority.py @@ -99,6 +99,37 @@ def test_authority_with_path_should_be_used_as_is(self, oidc_discovery): self.http_client) +@patch("msal.authority._instance_discovery") +@patch("msal.authority.tenant_discovery", return_value={ + "authorization_endpoint": "https://contoso.com/authorize", + "token_endpoint": "https://contoso.com/token", + }) +class TestOidcAuthority(unittest.TestCase): + def test_authority_obj_should_do_oidc_discovery_and_skip_instance_discovery( + self, oidc_discovery, instance_discovery): + c = MinimalHttpClient() + a = Authority(None, c, oidc_authority_url="https://contoso.com/tenant") + instance_discovery.assert_not_called() + oidc_discovery.assert_called_once_with( + "https://contoso.com/tenant/.well-known/openid-configuration", c) + self.assertEqual(a.authorization_endpoint, 'https://contoso.com/authorize') + self.assertEqual(a.token_endpoint, 'https://contoso.com/token') + + def test_application_obj_should_do_oidc_discovery_and_skip_instance_discovery( + self, oidc_discovery, instance_discovery): + app = msal.ClientApplication( + "id", + authority=None, + oidc_authority="https://contoso.com/tenant", + ) + instance_discovery.assert_not_called() + oidc_discovery.assert_called_once_with( + "https://contoso.com/tenant/.well-known/openid-configuration", + app.http_client) + self.assertEqual( + app.authority.authorization_endpoint, 'https://contoso.com/authorize') + self.assertEqual(app.authority.token_endpoint, 'https://contoso.com/token') + class TestAuthorityInternalHelperCanonicalize(unittest.TestCase): def test_canonicalize_tenant_followed_by_extra_paths(self):