diff --git a/.env.template b/.env.template index 3e8e4ec..30e33e5 100644 --- a/.env.template +++ b/.env.template @@ -1,3 +1,7 @@ +# SERVER (defaults shown; used by `python main.py`) +# HOST=127.0.0.1 +# PORT=8000 + # SECURITY # Clients must send this on protected endpoints as: Authorization: Bearer API_KEY=CHANGE_ME @@ -11,11 +15,14 @@ DB_NAME=postgres DB_USERNAME=postgres DB_PASSWORD=postgres -# APNS SETTINGS +# APNS SETTINGS (token auth, recommended) APNS_KEY_ID=YOUR_KEY_ID APNS_TEAM_ID=YOUR_TEAM_ID APNS_APP_BUNDLE_ID=YOUR_APP_BUNDLE_ID APNS_AUTH_KEY_PATH=PATH_TO_YOUR_AUTH_KEY +# Certificate auth instead: PEM file with certificate and private key +# APNS_CERT_PATH=path/to/cert.pem +# APNS_CERT_PASSWORD= # Set to true to target the APNs sandbox (development builds) APNS_USE_SANDBOX=false diff --git a/README.md b/README.md index 734b521..9e2046a 100644 --- a/README.md +++ b/README.md @@ -1,48 +1,55 @@ # Push Notification Server Framework ## Introduction -`PushNotificationServerFramework` is an open-source project designed to offer a template for creating remote push notification servers for iOS applications via [Apple Push Notification service](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns). - -It simplifies the process of registering devices with the server and provides services for storing, fetching, and clearing device information, in addition to providing endpoints for sending push notifications to these devices. +`PushNotificationServerFramework` is an open-source template for building remote push notification servers for iOS applications using the [Apple Push Notification service](https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/sending_notification_requests_to_apns). It handles device registration and delivers notifications through APNs. ### Features -- **Premade Models and Entities**: Includes premade models and entities for device and message information. -- **Device Endpoints**: Facilitates registering and fetching devices with the server. -- **Push Endpoints**: Provides endpoints for sending push notifications to devices. -- **Data Persistence**: Utilizes SQLAlchemy ORM for managing database operations. -- **Pydantic Models**: Ensures validation and serialization of device and message entities. -- **Modified APNS2**: Includes a modified version of the Python `apns2` package, updated for Python 3.11 compatibility. -- **FastAPI Framework**: Leverages the FastAPI framework for efficient and easy server development. +- **Device registration**: Idempotent registration keyed by device token, with optional device metadata. +- **Push delivery**: Per-token results; tokens Apple reports as gone are pruned automatically. +- **APNs client**: Persistent HTTP/2 connection, token (.p8) or certificate authentication, current APNs push types and error reasons. +- **API key authentication**: Bearer-token protection on push and admin endpoints. +- **Data persistence**: SQLAlchemy 2.0 with PostgreSQL. +- **Tested**: Unit, API, and Postgres-backed integration suites run in CI with lint and type checks. ### Project Structure -- `apis/`: Contains the API endpoints for the server. -- `entities/`: Contains the SQLAlchemy entities for the server. -- `models/`: Contains the Pydantic models for the server. -- `push/`: Contains the push notification services for the server. -- `services/`: Contains the services for the server. -- `utils/`: Contains utility functions for the server. +- `apis/`: API endpoints. +- `auth.py`: API key dependency. +- `database.py`: Database engine and session dependency. +- `entities/`: SQLAlchemy entities. +- `models/`: Pydantic request and response models. +- `push/`: APNs client and push handling. +- `services/`: Application services. +- `tests/`: Unit, API, and integration tests. +- `utils/`: Environment helpers. ## Prerequisites -Before installing this repository, ensure you have the following: -- Python 3.11 -- Pip package manager +- Python 3.11+ +- PostgreSQL (any reachable instance; a disposable Docker one is shown below) -## Installation -To install this repository, follow these steps: +## Quickstart +```bash +git clone https://github.com/j0shcap/PushNotificationServerFramework.git +cd PushNotificationServerFramework +python -m venv .venv && source .venv/bin/activate +pip install -r requirements.txt +cp .env.template .env # then fill in your values +docker run -d --name pnsf-postgres -e POSTGRES_PASSWORD=postgres -p 5432:5432 postgres:16-alpine +python main.py +``` +Interactive API documentation is served at `http://127.0.0.1:8000/docs`. -1. Clone the repository: - ```bash - git clone https://github.com/JoshCap20/PushNotificationServerFramework.git - ``` -2. Install required dependencies: - ```bash - pip install -r requirements.txt - ``` +## Running in Production +`python main.py` binds to `127.0.0.1:8000`; set `HOST` and `PORT` to override. Behind a reverse proxy, run uvicorn directly with workers: +```bash +uvicorn main:app --host 0.0.0.0 --port 8000 --workers 2 +``` +Terminate TLS at the proxy — the API key travels in a header and must never cross plain HTTP. ## Configuration -Configure the application by creating an .env file based off the template. Set the necessary parameters like database connection parameters and APNs identifiers. +Configure the application through the `.env` file. Database and APNs identifiers are required; the notable options: - `API_KEY` (required): the secret protected endpoints require. The server refuses to start without it. +- `APNS_CERT_PATH`: switches from token auth (the default, recommended by Apple) to certificate auth. Points to a PEM file containing the provider certificate and private key; `APNS_CERT_PASSWORD` supplies its passphrase if any. - `APNS_USE_SANDBOX`: set to `true` when testing with development builds; their device tokens are only valid against the APNs sandbox environment. - `CORS_ORIGINS`: comma-separated origins allowed to make cross-origin requests. Unset by default, which disables CORS entirely — iOS apps do not use CORS; only set this when serving a web frontend. - `DB_ECHO`: set to `true` to log SQL statements during development. Off by default because statements include device tokens. @@ -56,20 +63,18 @@ Authorization: Bearer Requests without a valid key receive `401 Unauthorized`. `/devices/register` is deliberately open: it is called by the iOS app itself, and shipping the key inside the app binary would expose it. The worst an unauthenticated caller can do is register junk tokens, which APNs pruning removes on the next push. -## Running the Server -To start the server, run the following command: -```bash -python main.py -``` - ## Client-Side Implementation -To implement push notifications in an iOS application, follow the steps below: -1. Register the application for push notifications. - - See [Apple Developer documentation](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns) for more information. +To implement push notifications in an iOS application: +1. Register the application for push notifications ([Apple Developer documentation](https://developer.apple.com/documentation/usernotifications/registering_your_app_with_apns)). 2. Request permission from the user to send push notifications. -3. Register the device with the server. - - Post the device token to the `/devices/register` endpoint. - +3. Post the device token to the `/devices/register` endpoint. APNs hands the app the token as raw `Data`; convert it to the hex string this server expects: + ```swift + func application(_ application: UIApplication, + didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let token = deviceToken.map { String(format: "%02x", $0) }.joined() + // POST ["token": token] to /devices/register + } + ``` ## Device Endpoints #### Register a Device @@ -78,9 +83,11 @@ To implement push notifications in an iOS application, follow the steps below: - **Body**: ```json { - "token": "unique_device_id", + "token": "hex_apns_device_token" } ``` + Also accepts the optional device fields listed under Design Notes. Server-managed fields (`id`, timestamps) are ignored if sent. +- **Response**: The registered device, including its server-assigned `id` and timestamps. Registration is idempotent by token: re-registering updates the stored fields. #### Retrieve Devices Information - **Endpoint**: `/devices/all` @@ -154,4 +161,4 @@ This project is licensed under the [MIT License](LICENSE). - Pydantic for data validation and serialization. - SQLAlchemy ORM for database management. - FastAPI for the server framework. -- Modified Python `apns2` package for handling Apple Push Notification services. +- The Python `apns2` package, from which the vendored APNs client is derived. diff --git a/apis/devices.py b/apis/devices.py index 7f2398e..51906fb 100644 --- a/apis/devices.py +++ b/apis/devices.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, Depends from auth import require_api_key -from models import Device +from models import Device, DeviceRegistration from services import DeviceService router = APIRouter( @@ -12,18 +12,18 @@ @router.post("/register", response_model=Device) -def register_device(device: Device, device_service: DeviceService = Depends()): +def register_device(registration: DeviceRegistration, device_service: DeviceService = Depends()): """ Registers a new device with the push notification framework. Args: - device (Device): The device to register. + registration (DeviceRegistration): The device information to register. device_service (DeviceService): An instance of the DeviceService class. Injected by FastAPI. Returns: Device: The registered device. """ - return device_service.register_device(device) + return device_service.register_device(registration) @router.get("/all", response_model=list[Device], dependencies=[Depends(require_api_key)]) diff --git a/database.py b/database.py index 1f59571..769f593 100644 --- a/database.py +++ b/database.py @@ -6,14 +6,10 @@ from utils import getenv, getenv_bool -def _engine_str(name: str = getenv("DB_NAME")) -> str: +def _engine_str() -> str: """ Helper function for reading settings from environment variables to produce connection string. - Arguments: - name (str): The name of the database. Defaults to the value of the - "DB_NAME" environment variable. - Returns: str: The connection string for the database. """ @@ -22,6 +18,7 @@ def _engine_str(name: str = getenv("DB_NAME")) -> str: password = getenv("DB_PASSWORD") host = getenv("DB_HOST") port = getenv("DB_PORT") + name = getenv("DB_NAME") return f"{dialect}://{user}:{password}@{host}:{port}/{name}" diff --git a/entities/device_entity.py b/entities/device_entity.py index ca366fa..efb60b8 100644 --- a/entities/device_entity.py +++ b/entities/device_entity.py @@ -3,7 +3,7 @@ from sqlalchemy import DateTime, String, func from sqlalchemy.orm import Mapped, mapped_column -from models import Device +from models import Device, DeviceRegistration from .entity_base import EntityBase @@ -52,15 +52,14 @@ def to_model(self) -> Device: ) @classmethod - def from_model(cls, model: Device) -> "DeviceEntity": + def from_registration(cls, registration: DeviceRegistration) -> "DeviceEntity": + """Builds a new entity from client-supplied fields; the id and + timestamps are server-managed and never taken from the request.""" return cls( - id=model.id, - token=model.token, - name=model.name, - systemName=model.systemName, - systemVersion=model.systemVersion, - model=model.model, - localizedModel=model.localizedModel, - created_at=model.created_at, - updated_at=model.updated_at, + token=registration.token, + name=registration.name, + systemName=registration.systemName, + systemVersion=registration.systemVersion, + model=registration.model, + localizedModel=registration.localizedModel, ) diff --git a/main.py b/main.py index eb59851..d89cdfa 100644 --- a/main.py +++ b/main.py @@ -70,4 +70,4 @@ async def root(): if __name__ == "__main__": import uvicorn - uvicorn.run(app) + uvicorn.run(app, host=getenv("HOST", "127.0.0.1"), port=int(getenv("PORT", "8000"))) diff --git a/models/__init__.py b/models/__init__.py index 5ea4f62..e80fc61 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -2,7 +2,7 @@ This module contains the Device and Message models for serializing and deserializing data. """ -from .device import Device +from .device import Device, DeviceRegistration from .message import Message -__all__ = ["Device", "Message"] +__all__ = ["Device", "DeviceRegistration", "Message"] diff --git a/models/device.py b/models/device.py index 8daaeef..a32d975 100644 --- a/models/device.py +++ b/models/device.py @@ -3,28 +3,37 @@ from pydantic import BaseModel -class Device(BaseModel): +class DeviceRegistration(BaseModel): """ - Represents a device that can receive push notifications. + Client-supplied device information for registration. Attributes: - id (int | None): The unique identifier for the device. token (str): The device token used for push notifications. Required. name (str): The name of the device. systemName (str): The name of the operating system running on the device. systemVersion (str): The version of the operating system running on the device. model (str): The model of the device. localizedModel (str): The localized model of the device. - created_at (datetime | None): The date and time the device was created. - updated_at (datetime | None): The date and time the device was last updated. """ - id: int | None = None token: str name: str | None = None systemName: str | None = None systemVersion: str | None = None model: str | None = None localizedModel: str | None = None + + +class Device(DeviceRegistration): + """ + A registered device, including server-managed fields. + + Attributes: + id (int): The unique identifier for the device. Assigned by the server. + created_at (datetime | None): When the device was first registered. + updated_at (datetime | None): When the device was last updated. + """ + + id: int created_at: datetime | None = None updated_at: datetime | None = None diff --git a/push/apn_handler/__init__.py b/push/apn_handler/__init__.py index 2fb04f6..9f39ad8 100644 --- a/push/apn_handler/__init__.py +++ b/push/apn_handler/__init__.py @@ -1,5 +1,12 @@ from .client import APNsClient, Notification -from .credentials import Credentials, TokenCredentials +from .credentials import CertificateCredentials, Credentials, TokenCredentials from .payload import Payload -__all__ = ["APNsClient", "Credentials", "Notification", "Payload", "TokenCredentials"] +__all__ = [ + "APNsClient", + "CertificateCredentials", + "Credentials", + "Notification", + "Payload", + "TokenCredentials", +] diff --git a/push/apn_handler/client.py b/push/apn_handler/client.py index 33340f4..40b7d24 100644 --- a/push/apn_handler/client.py +++ b/push/apn_handler/client.py @@ -1,22 +1,26 @@ -import collections import json import logging from collections.abc import Iterable -from enum import Enum +from enum import StrEnum +from typing import NamedTuple import httpx -from .credentials import CertificateCredentials, Credentials, TokenCredentials +from .credentials import Credentials, TokenCredentials from .errors import exception_class_for_reason from .payload import Payload +DEFAULT_REQUEST_TIMEOUT = 10.0 -class NotificationPriority(Enum): +logger = logging.getLogger(__name__) + + +class NotificationPriority(StrEnum): Immediate = "10" Delayed = "5" -class NotificationType(Enum): +class NotificationType(StrEnum): Alert = "alert" Background = "background" VoIP = "voip" @@ -30,11 +34,24 @@ class NotificationType(Enum): PushToTalk = "pushtotalk" -Notification = collections.namedtuple("Notification", ["token", "payload"]) +class Notification(NamedTuple): + token: str + payload: Payload + DEFAULT_APNS_PRIORITY = NotificationPriority.Immediate -logger = logging.getLogger(__name__) +# apns-push-type values inferred from conventional apns-topic suffixes. +_PUSH_TYPE_BY_TOPIC_SUFFIX = { + ".voip-ptt": NotificationType.PushToTalk, + ".voip": NotificationType.VoIP, + ".complication": NotificationType.Complication, + ".pushkit.fileprovider": NotificationType.FileProvider, + ".push-type.liveactivity": NotificationType.LiveActivity, + ".location-query": NotificationType.Location, + ".push-type.widgets": NotificationType.Widgets, + ".push-type.controls": NotificationType.Controls, +} class APNsClient: @@ -46,44 +63,24 @@ class APNsClient: def __init__( self, - credentials: Credentials | str, + credentials: Credentials, use_sandbox: bool = False, use_alternative_port: bool = False, - proto: str | None = None, json_encoder: type | None = None, - password: str | None = None, - proxy_host: str | None = None, - proxy_port: int | None = None, - heartbeat_period: float | None = None, ) -> None: - self.__credentials: Credentials - if isinstance(credentials, str): - self.__credentials = CertificateCredentials(credentials, password) - else: - self.__credentials = credentials - - self._init_connection(use_sandbox, use_alternative_port, proto, proxy_host, proxy_port) - - if heartbeat_period: - raise NotImplementedError("heartbeat not supported") - - self.__json_encoder = json_encoder + self._credentials = credentials + self._json_encoder = json_encoder + self._server = self.SANDBOX_SERVER if use_sandbox else self.LIVE_SERVER + self._port = self.ALTERNATIVE_PORT if use_alternative_port else self.DEFAULT_PORT # APNs expects providers to keep connections open across requests; # opening one per notification is treated as abusive by Apple. - ssl_context = self.__credentials.ssl_context - self.__http_client = httpx.Client(http2=True, verify=ssl_context if ssl_context else True) - - def _init_connection( - self, - use_sandbox: bool, - use_alternative_port: bool, - proto: str | None, - proxy_host: str | None, - proxy_port: int | None, - ) -> None: - self.__server = self.SANDBOX_SERVER if use_sandbox else self.LIVE_SERVER - self.__port = self.ALTERNATIVE_PORT if use_alternative_port else self.DEFAULT_PORT + ssl_context = credentials.ssl_context + self._http_client = httpx.Client( + http2=True, + verify=ssl_context if ssl_context else True, + timeout=DEFAULT_REQUEST_TIMEOUT, + ) def send_notification( self, @@ -94,10 +91,9 @@ def send_notification( expiration: int | None = None, collapse_id: str | None = None, ) -> None: - status, reason = self.send_notification_sync( + status, reason = self._send( token_hex, notification, - self.__http_client, topic, priority, expiration, @@ -107,11 +103,54 @@ def send_notification( if status != 200: raise exception_class_for_reason(reason)(reason) - def send_notification_sync( + def send_notification_batch( + self, + notifications: Iterable[Notification], + topic: str | None = None, + priority: NotificationPriority = NotificationPriority.Immediate, + expiration: int | None = None, + collapse_id: str | None = None, + push_type: NotificationType | None = None, + ) -> dict[str, str]: + """ + Send a notification to a list of tokens. + + Returns a dictionary mapping each token to "Success", the reason + string APNs answered with, or "ConnectionFailed" for a network error. + A failure for one token does not prevent delivery to the others. + """ + results = {} + + for notification in notifications: + logger.info("Sending to token %s", notification.token) + try: + status, reason = self._send( + notification.token, + notification.payload, + topic, + priority, + expiration, + collapse_id, + push_type, + ) + except httpx.HTTPError as error: + logger.warning("Network error sending to token %s: %r", notification.token, error) + results[notification.token] = "ConnectionFailed" + continue + result = "Success" if status == 200 else reason + logger.info("Got response for %s: %s", notification.token, result) + results[notification.token] = result + + return results + + def close(self) -> None: + """Close the underlying HTTP connection to APNs.""" + self._http_client.close() + + def _send( self, token_hex: str, notification: Payload, - client: httpx.Client, topic: str | None = None, priority: NotificationPriority = NotificationPriority.Immediate, expiration: int | None = None, @@ -120,7 +159,7 @@ def send_notification_sync( ) -> tuple[int, str]: json_str = json.dumps( notification.dict(), - cls=self.__json_encoder, + cls=self._json_encoder, ensure_ascii=False, separators=(",", ":"), ) @@ -128,41 +167,13 @@ def send_notification_sync( headers = {} - inferred_push_type: str | None = None if topic is not None: headers["apns-topic"] = topic - if topic.endswith(".voip-ptt"): - inferred_push_type = NotificationType.PushToTalk.value - elif topic.endswith(".voip"): - inferred_push_type = NotificationType.VoIP.value - elif topic.endswith(".complication"): - inferred_push_type = NotificationType.Complication.value - elif topic.endswith(".pushkit.fileprovider"): - inferred_push_type = NotificationType.FileProvider.value - elif topic.endswith(".push-type.liveactivity"): - inferred_push_type = NotificationType.LiveActivity.value - elif topic.endswith(".location-query"): - inferred_push_type = NotificationType.Location.value - elif topic.endswith(".push-type.widgets"): - inferred_push_type = NotificationType.Widgets.value - elif topic.endswith(".push-type.controls"): - inferred_push_type = NotificationType.Controls.value - elif any( - [ - notification.alert is not None, - notification.badge is not None, - notification.sound is not None, - ] - ): - inferred_push_type = NotificationType.Alert.value - else: - inferred_push_type = NotificationType.Background.value - - if push_type: - inferred_push_type = push_type.value - - if inferred_push_type: - headers["apns-push-type"] = inferred_push_type + + if push_type is None: + push_type = self._infer_push_type(topic, notification) + if push_type is not None: + headers["apns-push-type"] = push_type.value if priority != DEFAULT_APNS_PRIORITY: headers["apns-priority"] = priority.value @@ -170,18 +181,30 @@ def send_notification_sync( if expiration is not None: headers["apns-expiration"] = str(expiration) - if isinstance(self.__credentials, TokenCredentials): - auth_header = self.__credentials.get_authorization_header(topic) - if auth_header is not None: - headers["authorization"] = auth_header + if isinstance(self._credentials, TokenCredentials): + headers["authorization"] = self._credentials.get_authorization_header() if collapse_id is not None: headers["apns-collapse-id"] = collapse_id - url = f"https://{self.__server}:{self.__port}/3/device/{token_hex}" - response = client.post(url, headers=headers, content=json_payload) + url = f"https://{self._server}:{self._port}/3/device/{token_hex}" + response = self._http_client.post(url, headers=headers, content=json_payload) return response.status_code, self._extract_reason(response) + @staticmethod + def _infer_push_type(topic: str | None, notification: Payload) -> NotificationType | None: + if topic is None: + return None + for suffix, push_type in _PUSH_TYPE_BY_TOPIC_SUFFIX.items(): + if topic.endswith(suffix): + return push_type + if any( + value is not None + for value in (notification.alert, notification.badge, notification.sound) + ): + return NotificationType.Alert + return NotificationType.Background + @staticmethod def _extract_reason(response: httpx.Response) -> str: """Extract the 'reason' field from an APNs error response body. @@ -204,62 +227,3 @@ def _extract_reason(response: httpx.Response) -> str: if response.status_code == 410: return "Unregistered" return f"HTTPError{response.status_code}" - - def get_notification_result(self, status: int, reason: str) -> str: - """ - Get result for specified stream - The function returns: 'Success' or 'failure reason' - """ - if status == 200: - return "Success" - else: - return reason - - def send_notification_batch( - self, - notifications: Iterable[Notification], - topic: str | None = None, - priority: NotificationPriority = NotificationPriority.Immediate, - expiration: int | None = None, - collapse_id: str | None = None, - push_type: NotificationType | None = None, - ) -> dict[str, str]: - """ - Send a notification to a list of tokens in batch. - - The function returns a dictionary mapping each token to its result. The result is "Success" - if the token was sent successfully, or the string returned by APNs in the 'reason' field of - the response, if the token generated an error. - """ - results = {} - - for next_notification in notifications: - logger.info("Sending to token %s", next_notification.token) - try: - status, reason = self.send_notification_sync( - next_notification.token, - next_notification.payload, - self.__http_client, - topic, - priority, - expiration, - collapse_id, - push_type, - ) - except httpx.HTTPError as error: - logger.warning( - "Network error sending to token %s: %r", - next_notification.token, - error, - ) - results[next_notification.token] = "ConnectionFailed" - continue - result = self.get_notification_result(status, reason) - logger.info("Got response for %s: %s", next_notification.token, result) - results[next_notification.token] = result - - return results - - def close(self) -> None: - """Close the underlying HTTP connection to APNs.""" - self.__http_client.close() diff --git a/push/apn_handler/credentials.py b/push/apn_handler/credentials.py index 1c4e222..cbdd2dd 100644 --- a/push/apn_handler/credentials.py +++ b/push/apn_handler/credentials.py @@ -7,26 +7,34 @@ DEFAULT_TOKEN_ENCRYPTION_ALGORITHM = "ES256" -# Abstract Base class. This should not be instantiated directly. class Credentials: + """Base class for APNs credentials. Not instantiated directly.""" + def __init__(self, ssl_context: ssl.SSLContext | None = None) -> None: - super().__init__() self.ssl_context = ssl_context - def get_authorization_header(self, topic: str | None) -> str | None: + def get_authorization_header(self) -> str | None: return None -# Credentials subclass for certificate authentication class CertificateCredentials(Credentials): + """Certificate-based authentication: the client certificate is presented + during the TLS handshake, so no authorization header is sent.""" + def __init__(self, cert_file: str, password: str | None = None) -> None: ssl_context = ssl.create_default_context() ssl_context.load_cert_chain(cert_file, password=password) super().__init__(ssl_context) -# Credentials subclass for JWT token based authentication class TokenCredentials(Credentials): + """JWT token-based authentication using an APNs .p8 signing key. + + Tokens are cached and reused until they near Apple's one-hour validity + limit; Apple throttles providers that mint tokens more than once every + 20 minutes. + """ + def __init__( self, auth_key_path: str, @@ -35,56 +43,31 @@ def __init__( encryption_algorithm: str = DEFAULT_TOKEN_ENCRYPTION_ALGORITHM, token_lifetime: int = DEFAULT_TOKEN_LIFETIME, ) -> None: - self.__auth_key = self._get_signing_key(auth_key_path) - self.__auth_key_id = auth_key_id - self.__team_id = team_id - self.__encryption_algorithm = encryption_algorithm - self.__token_lifetime = token_lifetime - - self.__jwt_token: tuple[float, str] | None = None + with open(auth_key_path) as key_file: + self._auth_key = key_file.read() + self._auth_key_id = auth_key_id + self._team_id = team_id + self._encryption_algorithm = encryption_algorithm + self._token_lifetime = token_lifetime + self._cached_token: tuple[float, str] | None = None - # Use the default constructor because we don't have an SSL context super().__init__() - def get_authorization_header(self, topic: str | None) -> str: - token = self._get_or_create_topic_token() - return f"bearer {token}" - - def _is_expired_token(self, issue_date: float) -> bool: - return time.time() > issue_date + self.__token_lifetime - - @staticmethod - def _get_signing_key(key_path: str) -> str: - secret = "" - if key_path: - with open(key_path) as f: - secret = f.read() - return secret - - def _get_or_create_topic_token(self) -> str: - # dict of topic to issue date and JWT token - token_pair = self.__jwt_token - if token_pair is None or self._is_expired_token(token_pair[0]): - # Create a new token - issued_at = time.time() - token_dict = { - "iss": self.__team_id, - "iat": issued_at, - } - headers = { - "alg": self.__encryption_algorithm, - "kid": self.__auth_key_id, - } - jwt_token = jwt.encode( - token_dict, - self.__auth_key, - algorithm=self.__encryption_algorithm, - headers=headers, - ) - - # Cache JWT token for later use. One JWT token per connection. - # https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token-based_connection_to_apns - self.__jwt_token = (issued_at, jwt_token) - return jwt_token - else: - return token_pair[1] + def get_authorization_header(self) -> str: + return f"bearer {self._get_or_create_token()}" + + def _get_or_create_token(self) -> str: + if self._cached_token is not None: + issued_at, token = self._cached_token + if time.time() <= issued_at + self._token_lifetime: + return token + + issued_at = time.time() + token = jwt.encode( + {"iss": self._team_id, "iat": issued_at}, + self._auth_key, + algorithm=self._encryption_algorithm, + headers={"alg": self._encryption_algorithm, "kid": self._auth_key_id}, + ) + self._cached_token = (issued_at, token) + return token diff --git a/push/apn_handler/payload.py b/push/apn_handler/payload.py index 0fdf6d4..175a977 100644 --- a/push/apn_handler/payload.py +++ b/push/apn_handler/payload.py @@ -1,8 +1,6 @@ from collections.abc import Iterable from typing import Any -MAX_PAYLOAD_SIZE = 4096 - class PayloadAlert: def __init__( diff --git a/push/config.py b/push/config.py index 786ec2e..2b5e11d 100644 --- a/push/config.py +++ b/push/config.py @@ -1,53 +1,24 @@ from utils import getenv, getenv_bool -from .apn_handler import TokenCredentials +from .apn_handler import CertificateCredentials, Credentials, TokenCredentials class PushConfig: """ - A class representing the configuration for push notifications. - """ - - AUTH_KEY_PATH: str | None = None - AUTH_KEY_ID: str | None = None - TEAM_ID: str | None = None - APNS_APP_BUNDLE_ID: str | None = None - - @classmethod - def get_auth_key_path(cls) -> str: - """ - Returns the path to the authentication key for APNS. - """ - if not cls.AUTH_KEY_PATH: - cls.AUTH_KEY_PATH = getenv("APNS_AUTH_KEY_PATH") - return cls.AUTH_KEY_PATH + Reads push notification configuration from the environment. - @classmethod - def get_auth_key_id(cls) -> str: - """ - Returns the ID of the authentication key for APNS. - """ - if not cls.AUTH_KEY_ID: - cls.AUTH_KEY_ID = getenv("APNS_KEY_ID") - return cls.AUTH_KEY_ID - - @classmethod - def get_team_id(cls) -> str: - """ - Returns the team ID for APNS. - """ - if not cls.TEAM_ID: - cls.TEAM_ID = getenv("APNS_TEAM_ID") - return cls.TEAM_ID + Token-based authentication with an APNs .p8 key is the default and + Apple's recommended mechanism (keys do not expire). Setting + APNS_CERT_PATH switches to certificate authentication using a PEM file + containing the provider certificate and its private key. + """ @classmethod def get_apns_app_bundle_id(cls) -> str: """ - Returns the bundle ID for the APNS app. + Returns the bundle ID pushes are addressed to (the apns-topic header). """ - if not cls.APNS_APP_BUNDLE_ID: - cls.APNS_APP_BUNDLE_ID = getenv("APNS_APP_BUNDLE_ID") - return cls.APNS_APP_BUNDLE_ID + return getenv("APNS_APP_BUNDLE_ID") @classmethod def get_use_sandbox(cls) -> bool: @@ -64,12 +35,21 @@ def get_use_sandbox(cls) -> bool: return getenv_bool("APNS_USE_SANDBOX") @classmethod - def get_token_credentials(cls) -> TokenCredentials: + def get_credentials(cls) -> Credentials: """ - Returns the token credentials for APNS. + Builds APNs credentials from the environment. + + Returns: + CertificateCredentials when APNS_CERT_PATH is set (with the + passphrase from APNS_CERT_PASSWORD, if any); TokenCredentials + built from APNS_AUTH_KEY_PATH, APNS_KEY_ID, and APNS_TEAM_ID + otherwise. """ + cert_path = getenv("APNS_CERT_PATH", "") + if cert_path: + return CertificateCredentials(cert_path, password=getenv("APNS_CERT_PASSWORD", None)) return TokenCredentials( - auth_key_path=cls.get_auth_key_path(), - auth_key_id=cls.get_auth_key_id(), - team_id=cls.get_team_id(), + auth_key_path=getenv("APNS_AUTH_KEY_PATH"), + auth_key_id=getenv("APNS_KEY_ID"), + team_id=getenv("APNS_TEAM_ID"), ) diff --git a/push/handler.py b/push/handler.py index e301ee8..aa607d2 100644 --- a/push/handler.py +++ b/push/handler.py @@ -1,6 +1,6 @@ import threading -from .apn_handler import APNsClient, Notification, Payload, TokenCredentials +from .apn_handler import APNsClient, Credentials, Notification, Payload from .config import PushConfig @@ -9,8 +9,8 @@ class PushHandler: A wrapper class for sending push notifications using the Apple Push Notification service (APNs). Attributes: - token_credentials (TokenCredentials): The token credentials required to - connect to APNs. + credentials (Credentials): The credentials used to authenticate with + APNs; token-based or certificate-based per PushConfig. connection (APNsClient): An instance of the APNsClient class used to establish a connection to APNs. @@ -20,9 +20,9 @@ class PushHandler: """ def __init__(self): - self.token_credentials: TokenCredentials = PushConfig.get_token_credentials() + self.credentials: Credentials = PushConfig.get_credentials() self.connection: APNsClient = APNsClient( - credentials=self.token_credentials, + credentials=self.credentials, use_sandbox=PushConfig.get_use_sandbox(), ) diff --git a/services/device_service.py b/services/device_service.py index 3a7bc9a..8a88097 100644 --- a/services/device_service.py +++ b/services/device_service.py @@ -5,7 +5,7 @@ from database import db_session from entities import DeviceEntity -from models import Device +from models import Device, DeviceRegistration class DeviceService: @@ -16,9 +16,10 @@ class DeviceService: session (Session): The database session to use. Injected by FastAPI. Methods: - register_device(device: Device) -> Device: Register a device. - get_registered_devices() -> list[Device]: Get all registered devices. - clear_registered_devices() -> None: Clear all registered devices. + register_device: Register or update a device. + get_registered_devices: Get all registered devices. + remove_devices: Remove devices by token. + clear_registered_devices: Clear all registered devices. """ def __init__(self, session: Session = Depends(db_session)): @@ -30,7 +31,7 @@ def __init__(self, session: Session = Depends(db_session)): """ self._session = session - def register_device(self, device: Device) -> Device: + def register_device(self, registration: DeviceRegistration) -> Device: """ Register a device, updating its information if the token is already registered. @@ -40,13 +41,13 @@ def register_device(self, device: Device) -> Device: preserved unless the request provides a new value. Args: - device (Device): The device to register. + registration (DeviceRegistration): The device information to register. Returns: Device: The registered device. """ device_entity = self._session.scalar( - select(DeviceEntity).where(DeviceEntity.token == device.token) + select(DeviceEntity).where(DeviceEntity.token == registration.token) ) if device_entity: for field in ( @@ -56,21 +57,23 @@ def register_device(self, device: Device) -> Device: "model", "localizedModel", ): - value = getattr(device, field) + value = getattr(registration, field) if value is not None: setattr(device_entity, field, value) self._session.commit() return device_entity.to_model() - device_entity = DeviceEntity.from_model(device) + device_entity = DeviceEntity.from_registration(registration) self._session.add(device_entity) try: self._session.commit() except IntegrityError: # A concurrent request registered the same token between our # select and commit; retry to update the row that won the race. + # Only the token column is client-controlled and unique, so the + # retry always finds the winning row and takes the update path. self._session.rollback() - return self.register_device(device) + return self.register_device(registration) return device_entity.to_model() def get_registered_devices(self) -> list[Device]: diff --git a/tests/fixtures/apns_test_cert.pem b/tests/fixtures/apns_test_cert.pem new file mode 100644 index 0000000..9817f06 --- /dev/null +++ b/tests/fixtures/apns_test_cert.pem @@ -0,0 +1,16 @@ +-----BEGIN CERTIFICATE----- +MIIBfjCCASOgAwIBAgIUQsDeW16c2wo6bH+3p48tEy6M6tswCgYIKoZIzj0EAwIw +FDESMBAGA1UEAwwJYXBucy10ZXN0MB4XDTI2MDgyMDA1MDMxMVoXDTM2MDgxNzA1 +MDMxMVowFDESMBAGA1UEAwwJYXBucy10ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEezYhNWBoz+Lzm4MFjTJZA2B7Rvv4OJR1NTo6YtBOd1xeD19ol8WTkgM4 +vHJ3+DGfXI1eRgB0eWoE294ySgpmPqNTMFEwHQYDVR0OBBYEFGmbPkMCG+iZRLE3 +yHBiH3cgsx98MB8GA1UdIwQYMBaAFGmbPkMCG+iZRLE3yHBiH3cgsx98MA8GA1Ud +EwEB/wQFMAMBAf8wCgYIKoZIzj0EAwIDSQAwRgIhAMzvAy3PMnIuVp1jeBp2wSyb +VnAjCRPNqLJt0pzFS28QAiEAux60rTX+JXiliPhfMto19YCXS6DLB+ldvFvBsvwg +3/M= +-----END CERTIFICATE----- +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgRNb7famA/U9q0FyK +RuIrvgi0qOhArxTo7YLYL7/Mf4WhRANCAAR7NiE1YGjP4vObgwWNMlkDYHtG+/g4 +lHU1Ojpi0E53XF4PX2iXxZOSAzi8cnf4MZ9cjV5GAHR5agTb3jJKCmY+ +-----END PRIVATE KEY----- diff --git a/tests/integration/test_server.py b/tests/integration/test_server.py index d4b1238..5ad8ade 100644 --- a/tests/integration/test_server.py +++ b/tests/integration/test_server.py @@ -75,9 +75,12 @@ def server(): env = _integration_env() _drop_devices_table(env) port = _free_port() + env["PORT"] = str(port) + # Launched exactly as the README documents, so the entrypoint itself + # (including HOST/PORT handling) is part of what these tests cover. process = subprocess.Popen( - [sys.executable, "-m", "uvicorn", "main:app", "--port", str(port)], + [sys.executable, "main.py"], cwd=REPO_ROOT, env=env, stdout=subprocess.PIPE, diff --git a/tests/test_apns_credentials.py b/tests/test_apns_credentials.py index 58f146c..78c03ab 100644 --- a/tests/test_apns_credentials.py +++ b/tests/test_apns_credentials.py @@ -20,7 +20,7 @@ def make_credentials(**kwargs): def test_authorization_header_contains_signed_es256_jwt(): - header = make_credentials().get_authorization_header("com.example.test") + header = make_credentials().get_authorization_header() assert header.startswith("bearer ") token = header.removeprefix("bearer ") @@ -38,7 +38,7 @@ def test_authorization_header_contains_signed_es256_jwt(): def test_token_is_reused_until_expiry(): credentials = make_credentials() - first = credentials.get_authorization_header("com.example.test") - second = credentials.get_authorization_header("com.example.test") + first = credentials.get_authorization_header() + second = credentials.get_authorization_header() assert first == second diff --git a/tests/test_device_api.py b/tests/test_device_api.py index 141f8d6..f640724 100644 --- a/tests/test_device_api.py +++ b/tests/test_device_api.py @@ -2,7 +2,7 @@ from sqlalchemy.orm import Session -from models import Device +from models import DeviceRegistration from services import DeviceService @@ -89,7 +89,9 @@ def test_reregistering_with_only_token_preserves_stored_fields(client): def test_concurrent_registration_race_falls_back_to_update(test_engine): with Session(test_engine) as session: - DeviceService(session=session).register_device(Device(token="abc123", name="winner")) + DeviceService(session=session).register_device( + DeviceRegistration(token="abc123", name="winner") + ) with Session(test_engine) as session: service = DeviceService(session=session) @@ -103,7 +105,7 @@ def stale_scalar(*args, **kwargs): return real_scalar(*args, **kwargs) session.scalar = stale_scalar - result = service.register_device(Device(token="abc123", name="loser")) + result = service.register_device(DeviceRegistration(token="abc123", name="loser")) assert result.name == "loser" with Session(test_engine) as session: @@ -113,3 +115,18 @@ def stale_scalar(*args, **kwargs): def test_old_clear_route_is_gone(client): assert client.get("/devices/clear").status_code in (404, 405) + + +def test_register_ignores_client_supplied_server_fields(client): + first = client.post("/devices/register", json={"token": "aaa"}).json() + + response = client.post( + "/devices/register", + json={"token": "bbb", "id": first["id"], "created_at": "2000-01-01T00:00:00"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["id"] != first["id"] + assert body["created_at"] != "2000-01-01T00:00:00" + assert len(client.get("/devices/all").json()) == 2 diff --git a/tests/test_push_config.py b/tests/test_push_config.py new file mode 100644 index 0000000..f68b346 --- /dev/null +++ b/tests/test_push_config.py @@ -0,0 +1,74 @@ +"""Tests for push credential configuration. + +The certificate fixture is a self-signed cert generated for tests only; it +has never been presented to Apple. +""" + +from pathlib import Path + +import httpx +import pytest + +from push import PushHandler +from push.apn_handler import CertificateCredentials, TokenCredentials +from push.config import PushConfig + +CERT_PATH = str(Path(__file__).parent / "fixtures" / "apns_test_cert.pem") + + +def test_token_credentials_are_the_default(monkeypatch): + monkeypatch.delenv("APNS_CERT_PATH", raising=False) + + assert isinstance(PushConfig.get_credentials(), TokenCredentials) + + +def test_certificate_credentials_when_cert_path_is_set(monkeypatch): + monkeypatch.setenv("APNS_CERT_PATH", CERT_PATH) + + credentials = PushConfig.get_credentials() + + assert isinstance(credentials, CertificateCredentials) + assert credentials.ssl_context is not None + + +def test_certificate_credentials_reject_missing_file(): + with pytest.raises(FileNotFoundError): + CertificateCredentials("/nonexistent/cert.pem") + + +def test_certificate_auth_sends_no_authorization_header(monkeypatch): + monkeypatch.setenv("APNS_CERT_PATH", CERT_PATH) + requests = [] + + def route(request): + requests.append(request) + return httpx.Response(200) + + transport = httpx.MockTransport(route) + real_client = httpx.Client + monkeypatch.setattr(httpx, "Client", lambda **kwargs: real_client(transport=transport)) + handler = PushHandler() + + handler.send_push("device-token", body="hello") + + assert "authorization" not in requests[0].headers + handler.close() + + +def test_token_auth_sends_authorization_header(monkeypatch): + monkeypatch.delenv("APNS_CERT_PATH", raising=False) + requests = [] + + def route(request): + requests.append(request) + return httpx.Response(200) + + transport = httpx.MockTransport(route) + real_client = httpx.Client + monkeypatch.setattr(httpx, "Client", lambda **kwargs: real_client(transport=transport)) + handler = PushHandler() + + handler.send_push("device-token", body="hello") + + assert requests[0].headers["authorization"].startswith("bearer ") + handler.close()