diff --git a/README.md b/README.md index d6e9efcf..522610f5 100644 --- a/README.md +++ b/README.md @@ -99,10 +99,18 @@ For API v1, it expects: - the `project_id` parameter to contain the `Project ID`, which can be acquired from Firebase Console at: `https://console.cloud.google.com/project//settings/general/` -- the `service_account_file` parameter to contain the path to the service account file, - which can be acquired from Firebase Console at: + +API v1 also expects one of the following to be configured: +- the [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials) configured on the local system. + This can point to a service account file which can be acquired from Firebase Console at: `https://console.firebase.google.com/project//settings/serviceaccounts/adminsdk` + **OR..** + +- the `service_account_file` config parameter to contain the path to the service account file + +It is recommended to use the application default credentials method to acquire Google credentials. + Using an HTTP Proxy for outbound traffic ---------------------------------------- diff --git a/changelog.d/383.feature b/changelog.d/383.feature new file mode 100644 index 00000000..6ad3b822 --- /dev/null +++ b/changelog.d/383.feature @@ -0,0 +1 @@ +Switch to google application-default-credentials for firebase auth. diff --git a/scripts-dev/proxy-test/docker-compose.yml b/scripts-dev/proxy-test/docker-compose.yml index b83f242b..2963c20d 100644 --- a/scripts-dev/proxy-test/docker-compose.yml +++ b/scripts-dev/proxy-test/docker-compose.yml @@ -5,6 +5,8 @@ services: no-internet: ipv4_address: 172.28.0.2 container_name: sygnal + environment: + GOOGLE_APPLICATION_CREDENTIALS: "/service_account.json" volumes: - ./sygnal.yaml:/sygnal.yaml - ./service_account.json:/service_account.json:ro diff --git a/scripts-dev/proxy-test/sygnal.yaml b/scripts-dev/proxy-test/sygnal.yaml index 4d91d446..09406c21 100644 --- a/scripts-dev/proxy-test/sygnal.yaml +++ b/scripts-dev/proxy-test/sygnal.yaml @@ -63,4 +63,3 @@ apps: type: gcm api_version: v1 project_id: - service_account_file: /service_account.json diff --git a/sygnal.yaml.sample b/sygnal.yaml.sample index 1b59d4c5..6c50da80 100644 --- a/sygnal.yaml.sample +++ b/sygnal.yaml.sample @@ -210,7 +210,6 @@ apps: # #api_key: # api_version: v1 # project_id: project-id - # service_account_file: /path/to/service_account.json # # # This is the maximum number of connections to GCM servers at any one time # # the default is 20. diff --git a/sygnal/gcmpushkin.py b/sygnal/gcmpushkin.py index fa3360ec..3f567390 100644 --- a/sygnal/gcmpushkin.py +++ b/sygnal/gcmpushkin.py @@ -27,7 +27,7 @@ # https://github.com/googleapis/google-auth-library-python/issues/613 import aiohttp import google.auth.transport._aiohttp_requests -from google.auth._default_async import load_credentials_from_file +from google.auth._default_async import default_async, load_credentials_from_file from google.oauth2._credentials_async import Credentials from opentracing import Span, logs, tags from prometheus_client import Counter, Gauge, Histogram @@ -186,23 +186,39 @@ def __init__(self, name: str, sygnal: "Sygnal", config: Dict[str, Any]) -> None: "Must configure `project_id` when using FCM api v1", ) + self._load_credentials(proxy_url) + + # Use the fcm_options config dictionary as a foundation for the body; + # this lets the Sygnal admin choose custom FCM options + # (e.g. content_available). + self.base_request_body = self.get_config("fcm_options", dict, {}) + if not isinstance(self.base_request_body, dict): + raise PushkinSetupException( + "Config field fcm_options, if set, must be a dictionary of options" + ) + + def _load_credentials(self, proxy_url: Optional[str]) -> None: self.credentials: Optional[Credentials] = None if self.api_version is APIVersion.V1: self.service_account_file = self.get_config("service_account_file", str) - if not self.service_account_file: - raise PushkinSetupException( - "Must configure `service_account_file` when using FCM api v1", - ) - try: - self.credentials, _ = load_credentials_from_file( - str(self.service_account_file), - scopes=AUTH_SCOPES, - ) - except google.auth.exceptions.DefaultCredentialsError as e: - raise PushkinSetupException( - f"`service_account_file` must be valid: {str(e)}", - ) + if self.service_account_file: + try: + self.credentials, _ = load_credentials_from_file( + str(self.service_account_file), + scopes=AUTH_SCOPES, + ) + except google.auth.exceptions.DefaultCredentialsError as e: + raise PushkinSetupException( + f"`service_account_file` must be valid: {str(e)}", + ) + else: + try: + self.credentials, _ = default_async(scopes=AUTH_SCOPES) + except google.auth.exceptions.DefaultCredentialsError as e: + raise PushkinSetupException( + f"Failed loading google credentials: {str(e)}", + ) session = None if proxy_url: @@ -215,15 +231,6 @@ def __init__(self, name: str, sygnal: "Sygnal", config: Dict[str, Any]) -> None: session=session ) - # Use the fcm_options config dictionary as a foundation for the body; - # this lets the Sygnal admin choose custom FCM options - # (e.g. content_available). - self.base_request_body = self.get_config("fcm_options", dict, {}) - if not isinstance(self.base_request_body, dict): - raise PushkinSetupException( - "Config field fcm_options, if set, must be a dictionary of options" - ) - @classmethod async def create( cls, name: str, sygnal: "Sygnal", config: Dict[str, Any] diff --git a/tests/test_gcm.py b/tests/test_gcm.py index aa35f015..e856fa33 100644 --- a/tests/test_gcm.py +++ b/tests/test_gcm.py @@ -14,7 +14,7 @@ # limitations under the License. import json import tempfile -from typing import TYPE_CHECKING, Any, AnyStr, Dict, List, Tuple +from typing import TYPE_CHECKING, Any, AnyStr, Dict, List, Optional, Tuple from unittest.mock import MagicMock from sygnal.gcmpushkin import APIVersion, GcmPushkin @@ -108,8 +108,6 @@ def __init__(self, name: str, sygnal: "Sygnal", config: Dict[str, Any]): self.last_request_body: Dict[str, Any] = {} self.last_request_headers: Dict[AnyStr, List[AnyStr]] = {} # type: ignore[valid-type] self.num_requests = 0 - if self.api_version is APIVersion.V1: - self.credentials = TestCredentials() # type: ignore[assignment] def preload_with_response( self, code: int, response_payload: Dict[str, Any] @@ -134,10 +132,21 @@ async def _refresh_credentials(self) -> None: await self.credentials.refresh(self.google_auth_request) +class TestGcmPushkinCredentialBypass(TestGcmPushkin): + """ + A GCM pushkin that also bypasses credential loading. + """ + + def _load_credentials(self, proxy_url: Optional[str]) -> None: + if self.api_version is APIVersion.V1: + self.credentials = TestCredentials() # type: ignore[assignment] + self.google_auth_request = None # type: ignore[assignment] + + FAKE_SERVICE_ACCOUNT_FILE = b""" { "type": "service_account", - "project_id": "project_id", + "project_id": "test_project_id", "private_key_id": "private_key_id", "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC0PwE6TeTHjD5R\\nY2nOw1rsTgQZ38LCR2CLtx36n+LUkgej/9b+fwC88oKIqJKjUwn43JEOhf4rbA/a\\nqo4jVoLgv754G5+7Glfarr3/rqg+AVT75x6J5DRvhIYpDXwMIUqLAAbfk3TTFNJn\\n2ctrkBF2ZP9p3mzZ3NRjU63Wbf3LBpRqs8jdFEQu8JAecG8VKV1mboJIXG3hwqFN\\nJmcpC/+sWaxB5iMgSqy0w/rGFs6ZbZF6D10XYvf40lEEk9jQIovT+QD4+6GTlroT\\nbOk8uIwxFQcwMFpXj4MktqVNSNyiuuttptIvBWcMWHlaabXrR89vqUFe1g1Jx4GL\\nCF89RrcLAgMBAAECggEAPUYZ3b8zId78JGDeTEq+8wwGeuFFbRQkrvpeN5/41Xib\\nHlZPuQ5lqtXqKBjeWKVXA4G/0icc45gFv7kxPrQfI9YrItuJLmrjKNU0g+HVEdcU\\nE9pa2Fd6t9peXUBXRixfEee9bm3LTiKK8IDqlTNRrGTjKxNQ/7MBhI6izv1vRH/x\\n8i0o1xxNdqstHZ9wBFKYO9w8UQjtfzckkBNDLkaJ/WN0BoRubmUiV1+KwAyyBr6O\\nRnnZ9Tvy8VraSNSdJhX36ai36y18/sT6PWOp99zHYuDyz89KIz1la/fT9eSoR0Jy\\nYePmTEi+9pWhvtpAkqJkRxe5IDz71JVsQ07KoVfzaQKBgQDzKKUd/0ujhv/B9MQf\\nHcwSeWu/XnQ4hlcwz8dTWQjBV8gv9l4yBj9Pra62rg/tQ7b5XKMt6lv/tWs1IpdA\\neMsySY4972VPrmggKXgCnyKckDUYydNtHAIj9buo6AV8rONaneYnGv5wpSsf3q2c\\nOZrkamRgbBkI+B2mZ2obH1oVlQKBgQC9w9HkrDMvZ5L/ilZmpsvoHNFlQwmDgNlN\\n0ej5QGID5rljRM3CcLNHdyQiKqvLA9MCpPEXb2vVJPdmquD12A7a9s0OwxB/dtOD\\nykofcTY0ZHEM1HEyYJGmdK4FvZuNU4o2/D268dePjtj1Xw3c5fs0bcDiGQMtjWlz\\n5hjBzMsyHwKBgGjrIsPcwlBfEcAo0u7yNnnKNnmuUcuJ+9kt7j3Cbwqty80WKvK+\\ny1agBIECfhDMZQkXtbk8JFIjf4y/zi+db1/VaTDEORy2jmtCOWw4KgEQIDj/7OBp\\nc2r8vupUovl2x+rzsrkw5pTIT+FCffqoyHLCjWkle2/pTzHb8Waekoo5AoGAbELk\\nYy5uwTO45Hr60fOEzzZpq/iz28dNshz4agL2KD2gNGcTcEO1tCbfgXKQsfDLmG2b\\ncgBKJ77AOl1wnDEYQIme8TYOGnojL8Pfx9Jh10AaUvR8Y/49+hYFFhdXQCiR6M69\\nNQM2NJuNYWdKVGUMjJu0+AjHDFzp9YonQ6Ffp4cCgYEAmVALALCjU9GjJymgJ0lx\\nD9LccVHMwf9NmR/sMg0XNePRbCEcMDHKdtVJ1zPGS5txuxY3sRb/tDpv7TfuitrU\\nAw0/2ooMzunaoF/HXo+C/+t+pfuqPqLK4sCCyezUlMfCcaPdwXN2FmbgsaFHfe7I\\n7sGEnS/d8wEgydMiptJEf9s=\\n-----END PRIVATE KEY-----\\n", "client_email": "firebase-adminsdk@project_id.iam.gserviceaccount.com", @@ -168,7 +177,7 @@ def config_setup(self, config: Dict[str, Any]) -> None: self.service_account_file = tempfile.NamedTemporaryFile() self.service_account_file.write(FAKE_SERVICE_ACCOUNT_FILE) self.service_account_file.flush() - config["apps"]["com.example.gcm.apiv1"] = { + config["apps"]["com.example.gcm.apiv1.load_service_account"] = { "type": "tests.test_gcm.TestGcmPushkin", "api_version": "v1", "project_id": "example_project", @@ -192,6 +201,29 @@ def config_setup(self, config: Dict[str, Any]) -> None: }, }, } + config["apps"]["com.example.gcm.apiv1"] = { + "type": "tests.test_gcm.TestGcmPushkinCredentialBypass", + "api_version": "v1", + "project_id": "example_project", + "fcm_options": { + "android": { + "notification": { + "body": { + "test body", + }, + }, + }, + "apns": { + "payload": { + "aps": { + "content-available": 1, + "mutable-content": 1, + "alert": "", + }, + }, + }, + }, + } def tearDown(self) -> None: self.service_account_file.close() @@ -480,3 +512,12 @@ def test_fcm_options(self) -> None: assert gcm.last_request_body is not None self.assertEqual(gcm.last_request_body["mutable_content"], True) self.assertEqual(gcm.last_request_body["content_available"], True) + + def test_load_api_v1_service_account(self) -> None: + """ + Tests that the configured service_account_file can be loaded successfully. + """ + self.apns_pushkin_snotif = MagicMock() + gcm = self.get_test_pushkin("com.example.gcm.apiv1.load_service_account") + assert gcm.credentials is not None + assert gcm.credentials.project_id == "test_project_id" # type: ignore[attr-defined]