Repository: privacyidea/privacyidea

Issue:
oauth2client is deprecated, use google-auth
The `oauth2client` package is deprecated.
According to the [deprecation doc](https://google-auth.readthedocs.io/en/latest/oauth2client-deprecation.html) we should switch to `google-auth`.


Discussion:

Resolving patch:
diff --git a/privacyidea/lib/smsprovider/FirebaseProvider.py b/privacyidea/lib/smsprovider/FirebaseProvider.py
--- a/privacyidea/lib/smsprovider/FirebaseProvider.py
+++ b/privacyidea/lib/smsprovider/FirebaseProvider.py
@@ -28,11 +28,10 @@
 from privacyidea.lib.framework import get_app_local_store
 from privacyidea.lib import _
 import logging
-from oauth2client.service_account import ServiceAccountCredentials
-import requests
+from google.oauth2 import service_account
+from google.auth.transport.requests import AuthorizedSession
 import json
 import time
-import datetime
 
 FIREBASE_URL_SEND = 'https://fcm.googleapis.com/v1/projects/{0!s}/messages:send'
 SCOPES = ['https://www.googleapis.com/auth/cloud-platform',
@@ -45,44 +44,39 @@
 log = logging.getLogger(__name__)
 
 
-class AccessToken(object):
-
-    def __init__(self, access_token, validity):
-        self.access_token = access_token
-        self.expires_at = time.time() + validity - 10
-
-
 def get_firebase_access_token(config_file_name):
     """
     This returns the access token for a given JSON config file name
 
-    :param config_file_name:
-    :return:
+    :param config_file_name: The json file with the Service account credentials
+    :type config_file_name: str
+    :return: Firebase credentials
+    :rtype: google.oauth2.service_account.Credentials
     """
     fbt = "firebase_token"
-    now = time.time()
     app_store = get_app_local_store()
 
     if fbt not in app_store or not isinstance(app_store[fbt], dict):
         # initialize the firebase_token in the app_store as dict
         app_store[fbt] = {}
 
-    if not isinstance(app_store[fbt].get(config_file_name), AccessToken) or \
-            now > app_store[fbt].get(config_file_name).expires_at:
-        # If the type of the config is not class AccessToken or
-        # if the token has expired
-        credentials = ServiceAccountCredentials.from_json_keyfile_name(config_file_name, SCOPES)
+    if not isinstance(app_store[fbt].get(config_file_name), service_account.Credentials) or \
+            app_store[fbt].get(config_file_name).expired:
+        # If the type of the config is not of class Credentials or if the token
+        # has expired we get new scoped access token credentials
+        credentials = service_account.Credentials.from_service_account_file(config_file_name,
+                                                                            scopes=SCOPES)
+
         log.debug("Fetching a new access_token for {!r} from firebase...".format(config_file_name))
-        access_token_info = credentials.get_access_token()
-        # Now we set the expiration date for the new access_token with a margin of 10 seconds
-        At = AccessToken(access_token_info.access_token, access_token_info.expires_in)
         # We do not use a lock here: The worst that could happen is that two threads
-        # fetch new auth tokens concurrently. In this case, one of them wins and is written to the dictionary.
-        app_store[fbt][config_file_name] = At
-        readable_time = datetime.datetime.fromtimestamp(At.expires_at).isoformat()
-        log.debug(u"Setting the expiration for {!r} of the new access_token to {!s}.".format(config_file_name, readable_time))
+        # fetch new auth tokens concurrently. In this case, one of them wins and
+        # is written to the dictionary.
+        app_store[fbt][config_file_name] = credentials
+        readable_time = credentials.expiry.isoformat() if credentials.expiry else 'Never'
+        log.debug(u"Setting the expiration for {!r} of the new access_token "
+                  u"to {!s}.".format(config_file_name, readable_time))
 
-    return app_store[fbt][config_file_name].access_token
+    return app_store[fbt][config_file_name]
 
 
 class FIREBASE_CONFIG:
@@ -118,11 +112,12 @@ def submit_message(self, firebase_token, data):
         """
         res = False
 
-        bearer_token = get_firebase_access_token(self.smsgateway.option_dict.get(
+        credentials = get_firebase_access_token(self.smsgateway.option_dict.get(
             FIREBASE_CONFIG.JSON_CONFIG))
 
+        authed_session = AuthorizedSession(credentials)
+
         headers = {
-            'Authorization': u'Bearer {0!s}'.format(bearer_token),
             'Content-Type': 'application/json; UTF-8',
         }
         fcm_message = {
@@ -160,7 +155,7 @@ def submit_message(self, firebase_token, data):
         if self.smsgateway.option_dict.get(FIREBASE_CONFIG.HTTPS_PROXY):
             proxies["https"] = self.smsgateway.option_dict.get(FIREBASE_CONFIG.HTTPS_PROXY)
         url = FIREBASE_URL_SEND.format(self.smsgateway.option_dict.get(FIREBASE_CONFIG.PROJECT_ID))
-        resp = requests.post(url, data=json.dumps(fcm_message), headers=headers, proxies=proxies)
+        resp = authed_session.post(url, data=json.dumps(fcm_message), headers=headers, proxies=proxies)
 
         if resp.status_code == 200:
             log.debug("Message sent successfully to Firebase service.")
diff --git a/setup.py b/setup.py
--- a/setup.py
+++ b/setup.py
@@ -36,7 +36,6 @@ def get_file_list(file_path):
                     "croniter>=0.3.8",
                     "cryptography>=2.4.2",
                     "defusedxml>=0.4.1",
-                    "ecdsa>=0.13.3",
                     "Flask>=0.10.1",
                     "Flask-Babel>=0.9",
                     "Flask-Migrate>=1.2.0",
@@ -44,10 +43,10 @@ def get_file_list(file_path):
                     "Flask-SQLAlchemy>=2.0",
                     "Flask-Versioned>=0.9.4",
                     "future>=0.18.2;python_version<'3.0'",
+                    "google-auth>=1.24.0",
                     "huey[redis]>=1.11.0",
                     "ldap3>=2.6",
                     "netaddr>=0.7.12",
-                    "oauth2client>=2.0.1",
                     "passlib[bcrypt]>=1.7.0",
                     "argon2_cffi>=20.1.0",
                     "Pillow>=6.2.1",


Test patch:
diff --git a/tests/test_lib_tokens_push.py b/tests/test_lib_tokens_push.py
--- a/tests/test_lib_tokens_push.py
+++ b/tests/test_lib_tokens_push.py
@@ -34,6 +34,7 @@
 from cryptography.hazmat.primitives.asymmetric import padding
 from cryptography.hazmat.primitives.asymmetric import rsa
 from cryptography.hazmat.primitives import serialization
+from google.oauth2 import service_account
 from threading import Timer
 import time
 
@@ -51,18 +52,10 @@
     FIREBASE_CONFIG.JSON_CONFIG: FIREBASE_FILE}
 
 
-class myAccessTokenInfo(object):
-    def __init__(self, access_token):
-        self.access_token = access_token
-        self.expires_in = 3600
-
-
-class myCredentials(object):
-    def __init__(self, access_token_info):
-        self.access_token_info = access_token_info
-
-    def get_access_token(self):
-        return self.access_token_info
+def _create_credential_mock():
+    c = service_account.Credentials('a', 'b', 'c')
+    return mock.MagicMock(spec=c, expired=False, expiry=None,
+                          access_token='my_new_bearer_token')
 
 
 class PushTokenTestCase(MyTestCase):
@@ -103,8 +96,8 @@ def _create_push_token(self):
         tok.add_tokeninfo(PUBLIC_KEY_SERVER, self.server_public_key_pem)
         tok.add_tokeninfo(PRIVATE_KEY_SERVER, self.server_private_key_pem, 'password')
         tok.del_tokeninfo("enrollment_credential")
-        tok.rollout_state = "enrolled"
-        tok.active = True
+        tok.token.rollout_state = "enrolled"
+        tok.token.active = True
         return tok
 
     def test_01_create_token(self):
@@ -320,25 +313,34 @@ def test_02_api_enroll(self):
             self.assertEqual(tokeninfo.get("public_key_server").strip().strip("-BEGIN END RSA PUBLIC KEY-").strip(), pubkey)
             # The token should also contain the firebase config
             self.assertEqual(tokeninfo.get(PUSH_ACTION.FIREBASE_CONFIG), self.firebase_config_name)
+            remove_token(serial=serial)
+
+        delete_smsgateway(self.firebase_config_name)
+        delete_policy('push1')
 
     @responses.activate
     def test_03a_api_authenticate_fail(self):
-        # This tests the failed to communicate to the firebase service
+        # This tests failure to communicate to the firebase service
         self.setUp_user_realms()
-
-        # get enrolled push token
-        toks = get_tokens(tokentype="push")
-        self.assertEqual(len(toks), 1)
-        tokenobj = toks[0]
-
+        # create FireBase Service and policies
+        set_smsgateway(self.firebase_config_name,
+                       u'privacyidea.lib.smsprovider.FirebaseProvider.FirebaseProvider',
+                       "myFB", FB_CONFIG_VALS)
+        set_policy("push1", scope=SCOPE.ENROLL,
+                   action="{0!s}={1!s}".format(PUSH_ACTION.FIREBASE_CONFIG,
+                                               self.firebase_config_name))
+        # create push token
+        tokenobj = self._create_push_token()
+        serial = tokenobj.get_serial()
         # set PIN
         tokenobj.set_pin("pushpin")
         tokenobj.add_user(User("cornelius", self.realm1))
 
         # We mock the ServiceAccountCredentials, since we can not directly contact the Google API
-        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.ServiceAccountCredentials') as mySA:
+        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.service_account.Credentials'
+                        '.from_service_account_file') as mySA:
             # alternative: side_effect instead of return_value
-            mySA.from_json_keyfile_name.return_value = myCredentials(myAccessTokenInfo("my_bearer_token"))
+            mySA.return_value = _create_credential_mock()
 
             # add responses, to simulate the failing communication (status 500)
             responses.add(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
@@ -362,14 +364,14 @@ def test_03a_api_authenticate_fail(self):
 
             # Our ServiceAccountCredentials mock has been called once, because
             # no access token has been fetched before
-            self.assertEqual(len(mySA.from_json_keyfile_name.mock_calls), 1)
+            mySA.assert_called_once()
             self.assertIn(FIREBASE_FILE, get_app_local_store()["firebase_token"])
 
             # By default, polling is allowed for push tokens so the corresponding
             # challenge should be available in the challenge table, even though
             # the request to firebase failed.
             chals = get_challenges(serial=tokenobj.token.serial)
-            self.assertEqual(len(chals), 1)
+            self.assertEqual(len(chals), 1, chals)
             chals[0].delete()
 
             # Now disable polling and check that no challenge is created
@@ -426,29 +428,39 @@ def test_03a_api_authenticate_fail(self):
                 self.assertTrue(jsonresp.get("result").get("status"))
             self.assertEqual(len(get_challenges(serial=tokenobj.token.serial)), 1)
             get_challenges(serial=tokenobj.token.serial)[0].delete()
+        remove_token(serial=serial)
+        delete_smsgateway(self.firebase_config_name)
         delete_policy('push_poll')
+        delete_policy('push1')
 
     @responses.activate
     def test_03b_api_authenticate_client(self):
         # Test the /validate/check endpoints without the smartphone endpoint /ttype/push
         self.setUp_user_realms()
-
-        # get enrolled push token
-        toks = get_tokens(tokentype="push")
-        self.assertEqual(len(toks), 1)
-        tokenobj = toks[0]
-
+        # create FireBase Service and policies
+        set_smsgateway(self.firebase_config_name,
+                       u'privacyidea.lib.smsprovider.FirebaseProvider.FirebaseProvider',
+                       "myFB", FB_CONFIG_VALS)
+        set_policy("push_config", scope=SCOPE.ENROLL,
+                   action="{0!s}={1!s}".format(PUSH_ACTION.FIREBASE_CONFIG,
+                                               self.firebase_config_name))
+        # create push token
+        tokenobj = self._create_push_token()
+        serial = tokenobj.get_serial()
         # set PIN
         tokenobj.set_pin("pushpin")
         tokenobj.add_user(User("cornelius", self.realm1))
 
+        cached_fbtoken = {
+            'firebase_token': {
+                FB_CONFIG_VALS[FIREBASE_CONFIG.JSON_CONFIG]: _create_credential_mock()}}
+        self.app.config.setdefault('_app_local_store', {}).update(cached_fbtoken)
         # We mock the ServiceAccountCredentials, since we can not directly contact the Google API
-        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.ServiceAccountCredentials') as mySA:
-            # alternative: side_effect instead of return_value
-            mySA.from_json_keyfile_name.return_value = myCredentials(myAccessTokenInfo("my_bearer_token"))
-
+        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.service_account'
+                        '.Credentials.from_service_account_file') as mySA:
             # add responses, to simulate the communication to firebase
-            responses.add(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
+            responses.add(responses.POST, 'https://fcm.googleapis.com/v1/projects'
+                                          '/test-123456/messages:send',
                           body="""{}""",
                           content_type="application/json")
 
@@ -469,8 +481,10 @@ def test_03b_api_authenticate_client(self):
                 self.assertEqual(jsonresp.get("detail").get("message"), DEFAULT_CHALLENGE_TEXT)
 
             # Our ServiceAccountCredentials mock has not been called because we use a cached token
-            self.assertEqual(len(mySA.from_json_keyfile_name.mock_calls), 0)
+            mySA.assert_not_called()
             self.assertIn(FIREBASE_FILE, get_app_local_store()["firebase_token"])
+            # remove cached Credentials
+            get_app_local_store().pop("firebase_token")
 
         # The mobile device has not communicated with the backend, yet.
         # The user is not authenticated!
@@ -536,9 +550,11 @@ def test_03b_api_authenticate_client(self):
         with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.time') as mock_time:
             mock_time.time.return_value = time.time() + 4000
 
-            with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.ServiceAccountCredentials') as mySA:
+            with mock.patch(
+                    'privacyidea.lib.smsprovider.FirebaseProvider.service_account.Credentials'
+                    '.from_service_account_file') as mySA:
                 # alternative: side_effect instead of return_value
-                mySA.from_json_keyfile_name.return_value = myCredentials(myAccessTokenInfo("my_new_bearer_token"))
+                mySA.return_value = _create_credential_mock()
 
                 # add responses, to simulate the communication to firebase
                 responses.add(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
@@ -565,15 +581,16 @@ def test_03b_api_authenticate_client(self):
                 delete_policy("push1")
 
             # Our ServiceAccountCredentials mock has been called once because we fetched a new token
-            self.assertEqual(len(mySA.from_json_keyfile_name.mock_calls), 1)
+            mySA.assert_called_once()
             self.assertIn(FIREBASE_FILE, get_app_local_store()["firebase_token"])
             self.assertEqual(get_app_local_store()["firebase_token"][FIREBASE_FILE].access_token,
                              "my_new_bearer_token")
 
         # Authentication fails, if the push notification is not accepted within the configured time
-        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.ServiceAccountCredentials') as mySA:
+        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.service_account.Credentials'
+                        '.from_service_account_file') as mySA:
             # alternative: side_effect instead of return_value
-            mySA.from_json_keyfile_name.return_value = myCredentials(myAccessTokenInfo("my_bearer_token"))
+            mySA.return_value = _create_credential_mock()
 
             # add responses, to simulate the communication to firebase
             responses.add(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
@@ -595,6 +612,7 @@ def test_03b_api_authenticate_client(self):
                 self.assertTrue(jsonresp.get("result").get("status"))
                 self.assertEqual(jsonresp.get("detail").get("serial"), tokenobj.token.serial)
             delete_policy("push1")
+        delete_policy('push_config')
 
     def mark_challenge_as_accepted(self):
         # We simply mark all challenges as successfully answered!
@@ -636,9 +654,10 @@ def check_firebase_params(request):
             return (200, headers, json.dumps({}))
 
         # We mock the ServiceAccountCredentials, since we can not directly contact the Google API
-        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.ServiceAccountCredentials') as mySA:
+        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.service_account.Credentials'
+                        '.from_service_account_file') as mySA:
             # alternative: side_effect instead of return_value
-            mySA.from_json_keyfile_name.return_value = myCredentials(myAccessTokenInfo("my_bearer_token"))
+            mySA.from_json_keyfile_name.return_value = _create_credential_mock()
 
             # add responses, to simulate the communication to firebase
             responses.add_callback(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
@@ -812,9 +831,10 @@ def test_06_api_auth(self):
                    action="{}={}".format(ACTION.LOGINMODE, LOGINMODE.PRIVACYIDEA))
         # Set a PUSH_WAIT action which will be ignored by privacyIDEA
         set_policy("push1", scope=SCOPE.AUTH, action="{0!s}=20".format(PUSH_ACTION.WAIT))
-        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.ServiceAccountCredentials') as mySA:
+        with mock.patch('privacyidea.lib.smsprovider.FirebaseProvider.service_account.Credentials'
+                        '.from_service_account_file') as mySA:
             # alternative: side_effect instead of return_value
-            mySA.from_json_keyfile_name.return_value = myCredentials(myAccessTokenInfo("my_bearer_token"))
+            mySA.from_json_keyfile_name.return_value = _create_credential_mock()
 
             # add responses, to simulate the communication to firebase
             responses.add(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',