Repository: AzureAD/microsoft-authentication-library-for-python

Issue:
ClientApplication initializer sends HTTP requests
Constructing `ClientApplication` entails at least one HTTP request, for tenant discovery. This adds all the costs and failure modes associated with the network to constructing the class. In my own code I therefore defer constructing (subclasses of) `ClientApplication` until I want to authenticate. That's a feasible workaround but please consider taking a similar approach in msal, deferring discovery until a method requiring I/O is called.

Discussion:
abhidnya13: Tested the public api and added a few more changes 
abhidnya13: @rayluo summarized our offline conversation in the comment to give a context on the changes made! 

Patch:
diff --git a/msal/application.py b/msal/application.py
index 096ee24..85cd609 100644
--- a/msal/application.py
+++ b/msal/application.py
@@ -198,8 +198,9 @@ class ClientApplication(object):
                 authority or "https://login.microsoftonline.com/common/",
                 self.http_client, validate_authority=validate_authority)
             # Here the self.authority is not the same type as authority in input
+        self.client = None
         self.token_cache = token_cache or TokenCache()
-        self.client = self._build_client(client_credential, self.authority)
+        self._client_credential = client_credential
         self.authority_groups = None
 
     def _build_client(self, client_credential, authority):
@@ -248,6 +249,12 @@ class ClientApplication(object):
             on_removing_rt=self.token_cache.remove_rt,
             on_updating_rt=self.token_cache.update_rt)
 
+    def _get_client(self):
+        if not self.client:
+            self.authority.initialize()
+            self.client = self._build_client(self._client_credential, self.authority)
+        return self.client
+
     def get_authorization_request_url(
             self,
             scopes,  # type: list[str]
@@ -307,6 +314,7 @@ class ClientApplication(object):
             authority,
             self.http_client
             ) if authority else self.authority
+        the_authority.initialize()
 
         client = Client(
             {"authorization_endpoint": the_authority.authorization_endpoint},
@@ -367,7 +375,7 @@ class ClientApplication(object):
         # really empty.
         assert isinstance(scopes, list), "Invalid parameter type"
         self._validate_ssh_cert_input_data(kwargs.get("data", {}))
-        return self.client.obtain_token_by_authorization_code(
+        return self._get_client().obtain_token_by_authorization_code(
             code, redirect_uri=redirect_uri,
             scope=decorate_scope(scopes, self.client_id),
             headers={
@@ -391,6 +399,7 @@ class ClientApplication(object):
             Your app can choose to display those information to end user,
             and allow user to choose one of his/her accounts to proceed.
         """
+        self.authority.initialize()
         accounts = self._find_msal_accounts(environment=self.authority.instance)
         if not accounts:  # Now try other aliases of this authority instance
             for alias in self._get_authority_aliases(self.authority.instance):
@@ -543,6 +552,7 @@ class ClientApplication(object):
         #     authority,
         #     self.http_client,
         #     ) if authority else self.authority
+        self.authority.initialize()
         result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
             scopes, account, self.authority, force_refresh=force_refresh,
             correlation_id=correlation_id,
@@ -555,6 +565,7 @@ class ClientApplication(object):
                 "https://" + alias + "/" + self.authority.tenant,
                 self.http_client,
                 validate_authority=False)
+            the_authority.initialize()
             result = self._acquire_token_silent_from_cache_and_possibly_refresh_it(
                 scopes, account, the_authority, force_refresh=force_refresh,
                 correlation_id=correlation_id,
@@ -724,7 +735,7 @@ class ClientApplication(object):
             * A dict contains "error" and some other keys, when error happened.
             * A dict contains no "error" key means migration was successful.
         """
-        return self.client.obtain_token_by_refresh_token(
+        return self._get_client().obtain_token_by_refresh_token(
             refresh_token,
             decorate_scope(scopes, self.client_id),
             rt_getter=lambda rt: rt,
@@ -754,7 +765,7 @@ class PublicClientApplication(ClientApplication):  # browser app or mobile app
             - an error response would contain some other readable key/value pairs.
         """
         correlation_id = _get_new_correlation_id()
-        flow = self.client.initiate_device_flow(
+        flow = self._get_client().initiate_device_flow(
             scope=decorate_scope(scopes or [], self.client_id),
             headers={
                 CLIENT_REQUEST_ID: correlation_id,
@@ -778,7 +789,7 @@ class PublicClientApplication(ClientApplication):  # browser app or mobile app
             - A successful response would contain "access_token" key,
             - an error response would contain "error" and usually "error_description".
         """
-        return self.client.obtain_token_by_device_flow(
+        return self._get_client().obtain_token_by_device_flow(
             flow,
             data=dict(kwargs.pop("data", {}), code=flow["device_code"]),
                 # 2018-10-4 Hack:
@@ -815,6 +826,7 @@ class PublicClientApplication(ClientApplication):  # browser app or mobile app
             CLIENT_CURRENT_TELEMETRY: _build_current_telemetry_request_header(
                 self.ACQUIRE_TOKEN_BY_USERNAME_PASSWORD_ID),
             }
+        self.authority.initialize()
         if not self.authority.is_adfs:
             user_realm_result = self.authority.user_realm_discovery(
                 username, correlation_id=headers[CLIENT_REQUEST_ID])
@@ -822,7 +834,7 @@ class PublicClientApplication(ClientApplication):  # browser app or mobile app
                 return self._acquire_token_by_username_password_federated(
                     user_realm_result, username, password, scopes=scopes,
                     headers=headers, **kwargs)
-        return self.client.obtain_token_by_username_password(
+        return self._get_client().obtain_token_by_username_password(
                 username, password, scope=scopes,
                 headers=headers,
                 **kwargs)
@@ -851,16 +863,16 @@ class PublicClientApplication(ClientApplication):  # browser app or mobile app
         GRANT_TYPE_SAML1_1 = 'urn:ietf:params:oauth:grant-type:saml1_1-bearer'
         grant_type = {
             SAML_TOKEN_TYPE_V1: GRANT_TYPE_SAML1_1,
-            SAML_TOKEN_TYPE_V2: self.client.GRANT_TYPE_SAML2,
+            SAML_TOKEN_TYPE_V2: Client.GRANT_TYPE_SAML2,
             WSS_SAML_TOKEN_PROFILE_V1_1: GRANT_TYPE_SAML1_1,
-            WSS_SAML_TOKEN_PROFILE_V2: self.client.GRANT_TYPE_SAML2
+            WSS_SAML_TOKEN_PROFILE_V2: Client.GRANT_TYPE_SAML2
             }.get(wstrust_result.get("type"))
         if not grant_type:
             raise RuntimeError(
                 "RSTR returned unknown token type: %s", wstrust_result.get("type"))
-        self.client.grant_assertion_encoders.setdefault(  # Register a non-standard type
-            grant_type, self.client.encode_saml_assertion)
-        return self.client.obtain_token_by_assertion(
+        Client.grant_assertion_encoders.setdefault(  # Register a non-standard type
+            grant_type, Client.encode_saml_assertion)
+        return self._get_client().obtain_token_by_assertion(
             wstrust_result["token"], grant_type, scope=scopes, **kwargs)
 
 
@@ -878,7 +890,7 @@ class ConfidentialClientApplication(ClientApplication):  # server-side web app
             - an error response would contain "error" and usually "error_description".
         """
         # TBD: force_refresh behavior
-        return self.client.obtain_token_for_client(
+        return self._get_client().obtain_token_for_client(
             scope=scopes,  # This grant flow requires no scope decoration
             headers={
                 CLIENT_REQUEST_ID: _get_new_correlation_id(),
@@ -910,9 +922,9 @@ class ConfidentialClientApplication(ClientApplication):  # server-side web app
         """
         # The implementation is NOT based on Token Exchange
         # https://tools.ietf.org/html/draft-ietf-oauth-token-exchange-16
-        return self.client.obtain_token_by_assertion(  # bases on assertion RFC 7521
+        return self._get_client().obtain_token_by_assertion(  # bases on assertion RFC 7521
             user_assertion,
-            self.client.GRANT_TYPE_JWT,  # IDTs and AAD ATs are all JWTs
+            Client.GRANT_TYPE_JWT,  # IDTs and AAD ATs are all JWTs
             scope=decorate_scope(scopes, self.client_id),  # Decoration is used for:
                 # 1. Explicitly requesting an RT, without relying on AAD default
                 #    behavior, even though it currently still issues an RT.
diff --git a/msal/authority.py b/msal/authority.py
index e200299..edafbd3 100644
--- a/msal/authority.py
+++ b/msal/authority.py
@@ -52,6 +52,17 @@ class Authority(object):
             This parameter only controls whether an instance discovery will be
             performed.
         """
+        self._http_client = http_client
+        self._authority_url = authority_url
+        self._validate_authority = validate_authority
+        self._is_initialized = False
+
+    def initialize(self):
+        if not self._is_initialized:
+            self.__initialize(self._authority_url, self._http_client, self._validate_authority)
+            self._is_initialized = True
+
+    def __initialize(self, authority_url, http_client, validate_authority):
         self._http_client = http_client
         authority, self.instance, tenant = canonicalize(authority_url)
         parts = authority.path.split('/')
diff --git a/msal/oauth2cli/oauth2.py b/msal/oauth2cli/oauth2.py
index 3bf9339..55fa054 100644
--- a/msal/oauth2cli/oauth2.py
+++ b/msal/oauth2cli/oauth2.py
@@ -233,7 +233,7 @@ class BaseClient(object):
 
         :param refresh_token: The refresh token issued to the client
         :param scope: If omitted, is treated as equal to the scope originally
-            granted by the resource ownser,
+            granted by the resource owner,
             according to https://tools.ietf.org/html/rfc6749#section-6
         """
         assert isinstance(refresh_token, string_types)
@@ -397,7 +397,7 @@ class Client(BaseClient):  # We choose to implement all 4 grants in 1 class
 
     def obtain_token_by_authorization_code(
             self, code, redirect_uri=None, scope=None, **kwargs):
-        """Get a token via auhtorization code. a.k.a. Authorization Code Grant.
+        """Get a token via authorization code. a.k.a. Authorization Code Grant.
 
         This is typically used by a server-side app (Confidential Client),
         but it can also be used by a device-side native app (Public Client).
@@ -503,7 +503,7 @@ class Client(BaseClient):  # We choose to implement all 4 grants in 1 class
             Either way, this token_item will be passed into other callbacks as-is.
 
         :param scope: If omitted, is treated as equal to the scope originally
-            granted by the resource ownser,
+            granted by the resource owner,
             according to https://tools.ietf.org/html/rfc6749#section-6
         :param rt_getter: A callable to translate the token_item to a raw RT string
         :param on_removing_rt: If absent, fall back to the one defined in initialization
diff --git a/msal/oauth2cli/oidc.py b/msal/oauth2cli/oidc.py
index 33bbdb2..4586130 100644
--- a/msal/oauth2cli/oidc.py
+++ b/msal/oauth2cli/oidc.py
@@ -99,7 +99,7 @@ class Client(oauth2.Client):
             response_type, nonce=nonce, **kwargs)
 
     def obtain_token_by_authorization_code(self, code, nonce=None, **kwargs):
-        """Get a token via auhtorization code. a.k.a. Authorization Code Grant.
+        """Get a token via authorization code. a.k.a. Authorization Code Grant.
 
         Return value and all other parameters are the same as
         :func:`oauth2.Client.obtain_token_by_authorization_code`,


Test patch:
diff --git a/tests/test_application.py b/tests/test_application.py
index 65b36b3..57095bb 100644
--- a/tests/test_application.py
+++ b/tests/test_application.py
@@ -104,6 +104,7 @@ class TestClientApplicationAcquireTokenSilentFociBehaviors(unittest.TestCase):
         self.authority_url = "https://login.microsoftonline.com/common"
         self.authority = msal.authority.Authority(
             self.authority_url, MinimalHttpClient())
+        self.authority.initialize()
         self.scopes = ["s1", "s2"]
         self.uid = "my_uid"
         self.utid = "my_utid"
diff --git a/tests/test_authority.py b/tests/test_authority.py
index 15a0eb5..eae2c57 100644
--- a/tests/test_authority.py
+++ b/tests/test_authority.py
@@ -13,6 +13,7 @@ class TestAuthority(unittest.TestCase):
         for host in WELL_KNOWN_AUTHORITY_HOSTS:
             a = Authority(
                 'https://{}/common'.format(host), MinimalHttpClient())
+            a.initialize()
             self.assertEqual(
                 a.authorization_endpoint,
                 'https://%s/common/oauth2/v2.0/authorize' % host)
@@ -34,7 +35,7 @@ class TestAuthority(unittest.TestCase):
         _assert = getattr(self, "assertRaisesRegex", self.assertRaisesRegexp)  # Hack
         with _assert(ValueError, "invalid_instance"):
             Authority('https://example.com/tenant_doesnt_matter_in_this_case',
-                      MinimalHttpClient())
+                      MinimalHttpClient()).initialize()
 
     def test_invalid_host_skipping_validation_can_be_turned_off(self):
         try:
@@ -85,7 +86,7 @@ class TestAuthorityInternalHelperUserRealmDiscovery(unittest.TestCase):
         authority = "https://login.microsoftonline.com/common"
         self.assertNotIn(authority, Authority._domains_without_user_realm_discovery)
         a = Authority(authority, MinimalHttpClient(), validate_authority=False)
-
+        a.initialize()
         # We now pretend this authority supports no User Realm Discovery
         class MockResponse(object):
             status_code = 404
diff --git a/tests/test_authority_patch.py b/tests/test_authority_patch.py
index 1feca62..0a21164 100644
--- a/tests/test_authority_patch.py
+++ b/tests/test_authority_patch.py
@@ -15,6 +15,7 @@ class TestAuthorityHonorsPatchedRequests(unittest.TestCase):
         # First, we test that the original, unmodified authority is working
         a = msal.authority.Authority(
             "https://login.microsoftonline.com/common", MinimalHttpClient())
+        a.initialize()
         self.assertEqual(
             a.authorization_endpoint,
             'https://login.microsoftonline.com/common/oauth2/v2.0/authorize')
@@ -27,6 +28,7 @@ class TestAuthorityHonorsPatchedRequests(unittest.TestCase):
             with self.assertRaises(RuntimeError):
                 a = msal.authority.Authority(
                     "https://login.microsoftonline.com/common", MinimalHttpClient())
+                a.initialize()
         finally:  # Tricky:
             # Unpatch is necessary otherwise other test cases would be affected
             msal.authority.requests = original