Repository: privacyidea/privacyidea

Issue:
Hanging at contacting firebase service
When testing the iOS Push token App the communication to the firebase service hangs some times. It is not clear why.

We could however improve the communication here:
https://github.com/privacyidea/privacyidea/blob/master/privacyidea/lib/smsprovider/FirebaseProvider.py#L76

And reuse an existing access_token as long as it has not expired.




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
@@ -25,11 +25,14 @@
 
 from privacyidea.lib.smsprovider.SMSProvider import (ISMSProvider)
 from privacyidea.lib.error import ConfigAdminError
+from privacyidea.lib.framework import get_app_local_store
 from privacyidea.lib import _
 import logging
 from oauth2client.service_account import ServiceAccountCredentials
 import requests
 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',
@@ -42,6 +45,46 @@
 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:
+    """
+    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)
+        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))
+
+    return app_store[fbt][config_file_name].access_token
+
+
 class FIREBASE_CONFIG:
     REGISTRATION_URL = "registration URL"
     TTL = "time to live"
@@ -56,6 +99,11 @@ class FIREBASE_CONFIG:
 
 class FirebaseProvider(ISMSProvider):
 
+    def __init__(self, db_smsprovider_object=None, smsgateway=None):
+        ISMSProvider.__init__(self, db_smsprovider_object, smsgateway)
+        self.access_token_info = None
+        self.access_token_expires_at = 0
+
     def submit_message(self, firebase_token, data):
         """
         send a message to a registered Firebase client
@@ -69,16 +117,9 @@ def submit_message(self, firebase_token, data):
         """
         res = False
 
-        credentials = ServiceAccountCredentials.\
-                from_json_keyfile_name(self.smsgateway.option_dict.get(FIREBASE_CONFIG.JSON_CONFIG),
-                                       SCOPES)
-
-        access_token_info = credentials.get_access_token()
-
-        # Should we do something with expires in?
-        # expires_in = access_token_info.expires_in
+        bearer_token = get_firebase_access_token(self.smsgateway.option_dict.get(
+            FIREBASE_CONFIG.JSON_CONFIG))
 
-        bearer_token = access_token_info.access_token
         headers = {
             'Authorization': u'Bearer {0!s}'.format(bearer_token),
             'Content-Type': 'application/json; UTF-8',
@@ -189,4 +230,4 @@ def parameters(cls):
                   }
                   }
         return params
-        
\ No newline at end of file
+        


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
@@ -6,6 +6,7 @@
 from .base import MyTestCase
 from privacyidea.lib.error import ParameterError
 from privacyidea.lib.user import (User)
+from privacyidea.lib.framework import get_app_local_store
 from privacyidea.lib.tokens.pushtoken import PushTokenClass, PUSH_ACTION, DEFAULT_CHALLENGE_TEXT, strip_key
 from privacyidea.lib.smsprovider.FirebaseProvider import FIREBASE_CONFIG
 from privacyidea.lib.token import get_tokens, remove_token
@@ -283,7 +284,7 @@ def test_02_api_enroll(self):
             self.assertEqual(tokeninfo.get(PUSH_ACTION.FIREBASE_CONFIG), self.firebase_config_name)
 
     @responses.activate
-    def test_03_api_authenticate_fail(self):
+    def test_03a_api_authenticate_fail(self):
         # This tests the failed to communicate to the firebase service
         self.setUp_user_realms()
 
@@ -319,10 +320,14 @@ def test_03_api_authenticate_fail(self):
                 self.assertFalse(jsonresp.get("result").get("status"))
                 self.assertEqual(jsonresp.get("result").get("error").get("code"), 401)
                 self.assertEqual(jsonresp.get("result").get("error").get("message"), "ERR401: Failed to submit "
-                                                                                     "message to firebase service.")               
+                                                                                     "message to firebase service.")
+
+            # 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)
+            self.assertIn(FIREBASE_FILE, get_app_local_store()["firebase_token"])
 
     @responses.activate
-    def test_03_api_authenticate_client(self):
+    def test_03b_api_authenticate_client(self):
         # Test the /validate/check endpoints without the smartphone endpoint /ttype/push
         self.setUp_user_realms()
 
@@ -361,6 +366,10 @@ def test_03_api_authenticate_client(self):
                 transaction_id = jsonresp.get("detail").get("transaction_id")
                 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)
+            self.assertIn(FIREBASE_FILE, get_app_local_store()["firebase_token"])
+
         # The mobile device has not communicated with the backend, yet.
         # The user is not authenticated!
         with self.app.test_request_context('/validate/check',
@@ -395,33 +404,44 @@ def test_03_api_authenticate_client(self):
 
         # We mock the ServiceAccountCredentials, since we can not directly contact the Google API
         # Do single shot auth with waiting
-        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"))
-
-            # add responses, to simulate the communication to firebase
-            responses.add(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
-                          body="""{}""",
-                          content_type="application/json")
-
-            # In two seconds we need to run an update on the challenge table.
-            Timer(2, self.mark_challenge_as_accepted).start()
-
-            set_policy("push1", scope=SCOPE.AUTH, action="{0!s}=20".format(PUSH_ACTION.WAIT))
-            # Send the first authentication request to trigger the challenge
-            with self.app.test_request_context('/validate/check',
-                                               method='POST',
-                                               data={"user": "cornelius",
-                                                     "realm": self.realm1,
-                                                     "pass": "pushpin"}):
-                res = self.app.full_dispatch_request()
-                self.assertTrue(res.status_code == 200, res)
-                jsonresp = json.loads(res.data.decode('utf8'))
-                # We successfully authenticated! YEAH!
-                self.assertTrue(jsonresp.get("result").get("value"))
-                self.assertTrue(jsonresp.get("result").get("status"))
-                self.assertEqual(jsonresp.get("detail").get("serial"), tokenobj.token.serial)
-            delete_policy("push1")
+        # Also mock time.time to be 4000 seconds in the future (exceeding the validity of myAccessTokenInfo),
+        # so that we fetch a new auth token
+        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:
+                # alternative: side_effect instead of return_value
+                mySA.from_json_keyfile_name.return_value = myCredentials(myAccessTokenInfo("my_new_bearer_token"))
+
+                # add responses, to simulate the communication to firebase
+                responses.add(responses.POST, 'https://fcm.googleapis.com/v1/projects/test-123456/messages:send',
+                              body="""{}""",
+                              content_type="application/json")
+
+                # In two seconds we need to run an update on the challenge table.
+                Timer(2, self.mark_challenge_as_accepted).start()
+
+                set_policy("push1", scope=SCOPE.AUTH, action="{0!s}=20".format(PUSH_ACTION.WAIT))
+                # Send the first authentication request to trigger the challenge
+                with self.app.test_request_context('/validate/check',
+                                                   method='POST',
+                                                   data={"user": "cornelius",
+                                                         "realm": self.realm1,
+                                                         "pass": "pushpin"}):
+                    res = self.app.full_dispatch_request()
+                    self.assertTrue(res.status_code == 200, res)
+                    jsonresp = json.loads(res.data.decode('utf8'))
+                    # We successfully authenticated! YEAH!
+                    self.assertTrue(jsonresp.get("result").get("value"))
+                    self.assertTrue(jsonresp.get("result").get("status"))
+                    self.assertEqual(jsonresp.get("detail").get("serial"), tokenobj.token.serial)
+                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)
+            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:
@@ -514,6 +534,10 @@ def check_firebase_params(request):
                 transaction_id = jsonresp.get("detail").get("transaction_id")
                 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)
+            self.assertIn(FIREBASE_FILE, get_app_local_store()["firebase_token"])
+
         # The challenge is sent to the smartphone via the Firebase service, so we do not know
         # the challenge from the /validate/check API.
         # So lets read the challenge from the database!
@@ -733,4 +757,4 @@ def test_06_api_auth(self):
             self.assertTrue(res.json['result']['status'])
 
         delete_policy("push1")
-        delete_policy("webui")
\ No newline at end of file
+        delete_policy("webui")