From 804e27fd6d889f1db30c20d2ec8bfdbbaf3a232c Mon Sep 17 00:00:00 2001 From: jrconlin Date: Mon, 4 Mar 2024 09:52:53 -0800 Subject: [PATCH 1/2] feat: Add `--wns` option to handle WNS header Microsoft has introduced [extra header](https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/push-request-response-headers#request-parameters) requirements for incoming push messages. I kind of want to avoid adding a lot of system specific smarts to pywebpush, mostly because that's an endless road of feature creep. The preferred way to handle this would be to include the extra, call specific headers in the `webpush(..., headers=dict(...))` argument. Closes #162 --- pywebpush/__init__.py | 3 ++- pywebpush/__main__.py | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/pywebpush/__init__.py b/pywebpush/__init__.py index e58cbd3..ff7367d 100644 --- a/pywebpush/__init__.py +++ b/pywebpush/__init__.py @@ -424,9 +424,10 @@ def send(self, *args, **kwargs) -> Union[Response, str]: **params, ) self.verb( - "\nResponse:\n\tcode: {}\n\tbody: {}\n", + "\nResponse:\n\tcode: {}\n\tbody: {}\n\theaders: {}", resp.status_code, resp.text or "Empty", + resp.headers or "None" ) return resp diff --git a/pywebpush/__main__.py b/pywebpush/__main__.py index 0a88859..c2f0270 100644 --- a/pywebpush/__main__.py +++ b/pywebpush/__main__.py @@ -2,6 +2,7 @@ import os import json import logging +import math from requests import JSONDecodeError @@ -15,6 +16,11 @@ def get_config(): parser.add_argument("--head", help="Header Info JSON file") parser.add_argument("--claims", help="Vapid claim file") parser.add_argument("--key", help="Vapid private key file path") + parser.add_argument( + "--wns", + help="Include WNS cache header based on TTL", + default=False, + action="store_true") parser.add_argument( "--curl", help="Don't send, display as curl command", @@ -53,6 +59,15 @@ def get_config(): args.head = json.loads(r.read()) except JSONDecodeError as e: raise WebPushException("Could not read the header arguments: {}", e) + # Set the default "TTL" + args.head["ttl"] = args.head.get("ttl", "0") + if args.wns: + # NOTE: Microsoft also requires `X-WNS-Type` as + # `tile`, `toast`, `badge` or `raw`. This is not provided by this code. + if int(args.head.get("ttl", "0")) > 0: + args.head["x-wns-cache-policy"] = "cache" + else: + args.head["x-wns-cache-policy"] = "no-cache" if args.claims: if not args.key: raise WebPushException("No private --key specified for claims") From 4b6957c17ad288891c3c23566467f18444b6de3e Mon Sep 17 00:00:00 2001 From: jrconlin Date: Fri, 19 Apr 2024 10:30:38 -0700 Subject: [PATCH 2/2] feat: WNS notes / Drop GCM/FCM support See CHANGELOG --- CHANGELOG.md | 8 +++++ README.md | 60 +++++++++++++++++++++++++++------ pywebpush/__init__.py | 47 ++------------------------ pywebpush/tests/test_webpush.py | 30 ----------------- setup.py | 2 +- 5 files changed, 60 insertions(+), 87 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0999302..e4406e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,12 @@ # I am terrible at keeping this up-to-date. +## 2.1.0 (2024-04-19) +Add comment discussing additional work for Windows Notification Service (WNS) +* Update the README.md file to mention the required, non-standard headers. + +*BREAKING_CHANGE* +This version also drops legacy support for GCM/FCM authorization keys, since those items +are obsolete according to Google. +See https://firebase.google.com/docs/cloud-messaging/auth-server#authorize-legacy-protocol-send-requests ## 2.0.0 (2024-01-02) chore: Update to modern python practices diff --git a/README.md b/README.md index f20d3ec..3525945 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,11 @@ referenced to the user who requested it, and recall it when there's a new push subscription update is left as an exercise for the reader. +_*Note:*_ Some platforms (like Microsoft Windows) require additional +headers specified for the Push call. These additional headers are not +standard and may be rejected by other Push services. Please see +[Special Instructions](#special-instructions) below. + ### Sending Data using `webpush()` One Call In many cases, your code will be sending a single message to many @@ -56,7 +61,7 @@ webpush(subscription_info, This will encode `data`, add the appropriate VAPID auth headers if required and send it to the push server identified in the `subscription_info` block. -##### Parameters +#### Parameters _subscription_info_ - The `dict` of the subscription info (described above). @@ -81,7 +86,7 @@ e.g. the output of: openssl ecparam -name prime256v1 -genkey -noout -out private_key.pem ``` -##### Example +#### Example ```python from pywebpush import webpush, WebPushException @@ -119,7 +124,7 @@ can pass just `wp = WebPusher(subscription_info)`. This will return a `WebPusher The following methods are available: -#### `.send(data, headers={}, ttl=0, gcm_key="", reg_id="", content_encoding="aes128gcm", curl=False, timeout=None)` +#### `.send(data, headers={}, ttl=0, reg_id="", content_encoding="aes128gcm", curl=False, timeout=None)` Send the data using additional parameters. On error, returns a `WebPushException` @@ -131,9 +136,6 @@ _headers_ A `dict` containing any additional headers to send _ttl_ Message Time To Live on Push Server waiting for the client to reconnect (in seconds) -_gcm_key_ Google Cloud Messaging key (if using the older GCM push system) This is the API key obtained from the Google -Developer Console. - _reg_id_ Google Cloud Messaging registration ID (will be extracted from endpoint if not specified) _content_encoding_ ECE content encoding type (defaults to "aes128gcm") @@ -146,10 +148,10 @@ See [requests documentation](http://docs.python-requests.org/en/master/user/quic ##### Example -to send from Chrome using the old GCM mode: +to send to a user on Chrome: ```python -WebPusher(subscription_info).send(data, headers, ttl, gcm_key) +WebPusher(subscription_info).send(data, headers, ttl) ``` #### `.encode(data, content_encoding="aes128gcm")` @@ -162,7 +164,7 @@ _data_ Binary string of data to send _content_encoding_ ECE content encoding type (defaults to "aes128gcm") -*Note* This will return a `NoData` exception if the data is not present or empty. It is completely +_*Note*_ This will return a `NoData` exception if the data is not present or empty. It is completely valid to send a WebPush notification with no data, but encoding is a no-op in that case. Best not to call it if you don't have data. @@ -174,8 +176,7 @@ encoded_data = WebPush(subscription_info).encode(data) ## Stand Alone Webpush -If you're not really into coding your own solution, there's also a "stand-alone" `pywebpush` command in the -./bin directory. +If you're not really into coding your own solution, there's also a "stand-alone" `pywebpush` command in the `./bin` directory. This uses two files: @@ -201,3 +202,40 @@ If you're interested in just testing your applications WebPush interface, you co which will encrypt and send the contents of `stuff_to_send.data`. See `./bin/pywebpush --help` for available commands and options. + +## Special Instructions + +### Windows + +[Microsoft requires](https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/push-request-response-headers#request-parameters) +one extra header and suggests several additional headers. Users have reported that not including +these headers can cause notifications to fail to appear. These additional headers are non-standard +and may be rejected by other platforms. You should be cautious including them with calls to +non-Microsoft platforms, or to non-Microsoft destinations. + +As of 2024-Apr-19, Microsoft requires an `X-WNS-Type` header. As an example, you can include this +header within the command line call: + +Content of the `windows_headers.json` file: + +```json +{"X-WNS-Type":"wns/toast", "TTL":600, "Content-Type": "text/xml"} +``` + +_*Note*_ : This includes both the `TTL` set to 10 minutes and the required matching `Content-Type` header for `wns/toast`. + +```bash +pywebpush --data stuff_to_send.xml \ + --info edge_user_info.json \ + --head windows_headers.json \ + --claims vapid_claims.json +``` + +### Google Cloud Messaging (GCM) + +Please note that GCM has been sunset by Google. Providers should +use [Firebase Cloud Messaging](https://firebase.google.com/support/troubleshooter/fcm/tokens/gcm) instead. + +Please also note that sending messages directly to FCM is not supported by this library. In the past, you could use an insecure `gcm_key` as a proxy for the authentication service. [This was disabled in June 2024](https://firebase.google.com/docs/cloud-messaging/auth-server#authorize-legacy-protocol-send-requests). + +Sending WebPush messages to Google Chrome users should not be impacted by this change. diff --git a/pywebpush/__init__.py b/pywebpush/__init__.py index ff7367d..6a784be 100644 --- a/pywebpush/__init__.py +++ b/pywebpush/__init__.py @@ -304,10 +304,7 @@ def _prepare_send_data( data: Union[None, bytes] = None, headers: Union[None, Dict[str, str]] = None, ttl: int = 0, - gcm_key: Union[None, str] = None, - reg_id: Union[None, str] = None, content_encoding: str = "aes128gcm", - curl: bool = False, ) -> dict: """Encode and send the data to the Push Service. @@ -319,16 +316,8 @@ def _prepare_send_data( recipient is not online. (Defaults to "0", which discards the message immediately if the recipient is unavailable.) :type ttl: int - :param gcm_key: API key obtained from the Google Developer Console. - Needed if endpoint is https://android.googleapis.com/gcm/send - :type gcm_key: string - :param reg_id: registration id of the recipient. If not provided, - it will be extracted from the endpoint. - :type reg_id: str :param content_encoding: ECE content encoding (defaults to "aes128gcm") :type content_encoding: str - :param curl: Display output as `curl` command instead of sending - :type curl: bool """ # Encode the data. if headers is None: @@ -355,40 +344,8 @@ def _prepare_send_data( "content-encoding": content_encoding, } ) - if gcm_key: - # guess if it is a legacy GCM project key or actual FCM key - # gcm keys are all about 40 chars (use 100 for confidence), - # fcm keys are 153-175 chars - if len(gcm_key) < 100: - self.verb("Guessing this is legacy GCM...") - endpoint = "https://android.googleapis.com/gcm/send" - else: - self.verb("Guessing this is FCM...") - endpoint = "https://fcm.googleapis.com/fcm/send" - reg_ids = [] - if not reg_id: - reg_id = cast(str, self.subscription_info["endpoint"]).rsplit("/", 1)[ - -1 - ] - self.verb("Fetching out registration id: {}", reg_id) - reg_ids.append(reg_id) - gcm_data = dict() - gcm_data["registration_ids"] = reg_ids - if data: - buffer = encoded.get("body") - if buffer: - gcm_data["raw_data"] = base64.b64encode(buffer).decode("utf8") - gcm_data["time_to_live"] = int(headers["ttl"] if "ttl" in headers else ttl) - encoded_data = json.dumps(gcm_data) - headers.update( - { - "Authorization": "key=" + gcm_key, - "Content-Type": "application/json", - } - ) - else: - encoded_data = encoded.get("body") - endpoint = self.subscription_info["endpoint"] + encoded_data = encoded.get("body") + endpoint = self.subscription_info["endpoint"] if "ttl" not in headers or ttl: self.verb("Generating TTL of 0...") diff --git a/pywebpush/tests/test_webpush.py b/pywebpush/tests/test_webpush.py index 6f8378b..d358def 100644 --- a/pywebpush/tests/test_webpush.py +++ b/pywebpush/tests/test_webpush.py @@ -322,21 +322,6 @@ def test_ci_dict(self): del ci["FOO"] assert ci.get("Foo") is None - @patch("requests.post") - def test_gcm(self, mock_post): - subscription_info = self._gen_subscription_info( - None, endpoint="https://android.googleapis.com/gcm/send/regid123" - ) - headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} - data = "Mary had a little lamb" - wp = WebPusher(subscription_info) - wp.send(data, headers, gcm_key="gcm_key_value") - pdata = json.loads(mock_post.call_args[1].get("data")) - pheaders = mock_post.call_args[1].get("headers") - assert pdata["registration_ids"][0] == "regid123" - assert pheaders.get("authorization") == "key=gcm_key_value" - assert pheaders.get("content-type") == "application/json" - @patch("requests.post") def test_timeout(self, mock_post): mock_post.return_value.status_code = 200 @@ -399,21 +384,6 @@ async def test_send_no_headers(self, mock_post): assert pheaders.get("ttl") == "0" assert pheaders.get("content-encoding") == "aes128gcm" - @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) - async def test_fcm(self, mock_post): - subscription_info = self._gen_subscription_info( - None, endpoint="https://android.googleapis.com/fcm/send/regid123" - ) - headers = {"Crypto-Key": "pre-existing", "Authentication": "bearer vapid"} - data = "Mary had a little lamb" - wp = WebPusher(subscription_info) - await wp.send_async(data, headers, gcm_key="gcm_key_value") - pdata = json.loads(mock_post.call_args[1].get("data")) - pheaders = mock_post.call_args[1].get("headers") - assert pdata["registration_ids"][0] == "regid123" - assert pheaders.get("authorization") == "key=gcm_key_value" - assert pheaders.get("content-type") == "application/json" - @patch("aiohttp.ClientSession.post", new_callable=AsyncMock) async def test_timeout(self, mock_post): mock_post.return_value.status_code = 200 diff --git a/setup.py b/setup.py index f8756d1..2b7825f 100644 --- a/setup.py +++ b/setup.py @@ -9,7 +9,7 @@ from setuptools import find_packages, setup -__version__ = "2.0.0" +__version__ = "2.1.0" def read_from(file):