From 0d84de622e6ee0773d5354780df3f5ef0963bd59 Mon Sep 17 00:00:00 2001 From: jrconlin Date: Mon, 27 Aug 2018 16:30:53 -0700 Subject: [PATCH] feat: add ADM support Closes #1275 --- autopush/config.py | 9 +- autopush/main_argparse.py | 6 + autopush/router/__init__.py | 6 +- autopush/router/adm.py | 220 +++++++++++++++++++ autopush/tests/test_integration.py | 306 +++++++++++++++++++++++++++ autopush/tests/test_z_main.py | 14 ++ autopush/web/registration.py | 11 +- autopush/web/webpush.py | 2 +- configs/autopush_endpoint.ini.sample | 3 + docs/adm.rst | 89 ++++++++ docs/glossary.rst | 2 +- docs/install.rst | 4 + 12 files changed, 667 insertions(+), 5 deletions(-) create mode 100644 autopush/router/adm.py create mode 100644 docs/adm.rst diff --git a/autopush/config.py b/autopush/config.py index 40a0e0a1..22f97b4b 100644 --- a/autopush/config.py +++ b/autopush/config.py @@ -258,7 +258,6 @@ def from_argparse(cls, ns, **kwargs): "max_data": ns.max_data, "collapsekey": ns.gcm_collapsekey, "senderIDs": sender_ids} - client_certs = None # endpoint only if getattr(ns, 'client_certs', None): @@ -296,6 +295,14 @@ def from_argparse(cls, ns, **kwargs): "auth": ns.fcm_auth, "senderid": ns.fcm_senderid} + if ns.adm_creds: + # Create a common admclient + try: + router_conf["adm"] = json.loads(ns.adm_creds) + except (ValueError, TypeError): + raise InvalidConfig( + "Invalid JSON specified for ADM config options") + ami_id = None # Not a fan of double negatives, but this makes more # understandable args diff --git a/autopush/main_argparse.py b/autopush/main_argparse.py index 068aae81..a398806b 100644 --- a/autopush/main_argparse.py +++ b/autopush/main_argparse.py @@ -189,6 +189,12 @@ def _add_external_router_args(parser): "APNS settings", type=str, default="", env_var="APNS_CREDS") + # Amazon Device Messaging client credentials + parser.add_argument('--adm_creds', help="JSON dictionary of " + "Amazon Device Message " + "credentials", + type=str, default="", + env_var="ADM_CREDS") def parse_connection(config_files, args): diff --git a/autopush/router/__init__.py b/autopush/router/__init__.py index 8b4e4de4..2fefab17 100644 --- a/autopush/router/__init__.py +++ b/autopush/router/__init__.py @@ -15,8 +15,10 @@ from autopush.router.interface import IRouter # noqa from autopush.router.webpush import WebPushRouter from autopush.router.fcm import FCMRouter +from autopush.router.adm import ADMRouter -__all__ = ["APNSRouter", "FCMRouter", "GCMRouter", "WebPushRouter"] +__all__ = ["APNSRouter", "FCMRouter", "GCMRouter", "WebPushRouter", + "ADMRouter"] def routers_from_config(conf, db, agent): @@ -30,4 +32,6 @@ def routers_from_config(conf, db, agent): routers["apns"] = APNSRouter(conf, router_conf["apns"], db.metrics) if 'gcm' in router_conf: routers["gcm"] = GCMRouter(conf, router_conf["gcm"], db.metrics) + if 'adm' in router_conf: + routers["adm"] = ADMRouter(conf, router_conf["adm"], db.metrics) return routers diff --git a/autopush/router/adm.py b/autopush/router/adm.py new file mode 100644 index 00000000..531e729c --- /dev/null +++ b/autopush/router/adm.py @@ -0,0 +1,220 @@ +"""ADM Router""" +import time +import requests + +from typing import Any # noqa + +from requests.exceptions import ConnectionError, Timeout +from twisted.internet.threads import deferToThread +from twisted.logger import Logger + +from autopush.exceptions import RouterException +from autopush.metrics import make_tags +from autopush.router.interface import RouterResponse +from autopush.types import JSONDict # noqa + + +class ADMAuthError(Exception): + pass + + +class ADMClient(object): + def __init__(self, + credentials=None, + logger=None, + metrics=None, + endpoint="api.amazon.com", + timeout=2, + **options + ): + + self._client_id = credentials["client_id"] + self._client_secret = credentials["client_secret"] + self._token_exp = 0 + self._auth_token = None + self._aws_host = endpoint + self._logger = logger + self._metrics = metrics + self._request = requests + self._timeout = timeout + + def refresh_key(self): + url = "https://{}/auth/O2/token".format(self._aws_host) + if self._auth_token is None or self._token_exp < time.time(): + body = dict( + grant_type="client_credentials", + scope="messaging:push", + client_id=self._client_id, + client_secret=self._client_secret + ) + headers = { + "content-type": "application/x-www-form-urlencoded" + } + resp = self._request.post(url, data=body, headers=headers, + timeout=self._timeout) + if resp.status_code != 200: + self._logger.error("Could not get ADM Auth token {}".format( + resp.text + )) + raise ADMAuthError("Could not fetch auth token") + reply = resp.json() + self._auth_token = reply['access_token'] + self._token_exp = time.time() + reply.get('expires_in', 0) + + def send(self, reg_id, payload, ttl=None, collapseKey=None): + self.refresh_key() + headers = { + "Authorization": "Bearer {}".format(self._auth_token), + "Content-Type": "application/json", + "X-Amzn-Type-Version": + "com.amazon.device.messaging.ADMMessage@1.0", + "X-Amzn-Accept-Type": + "com.amazon.device.messaging.ADMSendResult@1.0", + "Accept": "application/json", + } + data = {} + if ttl: + data["expiresAfter"] = ttl + if collapseKey: + data["consolidationKey"] = collapseKey + data["data"] = payload + url = ("https://api.amazon.com/messaging/registrations" + "/{}/messages".format(reg_id)) + resp = self._request.post( + url, + json=data, + headers=headers, + timeout=self._timeout, + ) + # in fine tradition, the response message can sometimes be less than + # helpful. Still, good idea to include it anyway. + if resp.status_code != 200: + self._logger.error("Could not send ADM message: " + resp.text) + raise RouterException(resp.content) + + +class ADMRouter(object): + """Amazon Device Messaging Router Implementation""" + log = Logger() + dryRun = 0 + collapseKey = None + MAX_TTL = 2419200 + + def __init__(self, conf, router_conf, metrics): + """Create a new ADM router and connect to ADM""" + self.conf = conf + self.router_conf = router_conf + self.metrics = metrics + self.min_ttl = router_conf.get("ttl", 60) + timeout = router_conf.get("timeout", 10) + self.profiles = dict() + for profile in router_conf: + config = router_conf[profile] + if "client_id" not in config or "client_secret" not in config: + raise IOError("Profile info incomplete, missing id or secret") + self.profiles[profile] = ADMClient( + credentials=config, + logger=self.log, + metrics=self.metrics, + timeout=timeout) + self._base_tags = ["platform:adm"] + self.log.debug("Starting ADM router...") + + def amend_endpoint_response(self, response, router_data): + # type: (JSONDict, JSONDict) -> None + pass + + def register(self, uaid, router_data, app_id, *args, **kwargs): + # type: (str, JSONDict, str, *Any, **Any) -> None + """Validate that the ADM Registration ID is in the ``router_data``""" + if "token" not in router_data: + raise self._error("connect info missing ADM Instance 'token'", + status=401) + profile_id = app_id + if profile_id not in self.profiles: + raise self._error("Invalid ADM Profile", + status=410, errno=105, + uri=kwargs.get('uri'), + profile_id=profile_id) + # Assign a profile + router_data["creds"] = {"profile": profile_id} + + def route_notification(self, notification, uaid_data): + """Start the ADM notification routing, returns a deferred""" + # Kick the entire notification routing off to a thread + return deferToThread(self._route, notification, uaid_data) + + def _route(self, notification, uaid_data): + """Blocking ADM call to route the notification""" + router_data = uaid_data["router_data"] + # THIS MUST MATCH THE CHANNELID GENERATED BY THE REGISTRATION SERVICE + # Currently this value is in hex form. + data = {"chid": notification.channel_id.hex} + # Payload data is optional. The endpoint handler validates that the + # correct encryption headers are included with the data. + if notification.data: + data['body'] = notification.data + data['con'] = notification.headers['encoding'] + + if 'encryption' in notification.headers: + data['enc'] = notification.headers.get('encryption') + if 'crypto_key' in notification.headers: + data['cryptokey'] = notification.headers['crypto_key'] + + # registration_ids are the ADM instance tokens (specified during + # registration. + ttl = min(self.MAX_TTL, + max(notification.ttl or 0, self.min_ttl)) + + try: + adm = self.profiles[router_data['creds']['profile']] + adm.send( + reg_id=router_data.get("token"), + payload=data, + collapseKey=notification.topic, + ttl=ttl + ) + except RouterException: + raise # pragma nocover + except Timeout as e: + self.log.warn("ADM Timeout: %s" % e) + self.metrics.increment("notification.bridge.error", + tags=make_tags( + self._base_tags, + reason="timeout")) + raise RouterException("Server error", status_code=502, + errno=902, + log_exception=False) + except ConnectionError as e: + self.log.warn("ADM Unavailable: %s" % e) + self.metrics.increment("notification.bridge.error", + tags=make_tags( + self._base_tags, + reason="connection_unavailable")) + raise RouterException("Server error", status_code=502, + errno=902, + log_exception=False) + except ADMAuthError as e: + self.log.error("ADM unable to authorize: %s" % e) + self.metrics.increment("notification.bridge.error", + tags=make_tags( + self._base_tags, + reason="auth failure" + )) + raise RouterException("Server error", status_code=500, + errno=902, + log_exception=False) + except Exception as e: + self.log.error("Unhandled exception in ADM Routing: %s" % e) + raise RouterException("Server error", status_code=500) + location = "%s/m/%s" % (self.conf.endpoint_url, notification.version) + return RouterResponse(status_code=201, response_body="", + headers={"TTL": ttl, + "Location": location}, + logged_status=200) + + def _error(self, err, status, **kwargs): + """Error handler that raises the RouterException""" + self.log.debug(err, **kwargs) + return RouterException(err, status_code=status, response_body=err, + **kwargs) diff --git a/autopush/tests/test_integration.py b/autopush/tests/test_integration.py index bb085351..c60e36c9 100644 --- a/autopush/tests/test_integration.py +++ b/autopush/tests/test_integration.py @@ -11,6 +11,8 @@ from distutils.spawn import find_executable from StringIO import StringIO from httplib import HTTPResponse # noqa + +import pytest from mock import Mock, call, patch from unittest.case import SkipTest @@ -21,6 +23,7 @@ import websocket from cryptography.fernet import Fernet from jose import jws +from requests.exceptions import Timeout from typing import Optional # noqa from twisted.internet import reactor from twisted.internet.defer import inlineCallbacks, returnValue, Deferred @@ -1936,6 +1939,309 @@ def test_registration(self): assert ca_data['body'] == base64url_encode(data) +class TestADMBrideIntegration(IntegrationBase): + + class MockReply(object): + status_code = 200 + json = Mock(return_value=dict()) + + token = ("amzn1.adm-registration.v3.VeryVeryLongString0fA1phaNumericStuff" + + ("a" * 256)) + + def _add_router(self): + from autopush.router.adm import ADMRouter + adm = ADMRouter( + self.ep.conf, + { + "dev": { + "app_id": "amzn1.application.StringOfStuff", + "client_id": "amzn1.application-oa2-client.ev4nM0reStuff", + "client_secret": "deadbeef0000decafbad1111", + } + }, + self.ep.db.metrics, + ) + + self.ep.routers["adm"] = adm + self._mock_send = Mock() + self._mock_send.post = Mock() + self._mock_reply = self.MockReply + self._mock_send.post.return_value = self._mock_reply + for profile in adm.profiles: + adm.profiles[profile]._request = self._mock_send + + def test_bad_config(self): + from autopush.router.adm import ADMRouter + with pytest.raises(IOError): + ADMRouter( + self.ep.conf, + { + "dev": { + "app_id": "amzn1.application.StringOfStuff", + "client_id": + "amzn1.application-oa2-client.ev4nM0reStuff", + "collapseKey": "simplepush", + } + }, + self.ep.db.metrics, + ) + + @inlineCallbacks + def test_missing_token_registration(self): + self._add_router() + url = "{}/v1/{}/{}/registration".format( + self.ep.conf.endpoint_url, + "adm", + "dev", + ) + self._mock_reply.status_code = 200 + self._mock_reply.json.return_value = { + "access_token": "token", + "expires_in": 3000 + } + response, body = yield _agent("POST", url, body=json.dumps( + {"foo": self.token} + )) + assert response.code == 401 + url = "{}/v1/{}/{}/registration".format( + self.ep.conf.endpoint_url, + "adm", + "foo", + ) + response, body = yield _agent("POST", url, body=json.dumps( + {"token": self.token} + )) + assert response.code == 410 + + @inlineCallbacks + def test_successful(self): + self._add_router() + url = "{}/v1/{}/{}/registration".format( + self.ep.conf.endpoint_url, + "adm", + "dev", + ) + self._mock_reply.status_code = 200 + self._mock_reply.json.return_value = { + "access_token": "token", + "expires_in": 3000 + } + response, body = yield _agent("POST", url, body=json.dumps( + {"token": self.token} + )) + assert response.code == 200 + jbody = json.loads(body) + + print("Response = {}", body) + + data = ("\xa2\xa5\xbd\xda\x40\xdc\xd1\xa5\xf9\x6a\x60\xa8\x57\x7b\x48" + "\xe4\x43\x02\x5a\x72\xe0\x64\x69\xcd\x29\x6f\x65\x44\x53\x78" + "\xe1\xd9\xf6\x46\x26\xce\x69") + crypto_key = ("keyid=p256dh;dh=BAFJxCIaaWyb4JSkZopERL9MjXBeh3WdBxew" + "SYP0cZWNMJaT7YNaJUiSqBuGUxfRj-9vpTPz5ANmUYq3-u-HWOI") + salt = "keyid=p256dh;salt=S82AseB7pAVBJ2143qtM3A" + content_encoding = "aesgcm" + + self._mock_reply.json.return_value = dict(access_token="access.123") + + response, body = yield _agent( + 'POST', + str(jbody['endpoint']), + headers=Headers({ + "crypto-key": [crypto_key], + "encryption": [salt], + "ttl": ["0"], + "content-encoding": [content_encoding], + "topic": ["simplepush"] + }), + body=data + ) + print ("Response: %s" % response.code) + assert response.code == 201 + + ca_data = self._mock_send.post.mock_calls[1][2]['json']['data'] + # ChannelID here MUST match what we got from the registration call. + # Currently, this is a lowercase, hex UUID without dashes. + assert ca_data['chid'] == jbody['channelID'] + assert ca_data['con'] == content_encoding + assert ca_data['cryptokey'] == crypto_key + assert ca_data['enc'] == salt + assert ca_data['body'] == base64url_encode(data) + + @inlineCallbacks + def test_bad_registration(self): + self._add_router() + url = "{}/v1/{}/{}/registration".format( + self.ep.conf.endpoint_url, + "adm", + "dev", + ) + self._mock_reply.status_code = 400 + response, body = yield _agent("POST", url, body=json.dumps( + {"token": self.token[:-100]} + )) + assert response.code == 400 + + @inlineCallbacks + def test_bad_token_refresh(self): + self._add_router() + url = "{}/v1/{}/{}/registration".format( + self.ep.conf.endpoint_url, + "adm", + "dev", + ) + self._mock_reply.status_code = 200 + response, body = yield _agent("POST", url, body=json.dumps( + {"token": self.token} + )) + assert response.code == 200 + jbody = json.loads(body) + data = ("\xa2\xa5\xbd\xda\x40\xdc\xd1\xa5\xf9\x6a\x60\xa8\x57\x7b\x48" + "\xe4\x43\x02\x5a\x72\xe0\x64\x69\xcd\x29\x6f\x65\x44\x53\x78" + "\xe1\xd9\xf6\x46\x26\xce\x69") + crypto_key = ("keyid=p256dh;dh=BAFJxCIaaWyb4JSkZopERL9MjXBeh3WdBxew" + "SYP0cZWNMJaT7YNaJUiSqBuGUxfRj-9vpTPz5ANmUYq3-u-HWOI") + salt = "keyid=p256dh;salt=S82AseB7pAVBJ2143qtM3A" + content_encoding = "aesgcm" + self._mock_reply.status_code = 400 + self._mock_reply.text = "Test error" + self._mock_reply.content = "Test content" + response, body = yield _agent( + 'POST', + str(jbody['endpoint']), + headers=Headers({ + "crypto-key": [crypto_key], + "encryption": [salt], + "ttl": ["0"], + "content-encoding": [content_encoding], + }), + body=data + ) + assert response.code == 500 + self.flushLoggedErrors() + + @inlineCallbacks + def test_bad_sends(self): + from requests.exceptions import ConnectionError + self._add_router() + url = "{}/v1/{}/{}/registration".format( + self.ep.conf.endpoint_url, + "adm", + "dev", + ) + self._mock_reply.status_code = 200 + response, body = yield _agent("POST", url, body=json.dumps( + {"token": self.token} + )) + assert response.code == 200 + jbody = json.loads(body) + crypto_key = ("keyid=p256dh;dh=BAFJxCIaaWyb4JSkZopERL9MjXBeh3WdBxew" + "SYP0cZWNMJaT7YNaJUiSqBuGUxfRj-9vpTPz5ANmUYq3-u-HWOI") + salt = "keyid=p256dh;salt=S82AseB7pAVBJ2143qtM3A" + content_encoding = "aesgcm" + + # Test ADMAuth Error + self._mock_reply.status_code = 400 + self._mock_reply.text = "Test error" + self._mock_reply.content = "Test content" + response, body = yield _agent( + "POST", + str(jbody['endpoint']), + headers=Headers({ + "crypto-key": [crypto_key], + "encryption": [salt], + "ttl": ["0"], + "content-encoding": [content_encoding], + }), + body="BunchOfStuff" + ) + assert response.code == 500 + rbody = json.loads(body) + assert rbody["errno"] == 902 + self.flushLoggedErrors() + + # fake a valid ADM key + self.ep.routers["adm"].profiles["dev"]._auth_token = "SomeToken" + self.ep.routers["adm"].profiles["dev"]._token_exp = time.time() + 300 + + # Test ADM reply Error + self._mock_reply.status_code = 400 + self._mock_reply.text = "Test error" + self._mock_reply.content = "Test content" + response, body = yield _agent( + "POST", + str(jbody['endpoint']), + headers=Headers({ + "crypto-key": [crypto_key], + "encryption": [salt], + "ttl": ["0"], + "content-encoding": [content_encoding], + }), + body="BunchOfStuff" + ) + assert response.code == 500 + rbody = json.loads(body) + + # test Connection Error + def fcon(*args, **kwargs): + raise ConnectionError + self._mock_send.post.side_effect = fcon + response, body = yield _agent( + "POST", + str(jbody['endpoint']), + headers=Headers({ + "crypto-key": [crypto_key], + "encryption": [salt], + "ttl": ["0"], + "content-encoding": [content_encoding], + }), + body="BunchOfStuff" + ) + assert response.code == 502 + rbody = json.loads(body) + assert rbody["errno"] == 902 + self.flushLoggedErrors() + + # test timeout Error + def fcon(*args, **kwargs): + raise Timeout + self._mock_send.post.side_effect = fcon + response, body = yield _agent( + "POST", + str(jbody['endpoint']), + headers=Headers({ + "crypto-key": [crypto_key], + "encryption": [salt], + "ttl": ["0"], + "content-encoding": [content_encoding], + }), + body="BunchOfStuff" + ) + assert response.code == 502 + rbody = json.loads(body) + assert rbody["errno"] == 902 + self.flushLoggedErrors() + + # test random Exception + def fcon(*args, **kwargs): + raise Exception + self._mock_send.post.side_effect = fcon + response, body = yield _agent( + "POST", + str(jbody['endpoint']), + headers=Headers({ + "crypto-key": [crypto_key], + "encryption": [salt], + "ttl": ["0"], + "content-encoding": [content_encoding], + }), + body="BunchOfStuff" + ) + assert response.code == 500 + + self.flushLoggedErrors() + + class TestAPNSBridgeIntegration(IntegrationBase): class m_response: diff --git a/autopush/tests/test_z_main.py b/autopush/tests/test_z_main.py index 5f6f8dd4..e04de4a1 100644 --- a/autopush/tests/test_z_main.py +++ b/autopush/tests/test_z_main.py @@ -308,6 +308,14 @@ class TestArg(AutopushConfig): _no_sslcontext_cache = False aws_ddb_endpoint = None no_table_rotation = False + adm_creds = json.dumps({ + "dev": + { + "app_id": "amzn1.application.StringOfStuff", + "client_id": "amzn1.application-oa2-client.ev4nM0reStuff", + "client_secret": "deadbeef0000decafbad1111" + } + }) def setUp(self): patchers = [ @@ -405,6 +413,12 @@ def test_conf(self, *args): assert app.routers["apns"].router_conf['firefox']['cert'] == \ "cert.file" assert app.routers["apns"].router_conf['firefox']['key'] == "key.file" + assert app.routers["adm"].router_conf['dev']['app_id'] == \ + "amzn1.application.StringOfStuff" + assert app.routers["adm"].router_conf['dev']['client_id'] == \ + "amzn1.application-oa2-client.ev4nM0reStuff" + assert app.routers["adm"].router_conf['dev']['client_secret'] == \ + "deadbeef0000decafbad1111" def test_bad_senders(self): old_list = self.TestArg.senderid_list diff --git a/autopush/web/registration.py b/autopush/web/registration.py index 44fb4771..267a6fba 100644 --- a/autopush/web/registration.py +++ b/autopush/web/registration.py @@ -73,6 +73,7 @@ class TokenSchema(SubInfoSchema): valid_token = validate.Regexp("^[^ ]{8,}$") +valid_adm_token = validate.Regexp("^amzn1.adm-registration.v3.[^ ]{256,}$") class GCMTokenSchema(SubInfoSchema): @@ -88,6 +89,12 @@ class APNSTokenSchema(SubInfoSchema): aps = fields.Dict(allow_none=True) +class ADMTokenSchema(SubInfoSchema): + token = fields.Str(allow_none=False, + validate=valid_adm_token, + error="Missing required token value") + + ############################################################# # URI argument validation ############################################################# @@ -174,7 +181,7 @@ def validate_auth(self, data): def conditional_token_check(object_dict, parent_dict): ptype = parent_dict['path_kwargs']['type'] # Basic "bozo-filter" to prevent customer surprises later. - if ptype not in ['apns', 'fcm', 'gcm', 'webpush', 'test']: + if ptype not in ['apns', 'fcm', 'gcm', 'webpush', 'adm', 'test']: raise InvalidRequest("Unknown registration type", status_code=400, errno=108, @@ -183,6 +190,8 @@ def conditional_token_check(object_dict, parent_dict): return GCMTokenSchema() if ptype == 'apns': return APNSTokenSchema() + if ptype == 'adm': + return ADMTokenSchema() return TokenSchema() diff --git a/autopush/web/webpush.py b/autopush/web/webpush.py index 41730518..8c165ad7 100644 --- a/autopush/web/webpush.py +++ b/autopush/web/webpush.py @@ -54,7 +54,7 @@ # Base64 URL validation VALID_BASE64_URL = re.compile(r'^[0-9A-Za-z\-_]+=*$') -VALID_ROUTER_TYPES = ["simplepush", "webpush", "gcm", "fcm", "apns"] +VALID_ROUTER_TYPES = ["simplepush", "webpush", "gcm", "fcm", "apns", "adm"] class WebPushSubscriptionSchema(Schema): diff --git a/configs/autopush_endpoint.ini.sample b/configs/autopush_endpoint.ini.sample index ac5bf740..3e323890 100644 --- a/configs/autopush_endpoint.ini.sample +++ b/configs/autopush_endpoint.ini.sample @@ -41,3 +41,6 @@ port = 8082 ; Enable a secondary port to listen for notifications with HAProxy ; Proxy Protocol handling #proxy_protocol_port = 8083 + +; Amazon Device Messaging credentials +#adm_creds={"profileId":{"client_id":"...","client_secret":"..."}} diff --git a/docs/adm.rst b/docs/adm.rst new file mode 100644 index 00000000..0903163d --- /dev/null +++ b/docs/adm.rst @@ -0,0 +1,89 @@ +Configuring the Amazon Device Messaging Bridge +============================================== + +`ADM `_ requires +credentials that are provided on the `Amazon Developer portal +`_ page. Note, this is different than +the *Amazon Web Services* page. + +If you've not already done so, create a new App under the **Apps & Services** +tab. You will need to create an app so that you can associate a Security +Profile to it. + +Device Messaging can be created by generating a new *Security Profile* (located +under the *Security Profiles* sub-tab. If specifying for Android or Kindle, +you will need to provide the Java Package name you've used to identify the +application (e.g. `org.mozilla.services.admpushdemo`) + +You will need to provide the MD5 Signature and SHA256 Signature for the +package's Certificate. + +Getting the Key Signatures +-------------------------- + +Amazon provides `some instructions `_ +for getting the signature values of the `CERT.RSA` file. Be aware that android +and ADM are both moving targets and some information may no longer be correct. + +I was able to use the `keytool` to fetch out the SHA256 signature, but had to +get the MD5 signature from inside **Android Studio** by looking under the +*Gradle* tab, then under the Project (root) + +.. code-block:: text + + > Task + > android + * signingReport + +You do not need the SHA1: key provided from the signingReport output. + +Once the fields have been provided an API Key will be generated. This is a +long JWT that must be stored in a file named `api_key.txt` located in the +`/assets` directory. The file should only contain the key. Extra white +space, comments, or other data will cause the key to fail to be read. + +This file *MUST* be included with any client application that uses the ADM +bridge. Please note that the only way to test ADM messaging features is to +side load the application on a FireTV or Kindle device. + +Configuring the server +---------------------- + +The server requires the *Client ID* and *Client Secret* from the ADM Security +Profile page. Since a given server may need to talk to different +applications using different profiles, the server can be configured to use +one of several profiles. + +The `autopush_endpoint.ini` file may contain the `adm_creds` option. This is +a JSON structure similar to the APNS configuration. The configuration can +specify one or more "profiles". Each profile contains a "client_id" and +"client_secret". + +For example, let's say that we want to have a "dev" (for developers) and a +"stage" (for testing). We could specify the profiles as: + +.. code-block:: json + + { + "dev": { + "client_id": "amzn1.application.0e7299...", + "client_secret": "559dac53757a571d2fee78e5fcb2..." + }, + "stage": { + "client_id": "amzn1.application.0e7300...", + "client_secret": "589dcc53957a971d2fee78e5fee4..." + }, + } + +For the configuration, we'd collapse this to one line, e.g. + +.. code-block:: text + + adm_creds={"dev":{"client_id":"amzn1.application.0e7299...","client_secret":"559dac53757a571d2fee78e5fcb2..."},"stage":{"client_id":"amzn1.application.0e7300...","client_secret": "589dcc53957a971d2fee78e5fee4..."},} + +Much like other systems, a sender invokes the profile by using it in the +Registration URL. e.g. to register a new endpoint using the `dev` profile: + + `https://push.service.mozilla.org/v1/adm/dev/registration/` + diff --git a/docs/glossary.rst b/docs/glossary.rst index 73dcd930..212bfc1e 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -36,7 +36,7 @@ Glossary Every :term:`UAID` that connects has a router type. This indicates the type of routing to use when dispatching notifications. For most clients, this value will be ``webpush``. Clients using :term:`Bridging` it will use either - ``gcm``, ``fcm``, or ``apns``. + ``gcm``, ``fcm``, ``apns``, or ``adm``. Subscription A unique route between an :term:`AppServer` and the Application. May diff --git a/docs/install.rst b/docs/install.rst index 1c60f253..71e5d032 100644 --- a/docs/install.rst +++ b/docs/install.rst @@ -144,7 +144,11 @@ directs autopush to your local DynamoDB instance. .. _`properly set up your boto config file`: http://boto3.readthedocs.io/en/docs/guide/quickstart.html#configuration .. _`cryptography`: https://cryptography.io/en/latest/installation + +Configuring for Third Party Bridge services: + .. toctree:: :maxdepth: 1 apns.rst + adm.rst