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) = ¬ification.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) = ¬ification.category {
277 aps.insert("category".to_owned(), Value::String(category.clone()));
278 }
279 if let Some(thread_id) = ¬ification.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) = ¬ification.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) = ¬ification.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) = ¬ification.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}