Skip to main content

cratefield_adapter_apns/
lib.rs

1//! `cratefield-adapter-apns`: the [`Push`] port over Apple Push Notification
2//! service (issues #104, #177). Uses the runtime's [`HttpClient`] and
3//! [`Clock`] ports — no vendor SDK, no `reqwest`, no `openssl` — so the same
4//! adapter runs on Workers (`worker::Fetch`) and natively.
5//!
6//! **Recipients.** It serves [`Recipient::Apns`] and returns
7//! [`PushError::unsupported_recipient`] for FCM and Web Push. A venture with
8//! more than one transport wires
9//! [`RoutingPush`](cratefield_core::RoutingPush), which dispatches by variant
10//! (ADR 0015).
11//!
12//! **Authentication.** APNs takes a provider JWT signed ES256 with the `.p8`
13//! key from the Apple developer portal. Signing and caching live in
14//! [`cratefield_push_auth`] (issue #178), shared with the VAPID and Google
15//! signers: the token is minted once and reused, because Apple rejects
16//! regenerating it more than once per ~20 minutes
17//! (`TooManyProviderTokenUpdates`) and accepts it for up to 60, so the
18//! adapter caches it for [`JWT_TTL`] and re-mints past that. Signing is
19//! pure-Rust P-256 ECDSA with a deterministic RFC6979 nonce, so it needs no
20//! RNG on the isolate.
21//!
22//! **Degraded mode.** [`Apns::not_configured`] reports
23//! [`PushOutcome::NotConfigured`] without any network call, the same contract
24//! [`Resend`](cratefield_core::Mailer) uses when its key is absent, so a
25//! venture with no APNs credentials still builds and runs. It answers that
26//! only for the transport it serves: an FCM or Web Push recipient is still
27//! [`PushError::unsupported_recipient`], configured or not.
28//!
29//! **Verification.** Signing and payload construction are unit-tested here,
30//! but the live path against Apple's sandbox is `needs-human` (issue #104
31//! acceptance): it needs a real `.p8`, a bundle id, and a device token, none
32//! of which live in the repo.
33#![doc = include_str!("../README.md")]
34#![forbid(unsafe_code)]
35
36use std::sync::Arc;
37use std::time::Duration;
38
39use async_trait::async_trait;
40use cratefield_core::{
41    Clock, HttpClient, Notification, Priority, Push, PushError, PushOutcome, Recipient, ttl_secs,
42};
43use cratefield_push_auth::{CachedToken, Es256Signer};
44use http::header::AUTHORIZATION;
45use http::{Request, StatusCode};
46use serde_json::{Map, Value, json};
47
48use bytes::Bytes;
49
50/// How long a minted provider JWT is reused before it is re-signed. Apple
51/// accepts a token for 60 minutes and rejects regenerating one more than once
52/// per ~20; 50 minutes sits safely inside both bounds.
53#[allow(clippy::duration_suboptimal_units)] // `from_mins` is not const-stable on 1.98
54pub const JWT_TTL: Duration = Duration::from_secs(3_000);
55
56/// Which APNs environment to target. A build signed with a development
57/// provisioning profile registers its token with the sandbox; a
58/// TestFlight/App Store build with production. Sending to the wrong host
59/// returns `BadDeviceToken`.
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ApnsHost {
62    Production,
63    Sandbox,
64}
65
66impl ApnsHost {
67    fn authority(self) -> &'static str {
68        match self {
69            ApnsHost::Production => "api.push.apple.com",
70            ApnsHost::Sandbox => "api.sandbox.push.apple.com",
71        }
72    }
73
74    /// Parses the `APNS_HOST` secret: `production`/`prod` or
75    /// `sandbox`/`development`/`dev` (case-insensitive).
76    pub fn parse(value: &str) -> Option<Self> {
77        match value.trim().to_ascii_lowercase().as_str() {
78            "production" | "prod" => Some(ApnsHost::Production),
79            "sandbox" | "development" | "dev" => Some(ApnsHost::Sandbox),
80            _ => None,
81        }
82    }
83}
84
85/// The credentials a venture reads from its secrets to reach APNs.
86pub struct ApnsCredentials {
87    /// The `.p8` key file contents (PKCS#8 PEM) from the Apple developer
88    /// portal.
89    pub key_p8_pem: String,
90    /// The key's 10-character id (the `.p8` filename suffix).
91    pub key_id: String,
92    /// The 10-character Apple team id (the JWT issuer).
93    pub team_id: String,
94    /// The app's bundle id, sent as `apns-topic`.
95    pub topic: String,
96    pub host: ApnsHost,
97}
98
99/// Building an [`Apns`] adapter failed.
100#[derive(Debug, thiserror::Error)]
101pub enum ApnsConfigError {
102    /// The `.p8` did not parse as a PKCS#8 PEM P-256 private key.
103    #[error("invalid APNs .p8 key: {0}")]
104    Key(String),
105}
106
107/// [`Push`] over `POST https://{host}/3/device/{token}`.
108pub struct Apns {
109    inner: Inner,
110}
111
112enum Inner {
113    Live(Box<Live>),
114    NotConfigured,
115}
116
117struct Live {
118    http: Arc<dyn HttpClient>,
119    clock: Arc<dyn Clock>,
120    signer: Es256Signer,
121    key_id: String,
122    team_id: String,
123    topic: String,
124    host: ApnsHost,
125    /// One provider token for the whole adapter, hence the `()` key
126    /// (VAPID is the variant that keys per push-service origin).
127    jwt: CachedToken<()>,
128}
129
130impl Apns {
131    /// Builds a live adapter, parsing the `.p8` up front so a malformed key is
132    /// reported at construction rather than on the first send.
133    ///
134    /// # Errors
135    ///
136    /// Returns [`ApnsConfigError::Key`] if `key_p8_pem` is not a PKCS#8 PEM
137    /// P-256 private key.
138    pub fn new(
139        http: Arc<dyn HttpClient>,
140        clock: Arc<dyn Clock>,
141        creds: ApnsCredentials,
142    ) -> Result<Self, ApnsConfigError> {
143        let signer = Es256Signer::from_p8_pem(&creds.key_p8_pem)
144            .map_err(|err| ApnsConfigError::Key(err.to_string()))?;
145        Ok(Self {
146            inner: Inner::Live(Box::new(Live {
147                http,
148                clock,
149                signer,
150                key_id: creds.key_id,
151                team_id: creds.team_id,
152                topic: creds.topic,
153                host: creds.host,
154                jwt: CachedToken::new(JWT_TTL),
155            })),
156        })
157    }
158
159    /// A degraded adapter that reports [`PushOutcome::NotConfigured`] without
160    /// any network call — for a venture with no APNs credentials set.
161    #[must_use]
162    pub fn not_configured() -> Self {
163        Self {
164            inner: Inner::NotConfigured,
165        }
166    }
167}
168
169impl Live {
170    /// The current provider JWT, minting and caching a new one when the cached
171    /// token is missing or older than [`JWT_TTL`].
172    fn provider_jwt(&self) -> String {
173        self.jwt.get_or_mint(self.clock.as_ref(), &(), |now_unix| {
174            self.signer.sign_jwt(
175                &json!({ "alg": "ES256", "kid": self.key_id }),
176                &json!({ "iss": self.team_id, "iat": now_unix }),
177            )
178        })
179    }
180
181    /// Invalidates the cached JWT so the next send re-signs (used when APNs
182    /// reports the token expired).
183    fn invalidate_jwt(&self) {
184        self.jwt.invalidate(&());
185    }
186}
187
188/// The `apns-priority` header value for a [`Priority`].
189///
190/// A silent (`content-available`) push is always `5`: Apple rejects a
191/// background push sent at `10`.
192fn priority_header(priority: Priority, silent: bool) -> &'static str {
193    match (silent, priority) {
194        (true, _) | (false, Priority::Conserve) => "5",
195        (false, Priority::Immediate) => "10",
196    }
197}
198
199/// `apns-expiration` for a notification's TTL. The header is an **absolute**
200/// UNIX epoch, not a duration, so a TTL becomes `now + ttl`; `0` is the one
201/// value APNs reads as "deliver now or drop", which is what a zero TTL means.
202///
203/// A sub-second TTL is rounded up to one second by
204/// [`ttl_secs`](cratefield_core::ttl_secs) rather than truncated, so
205/// `from_millis(900)` asks APNs to hold the notification for a second and not
206/// to discard it the instant the device is offline.
207fn expiration_header(ttl: Duration, now_unix: i64) -> String {
208    if ttl.is_zero() {
209        return "0".to_owned();
210    }
211    let seconds = i64::try_from(ttl_secs(ttl)).unwrap_or(i64::MAX);
212    now_unix.saturating_add(seconds).to_string()
213}
214
215/// Whether a device token can be spliced into the request path as-is.
216///
217/// An APNs device token is the hex of the 32 bytes
218/// `application:didRegisterForRemoteNotificationsWithDeviceToken:` hands over.
219/// Anything else — a `/`, `?`, `#`, `%`, whitespace, the `<...>` wrapper of
220/// an old `Data` description — is not a token, and interpolating it would
221/// silently retarget the request (or fail far downstream as "could not build
222/// request"). Hyphen and underscore are tolerated because test and staging
223/// registries use them and neither can change the path's shape.
224fn is_wellformed_device_token(token: &str) -> bool {
225    !token.is_empty()
226        && token
227            .bytes()
228            .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
229}
230
231/// Builds the APNs JSON payload: an `aps` block from the notification, with the
232/// caller's `data` merged in at the top level alongside it.
233///
234/// Fields APNs has no home for are dropped on purpose: `icon` (the icon comes
235/// from the app bundle) and `collapse_id` (a header, not payload).
236fn build_payload(notification: &Notification) -> Vec<u8> {
237    let mut aps = Map::new();
238    if notification.silent {
239        // A background push carries `content-available` and **nothing else**.
240        // Apple ("Pushing background updates to your app"): omit the `alert`,
241        // `badge` and `sound` keys, or the notification is delivered as a
242        // user-visible one and the silent wake never happens. `category` and
243        // `thread-id` go with them: both only describe how an alert is shown
244        // and grouped, and Apple documents neither as usable in a background
245        // push — so they are dropped rather than gambled on.
246        aps.insert("content-available".to_owned(), json!(1));
247    } else {
248        let mut alert = Map::new();
249        alert.insert(
250            "title".to_owned(),
251            Value::String(notification.title.clone()),
252        );
253        alert.insert("body".to_owned(), Value::String(notification.body.clone()));
254        if let Some(loc) = &notification.loc {
255            // Apple's own names: the title keys are prefixed, the body keys
256            // are not. Where a loc key is present the device prefers it over
257            // the literal title/body, which stay as the fallback.
258            //
259            // `*-loc-args` are the substitutions for a `*-loc-key`, so each
260            // list is nested inside its key's arm: args without a key
261            // substitute into nothing and Apple defines no meaning for them.
262            if let Some(key) = &loc.title_loc_key {
263                alert.insert("title-loc-key".to_owned(), Value::String(key.clone()));
264                if !loc.title_loc_args.is_empty() {
265                    alert.insert("title-loc-args".to_owned(), json!(loc.title_loc_args));
266                }
267            }
268            if let Some(key) = &loc.body_loc_key {
269                alert.insert("loc-key".to_owned(), Value::String(key.clone()));
270                if !loc.body_loc_args.is_empty() {
271                    alert.insert("loc-args".to_owned(), json!(loc.body_loc_args));
272                }
273            }
274        }
275        aps.insert("alert".to_owned(), Value::Object(alert));
276        if let Some(category) = &notification.category {
277            aps.insert("category".to_owned(), Value::String(category.clone()));
278        }
279        if let Some(thread_id) = &notification.thread_id {
280            aps.insert("thread-id".to_owned(), Value::String(thread_id.clone()));
281        }
282        if let Some(badge) = notification.badge {
283            aps.insert("badge".to_owned(), json!(badge));
284        }
285    }
286
287    let mut root = Map::new();
288    // The app's custom payload goes in first and the adapter's own keys are
289    // written over it: last write wins, so `data` can carry an "aps" or a
290    // "url" without either reaching the wire in place of the real one.
291    if let Value::Object(data) = &notification.data {
292        for (key, value) in data {
293            root.insert(key.clone(), value.clone());
294        }
295    }
296    // APNs has no click-target of its own, so the tap URL travels in the
297    // custom payload, where the app reads it — the same key Web Push and FCM
298    // use. The typed field wins over a "url" in `data`: it is the field the
299    // port documents, and a caller that wants its own key can use another
300    // name. With no `url` set, a "url" in `data` is left alone.
301    if let Some(url) = &notification.url {
302        root.insert("url".to_owned(), Value::String(url.clone()));
303    }
304    root.insert("aps".to_owned(), Value::Object(aps));
305    Value::Object(root).to_string().into_bytes()
306}
307
308/// Reads the `reason` field from an APNs error body (`{"reason":"..."}`).
309fn reason(body: &[u8]) -> Option<String> {
310    serde_json::from_slice::<Value>(body)
311        .ok()?
312        .get("reason")?
313        .as_str()
314        .map(str::to_owned)
315}
316
317#[async_trait]
318impl Push for Apns {
319    async fn send(
320        &self,
321        to: &Recipient,
322        notification: &Notification,
323    ) -> Result<PushOutcome, PushError> {
324        // One transport per adapter: a venture that also speaks FCM or Web
325        // Push puts a `RoutingPush` in front (ADR 0015). This is decided
326        // *before* configuration is: an unconfigured APNs adapter still does
327        // not serve Web Push, and answering `NotConfigured` for an FCM
328        // recipient would claim a transport it will never carry — a router
329        // reading that answer would stop looking for the adapter that does.
330        let Recipient::Apns { device_token } = to else {
331            return Err(PushError::unsupported_recipient(to));
332        };
333        let live = match &self.inner {
334            Inner::NotConfigured => return Ok(PushOutcome::NotConfigured),
335            Inner::Live(live) => live,
336        };
337        if !is_wellformed_device_token(device_token) {
338            return Err(PushError::Rejected("malformed device token".to_owned()));
339        }
340
341        let now_unix = live.clock.now().unix_timestamp();
342        let jwt = live.provider_jwt();
343        let url = format!(
344            "https://{}/3/device/{}",
345            live.host.authority(),
346            device_token
347        );
348        let payload = build_payload(notification);
349
350        let mut builder = Request::builder()
351            .method("POST")
352            .uri(&url)
353            .header(AUTHORIZATION, format!("bearer {jwt}"))
354            .header("apns-topic", &live.topic)
355            .header(
356                "apns-push-type",
357                if notification.silent {
358                    "background"
359                } else {
360                    "alert"
361                },
362            )
363            .header(
364                "apns-priority",
365                priority_header(notification.priority, notification.silent),
366            );
367        if let Some(collapse_id) = &notification.collapse_id {
368            builder = builder.header("apns-collapse-id", collapse_id);
369        }
370        if let Some(ttl) = notification.ttl {
371            builder = builder.header("apns-expiration", expiration_header(ttl, now_unix));
372        }
373        let request = builder
374            .body(Bytes::from(payload))
375            .map_err(|err| PushError::Rejected(format!("could not build request: {err}")))?;
376
377        let response = live
378            .http
379            .send(request)
380            .await
381            .map_err(|err| PushError::transient(err.to_string()))?;
382
383        let status = response.status();
384        match status {
385            StatusCode::OK => {
386                let id = response
387                    .headers()
388                    .get("apns-id")
389                    .and_then(|value| value.to_str().ok())
390                    .map(str::to_owned);
391                Ok(PushOutcome::Delivered { id })
392            }
393            // The device token is no longer active: the caller deletes it.
394            StatusCode::GONE => Err(PushError::Unregistered),
395            _ => {
396                let detail = reason(response.body()).unwrap_or_else(|| status.as_u16().to_string());
397                // An expired/invalid provider token is our JWT, not the
398                // request: drop the cache so the next send re-signs, and ask
399                // the caller to retry.
400                if status == StatusCode::FORBIDDEN
401                    && (detail == "ExpiredProviderToken" || detail == "InvalidProviderToken")
402                {
403                    live.invalidate_jwt();
404                    return Err(PushError::transient(format!(
405                        "provider token rejected: {detail}"
406                    )));
407                }
408                if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
409                    Err(PushError::transient_after(
410                        format!("apns {}: {detail}", status.as_u16()),
411                        cratefield_core::retry_after(response.headers(), live.clock.as_ref()),
412                    ))
413                } else {
414                    Err(PushError::Rejected(format!(
415                        "apns {}: {detail}",
416                        status.as_u16()
417                    )))
418                }
419            }
420        }
421    }
422}