Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🛂 Fix authorization while mapping APNS tokens to FCM #1002

Merged
merged 2 commits into from
Dec 17, 2024

Conversation

shankari
Copy link
Contributor

@shankari shankari commented Dec 16, 2024

FCM is doubling down on the "I'm going to change my API and break everything" approach. We made one round of fixes in: e-mission/e-mission-docs#1094 (comment)
at which time the mapping to convert APNS tokens to FCM was working

However, in the ~ 2 months since, that has also regressed, and we are now getting a 401 error with the old code.

The new requirements include:

  • using an OAuth2 token instead of the server API key
  • passing in "access_token_auth": "true" as a header

https://developers.google.com/instance-id/reference/server

Screenshot 2024-12-16 at 2 32 42 PM

The current headers don't match these requirements

        importHeaders = {"Authorization": "key=%s" % self.server_auth_token,
                         "Content-Type": "application/json"}

We already use an OAuth2 token to log in and actually send the messages

DEBUG:google.auth.transport.requests:Making request: POST https://oauth2.googleapis.com/token
DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): oauth2.googleapis.com:443
DEBUG:urllib3.connectionpool:https://oauth2.googleapis.com:443 "POST /token HTTP/1.1" 200 None
DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): fcm.googleapis.com:443

So it seems like it would be best to just reuse it for this call as well. However, that token is retrieved from within the pyfcm library and is not easily exposed outside the library.

The token is retrieved using in `_get_access_token`
    def _get_access_token(self):
        """
        Generates access token from credentials.
        If token expires then new access token is generated.
        Returns:
             str: Access token
        """
        # get OAuth 2.0 access token
        try:
            if self.service_account_file:
                credentials = service_account.Credentials.from_service_account_file(
                    self.service_account_file,
                    scopes=["https://www.googleapis.com/auth/firebase.messaging"],
                )
            else:
                credentials = self.credentials
            request = google.auth.transport.requests.Request()
            credentials.refresh(request)
            return credentials.token
        except Exception as e:
            raise InvalidDataError(e)
which is called automatically in `requests_session`
    @property
    def requests_session(self):
        if getattr(self.thread_local, "requests_session", None) is None:
            retries = Retry(
                backoff_factor=1,
                status_forcelist=[502, 503],
                allowed_methods=(Retry.DEFAULT_ALLOWED_METHODS | frozenset(["POST"])),
            )
            adapter = self.custom_adapter or HTTPAdapter(max_retries=retries)
            self.thread_local.requests_session = requests.Session()
            self.thread_local.requests_session.mount("http://", adapter)
            self.thread_local.requests_session.mount("https://", adapter)
            self.thread_local.token_expiry = 0

        current_timestamp = time.time()
        if self.thread_local.token_expiry < current_timestamp:
            self.thread_local.requests_session.headers.update(self.request_headers())
            self.thread_local.token_expiry = current_timestamp + 1800
        return self.thread_local.requests_session

However, the requests_session only caches a property on when the token expires, it does not cache the token itself.
It does cache the headers and update them periodically, so instead of retrieving the token, this change retrieves the entire authorization header. This header includes the token, but is also formatted correctly with the Bearer prefix, so we can use it directly.

With this change, the mapping is successful and both silent and visible push notification are sent to iOS phones.
Please see testing details in
400f9fa

We had already turned on debug logging for the "remind" case
acf6864
so that we can more easily debug errors

It looks like we have had multiple issues with FCM deprecation/migrations
recently and this will be helpful until the APIs stabilize.
FCM is doubling down on the "I'm going to change my API and break
everything" approach. We made one round of fixes in:
e-mission/e-mission-docs#1094 (comment)
at which time the mapping to convert APNS tokens to FCM was working

However, in the ~ 2 months since, that has also regressed, and we are now
getting a 401 error with the old code.

The new requirements include:
- using an OAuth2 token instead of the server API key
- passing in `"access_token_auth": "true"` as a header

We already use an OAuth2 token to log in and actually send the messages

```
DEBUG:google.auth.transport.requests:Making request: POST https://oauth2.googleapis.com/token
DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): oauth2.googleapis.com:443
DEBUG:urllib3.connectionpool:https://oauth2.googleapis.com:443 "POST /token HTTP/1.1" 200 None
DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): fcm.googleapis.com:443
```

So it seems like it would be best to just reuse it for this call as well.
However, that token is retrieved from within the pyfcm library and is not
easily exposed outside the library.

Instead of retrieving the token, this change retrieves the entire
authorization header. This header includes the token, but is also formatted
correctly with the `Bearer` prefix and is accessible through the
`requests_session` property.

With this change, the mapping is successful and both silent and visible push
notification are sent to iOS phones.

Before the change:

```
DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): iid.googleapis.com:443
DEBUG:urllib3.connectionpool:https://iid.googleapis.com:443 "POST /iid/v1:batchImport HTTP/1.1" 401 None
DEBUG:root:Response = <Response [401]>
Received invalid result for batch starting at = 0
after mapping iOS tokens, imported 0 -> processed 0
```

After the change

```
DEBUG:root:Reading existing headers from current session {'User-Agent': 'python-requests/2.28.2', 'Accept-Encoding': 'gzip, deflate, br', 'Accept': '*/*', 'Connection': 'keep-alive', 'Content-Type': 'application/json', 'Authorization': 'Bearer ...'}
DEBUG:root:About to send message {'application': 'gov.nrel.cims.openpath', 'sandbox': False, 'apns_tokens': [....
DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): iid.googleapis.com:443
DEBUG:urllib3.connectionpool:https://iid.googleapis.com:443 "POST /iid/v1:batchImport HTTP/1.1" 200 None
DEBUG:root:Response = <Response [200]>
DEBUG:root:Found firebase mapping from ... at index 0
DEBUG:root:Found firebase mapping from ... at index 1
DEBUG:root:Found firebase mapping from ... at index 2
...
```

Visible push

```
...
s see if the fix actually worked" -e nrelop_open-access_default_1hITb1CUmGT4iNqUgnifhDreySbQUrtP
WARNING:root:Push configured for app gov.nrel.cims.openpath using platform firebase with token AAAAsojuOg... of length 152
after mapping iOS tokens, imported 0 -> processed 0
combo token map has 1 ios entries and 0 android entries
{'success': 0, 'failure': 0, 'results': {}}
Successfully sent to cK0jHHKUjS...
{'success': 1, 'failure': 0, 'results': {'cK0jHHKUjS': 'projects/nrel-openpath/messages/1734384976007500'}}

```
@shankari
Copy link
Contributor Author

@JGreenlee @Abby-Wheelis for visibility before I merge this

@shankari shankari merged commit 398960e into e-mission:master Dec 17, 2024
4 of 5 checks passed
@shankari
Copy link
Contributor Author

Verified on staging

DEBUG:urllib3.connectionpool:Starting new HTTPS connection (1): iid.googleapis.com:443
DEBUG:urllib3.connectionpool:https://iid.googleapis.com:443 "POST /iid/v1:batchImport HTTP/1.1" 200 None
DEBUG:root:Response = <Response [200]>

Plan to push to production in a day or two

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant