cratefield_adapter_fcm/lib.rs
1//! `cratefield-adapter-fcm`: the [`Push`] port over Firebase Cloud Messaging
2//! (issue #179), on the **HTTP v1** API. Uses the runtime's [`HttpClient`]
3//! and [`Clock`] ports — no Firebase Admin SDK (there is none for Rust and
4//! none is needed), no `reqwest`, no OpenSSL — so the same adapter runs on
5//! Workers (`worker::Fetch`) and natively.
6//!
7//! **Why v1 only.** Google shut the legacy `fcm/send` API down in June 2024.
8//! `POST /v1/projects/{project_id}/messages:send` with an OAuth 2.0 bearer
9//! token is the only supported server path, and it is plain HTTPS and JSON.
10//!
11//! **Recipients.** It serves [`Recipient::Fcm`] and returns
12//! [`PushError::unsupported_recipient`] for APNs and Web Push. Google-free
13//! Android is *not* this adapter: that is UnifiedPush, which the Web Push
14//! adapter carries. A venture with more than one transport wires
15//! [`RoutingPush`](cratefield_core::RoutingPush), which dispatches by variant
16//! (ADR 0015).
17//!
18//! **Authentication.** A Google service account signs an RS256 assertion —
19//! `{iss: client_email, scope: firebase.messaging, aud: token_uri, iat, exp}`
20//! — and trades it at `oauth2.googleapis.com/token` for a bearer token good
21//! for an hour. Signing is [`Rs256Signer`] and the token is held in a
22//! [`CachedToken`], both from [`cratefield_push_auth`] (issue #178), so this
23//! crate re-implements no JWT. The bearer token is cached for the lifetime
24//! Google states less a five-minute safety margin, capped by
25//! [`ACCESS_TOKEN_TTL`]. A `401 UNAUTHENTICATED` drops the cached token and
26//! retries the send **once**, mirroring the APNs expired-provider-token path.
27//!
28//! **Degraded mode.** [`Fcm::not_configured`] reports
29//! [`PushOutcome::NotConfigured`] without any network call, the same contract
30//! [`Resend`](cratefield_core::Mailer) uses when its key is absent, so a
31//! venture with no Firebase credentials still builds and runs. It answers
32//! that only for the transport it serves: an APNs or Web Push recipient is
33//! still [`PushError::unsupported_recipient`], configured or not.
34//!
35//! **Verification.** Token exchange, payload mapping and error mapping are
36//! unit-tested here against a scripted [`HttpClient`]. The live path against
37//! a real Firebase project and a real Android device is `needs-human`
38//! (issue #186) and is **not** a blocker for merging this crate: it needs a
39//! service-account key, a project and a handset, none of which live in the
40//! repo.
41#![doc = include_str!("../README.md")]
42#![forbid(unsafe_code)]
43
44use std::sync::Arc;
45use std::time::Duration;
46
47use async_trait::async_trait;
48use bytes::Bytes;
49use cratefield_core::{
50 Clock, HttpClient, Notification, Priority, Push, PushError, PushOutcome, Recipient,
51 retry_after, ttl_secs,
52};
53use cratefield_push_auth::{CachedToken, Rs256Signer};
54use http::header::{AUTHORIZATION, CONTENT_TYPE};
55use http::{Request, StatusCode};
56use serde::{Deserialize, Deserializer};
57use serde_json::{Map, Value, json};
58
59/// The ceiling on how long an exchanged bearer token is reused. Google issues
60/// them for an hour; the adapter keeps `expires_in` less
61/// [`EXPIRY_MARGIN`], and never longer than this, so a token is always
62/// re-exchanged with minutes to spare.
63#[allow(clippy::duration_suboptimal_units)] // `from_mins` is not const-stable on 1.98
64pub const ACCESS_TOKEN_TTL: Duration = Duration::from_secs(3_300);
65
66/// How far short of Google's stated expiry a token is retired, so a send that
67/// starts just before the boundary cannot finish just after it.
68#[allow(clippy::duration_suboptimal_units)] // `from_mins` is not const-stable on 1.98
69pub const EXPIRY_MARGIN: Duration = Duration::from_secs(300);
70
71/// How long the signed service-account assertion claims to be valid. Google's
72/// documented maximum for the JWT-bearer grant is one hour; the assertion is
73/// spent immediately on the token endpoint, so this is a bound, not a cache.
74const ASSERTION_TTL_SECS: i64 = 3_600;
75
76/// The OAuth scope FCM sends are authorised under.
77const SCOPE: &str = "https://www.googleapis.com/auth/firebase.messaging";
78
79/// The `grant_type` for RFC 7523's JWT-bearer flow.
80const JWT_BEARER_GRANT: &str = "urn:ietf:params:oauth:grant-type:jwt-bearer";
81
82/// Google's token endpoint, and the default when a service-account JSON does
83/// not carry a `token_uri` of its own.
84pub const DEFAULT_TOKEN_URI: &str = "https://oauth2.googleapis.com/token";
85
86/// The FCM HTTP v1 host.
87const FCM_BASE: &str = "https://fcm.googleapis.com";
88
89// The `errorCode` values of `google.firebase.fcm.v1.FcmError`. Mapping keys
90// off these rather than the HTTP status, because the status alone cannot tell
91// a dead registration token (404 `UNREGISTERED`, prune it) from a project
92// that does not exist (404, a configuration bug), nor a wrong-project token
93// (403 `SENDER_ID_MISMATCH`) from a missing APNs credential
94// (401 `THIRD_PARTY_AUTH_ERROR`).
95const UNREGISTERED: &str = "UNREGISTERED";
96const INVALID_ARGUMENT: &str = "INVALID_ARGUMENT";
97const SENDER_ID_MISMATCH: &str = "SENDER_ID_MISMATCH";
98const THIRD_PARTY_AUTH_ERROR: &str = "THIRD_PARTY_AUTH_ERROR";
99/// The pre-rename spelling of [`THIRD_PARTY_AUTH_ERROR`]; Google still emits
100/// it for some projects.
101const APNS_AUTH_ERROR: &str = "APNS_AUTH_ERROR";
102const QUOTA_EXCEEDED: &str = "QUOTA_EXCEEDED";
103const UNAVAILABLE: &str = "UNAVAILABLE";
104const INTERNAL: &str = "INTERNAL";
105
106// The RFC 6749 §5.2 `error` codes the token endpoint answers with that no
107// amount of retrying can fix: the assertion was signed with a key Google no
108// longer accepts, or the service account is not allowed this grant. Both are
109// configuration, and an outbox retrying them forever is worse than one
110// rejection in the log.
111const INVALID_GRANT: &str = "invalid_grant";
112const UNAUTHORIZED_CLIENT: &str = "unauthorized_client";
113
114/// The credentials a venture reads from its secrets to reach FCM.
115///
116/// [`Self::from_service_account_json`] takes the file Firebase hands over
117/// whole, which is the simplest thing for an operator to paste into a secret.
118///
119/// [`Debug`](#impl-Debug-for-FcmCredentials) is hand-written and never prints
120/// the private key: core's log redaction keys off the *field name*
121/// ([`is_secret_field`](cratefield_core::is_secret_field)) and cannot see
122/// inside a `{:?}` of this struct.
123#[derive(Clone)]
124pub struct FcmCredentials {
125 /// The service account's address (`…@….iam.gserviceaccount.com`), the
126 /// assertion's `iss`.
127 pub client_email: String,
128 /// The service account's RSA private key, PEM. Google ships PKCS#8
129 /// (`BEGIN PRIVATE KEY`); the legacy PKCS#1 form is accepted too.
130 pub private_key_pem: String,
131 /// The Firebase project id, which is the `{project_id}` in the send URL.
132 pub project_id: String,
133 /// Google's token endpoint. Comes from the service-account JSON's
134 /// `token_uri`; [`DEFAULT_TOKEN_URI`] when it has none.
135 pub token_uri: String,
136}
137
138impl FcmCredentials {
139 /// Parses a Google service-account JSON file — the whole thing, as
140 /// downloaded from the Firebase console.
141 ///
142 /// # Errors
143 ///
144 /// [`FcmConfigError::ServiceAccount`] if the JSON does not parse or is
145 /// missing `client_email`, `private_key` or `project_id`.
146 pub fn from_service_account_json(json: &str) -> Result<Self, FcmConfigError> {
147 #[derive(Deserialize)]
148 struct ServiceAccount {
149 client_email: Option<String>,
150 private_key: Option<String>,
151 project_id: Option<String>,
152 token_uri: Option<String>,
153 }
154
155 let account: ServiceAccount = serde_json::from_str(json)
156 .map_err(|err| FcmConfigError::ServiceAccount(err.to_string()))?;
157 let field = |name: &str, value: Option<String>| {
158 value
159 .filter(|value| !value.trim().is_empty())
160 .ok_or_else(|| FcmConfigError::ServiceAccount(format!("missing \"{name}\"")))
161 };
162 Ok(Self {
163 client_email: field("client_email", account.client_email)?,
164 private_key_pem: field("private_key", account.private_key)?,
165 project_id: field("project_id", account.project_id)?,
166 token_uri: account
167 .token_uri
168 .filter(|uri| !uri.trim().is_empty())
169 .unwrap_or_else(|| DEFAULT_TOKEN_URI.to_owned()),
170 })
171 }
172}
173
174impl std::fmt::Debug for FcmCredentials {
175 /// Never prints the private key.
176 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177 f.debug_struct("FcmCredentials")
178 .field("client_email", &self.client_email)
179 .field("private_key_pem", &"[redacted]")
180 .field("project_id", &self.project_id)
181 .field("token_uri", &self.token_uri)
182 .finish()
183 }
184}
185
186/// Google's token-endpoint reply. `expires_in` is defaulted rather than
187/// required: a reply without it is still a usable token, it just gets no
188/// cache lifetime and is exchanged again on the next send.
189#[derive(Deserialize)]
190struct TokenResponse {
191 access_token: String,
192 #[serde(default, deserialize_with = "lenient_seconds")]
193 expires_in: u64,
194}
195
196/// `expires_in` as either a JSON number or a JSON string.
197///
198/// RFC 6749 §5.1 types it as a number and Google sends one, but the string
199/// form is common enough in the wild — some proxies and some OAuth servers
200/// quote every value — that rejecting it would throw away a working token
201/// over its packaging. Anything that is neither reads as "no stated
202/// lifetime", which the struct already treats as a usable token that is
203/// simply not cached, rather than as a failed exchange.
204fn lenient_seconds<'de, D: Deserializer<'de>>(deserializer: D) -> Result<u64, D::Error> {
205 #[derive(Deserialize)]
206 #[serde(untagged)]
207 enum Seconds {
208 Number(u64),
209 Text(String),
210 }
211
212 Ok(match Option::<Seconds>::deserialize(deserializer)? {
213 Some(Seconds::Number(seconds)) => seconds,
214 Some(Seconds::Text(text)) => text.trim().parse().unwrap_or(0),
215 None => 0,
216 })
217}
218
219/// Building an [`Fcm`] adapter failed.
220#[derive(Debug, thiserror::Error)]
221pub enum FcmConfigError {
222 /// The service-account JSON did not parse, or a field was missing.
223 #[error("invalid FCM service account: {0}")]
224 ServiceAccount(String),
225 /// `private_key` was not an RSA private key in either PEM form.
226 #[error("invalid FCM service-account key: {0}")]
227 Key(String),
228 /// `project_id` is spliced into the send URL, so it has to be a project
229 /// id and not a path.
230 #[error("invalid FCM project id: {0}")]
231 ProjectId(String),
232 /// `token_uri` is where the service account's assertion — a bearer
233 /// credential — is sent, so it must be HTTPS.
234 #[error("invalid FCM token endpoint: {0}")]
235 TokenUri(String),
236}
237
238/// [`Push`] over `POST https://fcm.googleapis.com/v1/projects/{id}/messages:send`.
239pub struct Fcm {
240 inner: Inner,
241}
242
243enum Inner {
244 Live(Box<Live>),
245 NotConfigured,
246}
247
248struct Live {
249 http: Arc<dyn HttpClient>,
250 clock: Arc<dyn Clock>,
251 signer: Rs256Signer,
252 client_email: String,
253 token_uri: String,
254 send_url: String,
255 /// One bearer token for the whole adapter, hence the `()` key (VAPID is
256 /// the variant that keys per push-service origin).
257 token: CachedToken<()>,
258}
259
260impl Fcm {
261 /// Builds a live adapter, parsing the private key up front so a malformed
262 /// one is reported at construction rather than on the first send.
263 ///
264 /// # Errors
265 ///
266 /// - [`FcmConfigError::ServiceAccount`] if `client_email` is blank. It is
267 /// the assertion's `iss`, and an empty one signs `"iss": ""`, which
268 /// Google refuses forever — a failure worth naming at construction
269 /// rather than on every send.
270 /// - [`FcmConfigError::Key`] if `private_key_pem` is not an RSA private
271 /// key (PKCS#8 or PKCS#1 PEM).
272 /// - [`FcmConfigError::ProjectId`] if the project id is empty or carries
273 /// anything but ASCII alphanumerics, `-` or `_`.
274 /// - [`FcmConfigError::TokenUri`] if the token endpoint is not HTTPS.
275 pub fn new(
276 http: Arc<dyn HttpClient>,
277 clock: Arc<dyn Clock>,
278 creds: FcmCredentials,
279 ) -> Result<Self, FcmConfigError> {
280 if creds.client_email.trim().is_empty() {
281 // The same defect `from_service_account_json` reports, reported
282 // the same way: hand-built credentials get the check too.
283 return Err(FcmConfigError::ServiceAccount(
284 "missing \"client_email\"".to_owned(),
285 ));
286 }
287 if !is_wellformed_project_id(&creds.project_id) {
288 return Err(FcmConfigError::ProjectId(format!(
289 "{:?} is not a project id",
290 creds.project_id
291 )));
292 }
293 if !creds.token_uri.starts_with("https://") {
294 return Err(FcmConfigError::TokenUri(format!(
295 "{:?} is not https",
296 creds.token_uri
297 )));
298 }
299 let signer = Rs256Signer::from_pem(&creds.private_key_pem)
300 .map_err(|err| FcmConfigError::Key(err.to_string()))?;
301 Ok(Self {
302 inner: Inner::Live(Box::new(Live {
303 http,
304 clock,
305 signer,
306 client_email: creds.client_email,
307 token_uri: creds.token_uri,
308 send_url: format!("{FCM_BASE}/v1/projects/{}/messages:send", creds.project_id),
309 token: CachedToken::new(ACCESS_TOKEN_TTL),
310 })),
311 })
312 }
313
314 /// Builds a live adapter from a Google service-account JSON file.
315 ///
316 /// # Errors
317 ///
318 /// Everything [`Self::new`] returns, plus
319 /// [`FcmConfigError::ServiceAccount`] when the JSON does not parse or is
320 /// missing a field.
321 pub fn from_service_account_json(
322 http: Arc<dyn HttpClient>,
323 clock: Arc<dyn Clock>,
324 json: &str,
325 ) -> Result<Self, FcmConfigError> {
326 Self::new(
327 http,
328 clock,
329 FcmCredentials::from_service_account_json(json)?,
330 )
331 }
332
333 /// A degraded adapter that reports [`PushOutcome::NotConfigured`] without
334 /// any network call — for a venture with no Firebase credentials set.
335 #[must_use]
336 pub fn not_configured() -> Self {
337 Self {
338 inner: Inner::NotConfigured,
339 }
340 }
341}
342
343impl std::fmt::Debug for Fcm {
344 /// Names the endpoint it would post to and nothing else: no key, no
345 /// bearer token, no service-account address.
346 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347 match &self.inner {
348 Inner::Live(live) => f
349 .debug_struct("Fcm")
350 .field("send_url", &live.send_url)
351 .finish_non_exhaustive(),
352 Inner::NotConfigured => f.write_str("Fcm::not_configured()"),
353 }
354 }
355}
356
357/// Whether a project id can be spliced into the request path as-is.
358///
359/// A Firebase project id is lowercase letters, digits and hyphens; a project
360/// *number* is digits. Anything else — a `/`, a `..`, whitespace, a `%` — is
361/// not an id, and interpolating it would silently retarget the request at
362/// another project's endpoint (or fail far downstream as "could not build
363/// request"). Underscore and uppercase are tolerated because emulator and
364/// staging projects use them and neither can change the path's shape.
365fn is_wellformed_project_id(id: &str) -> bool {
366 !id.is_empty()
367 && id
368 .bytes()
369 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
370}
371
372impl Live {
373 /// The current bearer token: the cached one while it lives, otherwise a
374 /// fresh exchange at Google's token endpoint.
375 ///
376 /// The cache is read and written around the `await` rather than across it
377 /// ([`CachedToken::cached`] / [`CachedToken::store`]): the exchange is an
378 /// HTTP call and no lock may be held over one. Two sends racing a cold
379 /// cache therefore cost one extra exchange — Google issues both tokens
380 /// and either works, unlike Apple's once-per-20-minutes provider JWT,
381 /// which is why *that* adapter mints under the lock instead.
382 async fn access_token(&self) -> Result<String, PushError> {
383 if let Some(token) = self.token.cached(self.clock.as_ref(), &()) {
384 return Ok(token);
385 }
386 let (token, lifetime) = self.exchange().await?;
387 self.token
388 .store(self.clock.as_ref(), &(), token.clone(), lifetime);
389 Ok(token)
390 }
391
392 /// Signs a service-account assertion and trades it for a bearer token,
393 /// returning the token and how long it may be reused.
394 async fn exchange(&self) -> Result<(String, Duration), PushError> {
395 let now_unix = self.clock.now().unix_timestamp();
396 let assertion = self.signer.sign_jwt(
397 &json!({ "alg": "RS256", "typ": "JWT" }),
398 &json!({
399 "iss": self.client_email,
400 "scope": SCOPE,
401 "aud": self.token_uri,
402 "iat": now_unix,
403 "exp": now_unix.saturating_add(ASSERTION_TTL_SECS),
404 }),
405 );
406 let form = serde_urlencoded::to_string([
407 ("grant_type", JWT_BEARER_GRANT),
408 ("assertion", assertion.as_str()),
409 ])
410 .map_err(|err| PushError::transient(format!("could not encode token request: {err}")))?;
411
412 let request = Request::builder()
413 .method("POST")
414 .uri(&self.token_uri)
415 .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
416 .body(Bytes::from(form))
417 .map_err(|err| PushError::Rejected(format!("could not build token request: {err}")))?;
418 let response = self
419 .http
420 .send(request)
421 .await
422 .map_err(|err| PushError::transient(err.to_string()))?;
423
424 let status = response.status();
425 if !status.is_success() {
426 let (code, detail) = oauth_error(response.body());
427 let message = format!("fcm token exchange {}: {detail}", status.as_u16());
428 // A failure here is the adapter's own credential rather than the
429 // caller's notification, but that does not make all of them
430 // retryable. `invalid_grant` and `unauthorized_client` are
431 // permanent — a revoked or replaced service-account key, or an
432 // account without the grant — and retrying them forever turns a
433 // configuration mistake into an outbox that never drains. A
434 // briefly unavailable token endpoint (5xx,
435 // `temporarily_unavailable`) is the retryable case and stays
436 // retryable, as does anything unrecognised.
437 //
438 // Clock skew also surfaces as `invalid_grant`, which is why the
439 // assertion's `iat` comes from the [`Clock`] port rather than a
440 // wall clock the isolate cannot trust: a runtime whose clock is
441 // right cannot land here by accident.
442 return Err(match code.as_deref() {
443 Some(INVALID_GRANT | UNAUTHORIZED_CLIENT) => PushError::Rejected(message),
444 _ => PushError::transient_after(
445 message,
446 retry_after(response.headers(), self.clock.as_ref()),
447 ),
448 });
449 }
450
451 let token: TokenResponse = serde_json::from_slice(response.body())
452 .map_err(|err| PushError::transient(format!("unparseable token response: {err}")))?;
453 if token.access_token.is_empty() {
454 return Err(PushError::transient("token response carried no token"));
455 }
456 // Retire the token early: `expires_in` is when Google stops accepting
457 // it, and a send that starts a second before that finishes after it.
458 let lifetime = Duration::from_secs(token.expires_in).saturating_sub(EXPIRY_MARGIN);
459 Ok((token.access_token, lifetime))
460 }
461
462 /// One `messages:send` attempt with the bearer token it is handed.
463 async fn post_message(
464 &self,
465 token: &str,
466 body: Bytes,
467 ) -> Result<http::Response<Bytes>, PushError> {
468 let request = Request::builder()
469 .method("POST")
470 .uri(&self.send_url)
471 .header(AUTHORIZATION, format!("Bearer {token}"))
472 .header(CONTENT_TYPE, "application/json")
473 .body(body)
474 .map_err(|err| PushError::Rejected(format!("could not build request: {err}")))?;
475 self.http
476 .send(request)
477 .await
478 .map_err(|err| PushError::transient(err.to_string()))
479 }
480}
481
482/// `message.android.priority` for a [`Priority`].
483///
484/// Unlike APNs, FCM allows a high-priority data-only message — that is how an
485/// Android app is woken — so `silent` does not force the value down.
486fn priority_value(priority: Priority) -> &'static str {
487 match priority {
488 Priority::Immediate => "HIGH",
489 Priority::Conserve => "NORMAL",
490 }
491}
492
493/// Whether a value names something to fetch rather than something bundled.
494///
495/// `message.notification.image` is a URL the device downloads;
496/// `android.notification.icon` is the name of a drawable resource *inside the
497/// app*. The port has one `icon` field for both, so which one it means is
498/// decided by whether it looks like a URL — putting a URL in `icon` shows no
499/// icon at all, and a drawable name in `image` shows no image.
500fn is_url(value: &str) -> bool {
501 value.starts_with("https://") || value.starts_with("http://")
502}
503
504/// FCM data values must be **strings**. A caller's nested JSON is serialised
505/// rather than dropped or rejected: `{"room": {"id": 42}}` travels as
506/// `"{\"id\":42}"`, which the app parses back. A string is passed through as
507/// itself, so `"42"` does not become `"\"42\""`.
508fn data_value(value: &Value) -> String {
509 match value {
510 Value::String(text) => text.clone(),
511 other => other.to_string(),
512 }
513}
514
515/// The `message.data` map: the caller's `data` object with every value
516/// stringified, plus `url` where the notification carries one.
517fn build_data(notification: &Notification) -> Map<String, Value> {
518 let mut data = Map::new();
519 // A non-object `data` has no key-value shape to flatten, so there is
520 // nothing to send; the same rule the APNs adapter applies when merging.
521 if let Value::Object(object) = ¬ification.data {
522 for (key, value) in object {
523 data.insert(key.clone(), Value::String(data_value(value)));
524 }
525 }
526 // `click_action` only exists on the notification block, which a silent
527 // message does not have — so the tap target also travels in `data`, the
528 // same key the APNs and Web Push adapters use. The typed field wins over
529 // a "url" in `data`: it is the field the port documents.
530 if let Some(url) = ¬ification.url {
531 data.insert("url".to_owned(), Value::String(url.clone()));
532 }
533 data
534}
535
536/// `message.android.notification`: the fields that describe how Android shows
537/// an alert. Absent entirely on a silent message.
538///
539/// `thread_id` is **not** mapped. The port defines it as "groups related
540/// notifications in the UI", which APNs (`thread-id`) and the web
541/// Notification API do; FCM's `android.notification.tag` is not that. A tag
542/// *replaces* the notification already in the drawer, so five messages
543/// sharing a `thread_id` would show as five grouped on iOS and as one on
544/// Android, with four silently destroyed. Android grouping is a client-side
545/// call (`NotificationCompat.Builder.setGroup`) with no field in the HTTP v1
546/// message, and `collapse_id` already carries the caller's coalescing intent
547/// to `android.collapse_key`.
548fn build_android_notification(notification: &Notification) -> Map<String, Value> {
549 let mut block = Map::new();
550 if let Some(category) = ¬ification.category {
551 // FCM's channel_id is the Android notification channel, which is what
552 // a category names on this platform.
553 block.insert("channel_id".to_owned(), json!(category));
554 }
555 if let Some(url) = ¬ification.url {
556 block.insert("click_action".to_owned(), json!(url));
557 }
558 if let Some(icon) = notification.icon.as_deref().filter(|icon| !is_url(icon)) {
559 block.insert("icon".to_owned(), json!(icon));
560 }
561 if let Some(loc) = ¬ification.loc {
562 // `*_loc_args` are the substitutions for a `*_loc_key`, so each list
563 // is nested inside its key's arm: args without a key substitute into
564 // nothing and FCM defines no meaning for them.
565 if let Some(key) = &loc.title_loc_key {
566 block.insert("title_loc_key".to_owned(), json!(key));
567 if !loc.title_loc_args.is_empty() {
568 block.insert("title_loc_args".to_owned(), json!(loc.title_loc_args));
569 }
570 }
571 if let Some(key) = &loc.body_loc_key {
572 block.insert("body_loc_key".to_owned(), json!(key));
573 if !loc.body_loc_args.is_empty() {
574 block.insert("body_loc_args".to_owned(), json!(loc.body_loc_args));
575 }
576 }
577 }
578 block
579}
580
581/// The whole `{"message": {…}}` request body.
582///
583/// A silent notification is a **data-only** message: no `notification` block
584/// and no `android.notification` block, which is what stops Android from
585/// displaying anything and delivers the payload to the app instead.
586fn build_message(registration_token: &str, notification: &Notification) -> Value {
587 let mut message = Map::new();
588 message.insert("token".to_owned(), json!(registration_token));
589
590 if !notification.silent {
591 let mut visible = Map::new();
592 visible.insert("title".to_owned(), json!(notification.title));
593 visible.insert("body".to_owned(), json!(notification.body));
594 if let Some(image) = notification.icon.as_deref().filter(|icon| is_url(icon)) {
595 visible.insert("image".to_owned(), json!(image));
596 }
597 message.insert("notification".to_owned(), Value::Object(visible));
598 }
599
600 let mut android = Map::new();
601 android.insert(
602 "priority".to_owned(),
603 json!(priority_value(notification.priority)),
604 );
605 if let Some(ttl) = notification.ttl {
606 // FCM writes a TTL as a duration string, unlike the absolute epoch
607 // APNs takes. A sub-second TTL rounds up to one second through
608 // `ttl_secs` rather than truncating to `0s`, which would mean the
609 // opposite ("deliver now or drop").
610 android.insert("ttl".to_owned(), json!(format!("{}s", ttl_secs(ttl))));
611 }
612 if let Some(collapse_id) = ¬ification.collapse_id {
613 android.insert("collapse_key".to_owned(), json!(collapse_id));
614 }
615 if !notification.silent {
616 let block = build_android_notification(notification);
617 if !block.is_empty() {
618 android.insert("notification".to_owned(), Value::Object(block));
619 }
620 }
621 message.insert("android".to_owned(), Value::Object(android));
622
623 let data = build_data(notification);
624 if !data.is_empty() {
625 message.insert("data".to_owned(), Value::Object(data));
626 }
627
628 json!({ "message": Value::Object(message) })
629}
630
631/// What an FCM error body said, read **once** per failed send: the body is
632/// parsed a single time and both the code the mapping keys off and the text
633/// the log carries come out of that one parse.
634struct FcmFailure {
635 /// The `errorCode` of the `google.firebase.fcm.v1.FcmError` detail, which
636 /// is the only field in an FCM error body that identifies the failure
637 /// precisely.
638 code: Option<String>,
639 /// A human-readable detail: `errorCode: message` where there is a code,
640 /// else `error.status`, else `error.message`.
641 detail: String,
642}
643
644impl FcmFailure {
645 fn parse(body: &[u8]) -> Self {
646 let Ok(value) = serde_json::from_slice::<Value>(body) else {
647 return Self::opaque();
648 };
649 let Some(error) = value.get("error") else {
650 return Self::opaque();
651 };
652 let code = error
653 .get("details")
654 .and_then(Value::as_array)
655 .and_then(|details| {
656 details
657 .iter()
658 .find_map(|detail| detail.get("errorCode")?.as_str().map(str::to_owned))
659 });
660 let field = |name| error.get(name).and_then(Value::as_str);
661 // Both, where there are both: `errorCode` says *what* went wrong and
662 // `message` says *which value* did — "Invalid value at
663 // 'message.android.ttl'". Keeping only the code throws away the one
664 // part of the body that names the field, which is the part a reader
665 // of the log needs to fix it.
666 let detail = match (code.as_deref(), field("message")) {
667 (Some(code), Some(message)) => format!("{code}: {message}"),
668 (Some(code), None) => code.to_owned(),
669 (None, message) => field("status").or(message).unwrap_or(NO_DETAIL).to_owned(),
670 };
671 Self { code, detail }
672 }
673
674 /// A body that is not an FCM error at all — a proxy's HTML, an empty
675 /// 502, a load balancer's plain text.
676 fn opaque() -> Self {
677 Self {
678 code: None,
679 detail: NO_DETAIL.to_owned(),
680 }
681 }
682}
683
684/// What an error body that says nothing usable is reported as.
685const NO_DETAIL: &str = "no error detail";
686
687/// The `error`/`error_description` of an OAuth 2.0 error body (RFC 6749
688/// §5.2), which is what the token endpoint returns rather than an FCM error.
689/// The code is returned alongside the text because it, not the HTTP status,
690/// is what separates a permanently bad credential from a bad moment.
691fn oauth_error(body: &[u8]) -> (Option<String>, String) {
692 let Ok(value) = serde_json::from_slice::<Value>(body) else {
693 return (None, NO_DETAIL.to_owned());
694 };
695 let code = value
696 .get("error")
697 .and_then(Value::as_str)
698 .map(str::to_owned);
699 let description = value.get("error_description").and_then(Value::as_str);
700 let detail = match (code.as_deref(), description) {
701 (Some(code), Some(description)) => format!("{code}: {description}"),
702 (Some(code), None) => code.to_owned(),
703 (None, Some(description)) => description.to_owned(),
704 (None, None) => NO_DETAIL.to_owned(),
705 };
706 (code, detail)
707}
708
709/// Maps an FCM failure to the port's error.
710///
711/// The `errorCode` decides where there is one, because the HTTP status alone
712/// is ambiguous: a `404` is `UNREGISTERED` (prune the token) *or* a project
713/// that does not exist (a configuration bug), and `THIRD_PARTY_AUTH_ERROR` —
714/// the missing-APNs-credential case, which is a rejection and not an auth
715/// failure of ours — arrives as a `401`. Status is the fallback for a body
716/// with no recognisable detail, which is what a proxy or an outage returns.
717fn map_error(status: StatusCode, failure: &FcmFailure, retry_after: Option<Duration>) -> PushError {
718 let message = format!("fcm {}: {}", status.as_u16(), failure.detail);
719 match failure.code.as_deref() {
720 Some(UNREGISTERED) => PushError::Unregistered,
721 Some(INVALID_ARGUMENT | SENDER_ID_MISMATCH | THIRD_PARTY_AUTH_ERROR | APNS_AUTH_ERROR) => {
722 PushError::Rejected(message)
723 }
724 Some(QUOTA_EXCEEDED | UNAVAILABLE | INTERNAL) => {
725 PushError::transient_after(message, retry_after)
726 }
727 // UNSPECIFIED_ERROR, anything Google adds later, and a body with no
728 // recognisable detail at all: the status is what is left.
729 _ => map_by_status(status, &message, retry_after),
730 }
731}
732
733/// The fallback mapping, on HTTP status alone.
734///
735/// A `404` is **not** a prune signal here. `PushError::Unregistered` tells
736/// the caller to delete the device token, and only the explicit
737/// `UNREGISTERED` code means that. A bare `404` is what FCM answers for a
738/// project that does not exist — point `FCM_SERVICE_ACCOUNT_JSON` at a
739/// deleted or mistyped project and *every* send gets one — so treating it as
740/// a prune would delete a venture's whole device registry, one send at a
741/// time, from a configuration typo. Rejected is the honest answer: nothing
742/// about the recipient is known to be wrong.
743fn map_by_status(status: StatusCode, message: &str, retry_after: Option<Duration>) -> PushError {
744 if status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() {
745 return PushError::transient_after(message.to_owned(), retry_after);
746 }
747 PushError::Rejected(message.to_owned())
748}
749
750#[async_trait]
751impl Push for Fcm {
752 async fn send(
753 &self,
754 to: &Recipient,
755 notification: &Notification,
756 ) -> Result<PushOutcome, PushError> {
757 // One transport per adapter: a venture that also speaks APNs or Web
758 // Push puts a `RoutingPush` in front (ADR 0015). This is decided
759 // *before* configuration is: an unconfigured FCM adapter still does
760 // not serve Web Push, and answering `NotConfigured` for a Web Push
761 // recipient would claim a transport it will never carry — a router
762 // reading that answer would stop looking for the adapter that does.
763 let Recipient::Fcm { registration_token } = to else {
764 return Err(PushError::unsupported_recipient(to));
765 };
766 let live = match &self.inner {
767 Inner::NotConfigured => return Ok(PushOutcome::NotConfigured),
768 Inner::Live(live) => live,
769 };
770 if registration_token.trim().is_empty() {
771 return Err(PushError::Rejected(
772 "malformed registration token".to_owned(),
773 ));
774 }
775
776 let body = Bytes::from(build_message(registration_token, notification).to_string());
777
778 // At most two attempts, and the second only after a 401 that says our
779 // bearer token — not the caller's request — is the problem.
780 let mut reauthenticated = false;
781 loop {
782 let token = live.access_token().await?;
783 let response = live.post_message(&token, body.clone()).await?;
784 let status = response.status();
785
786 if status.is_success() {
787 // `{"name": "projects/{id}/messages/{message_id}"}`.
788 let id = serde_json::from_slice::<Value>(response.body())
789 .ok()
790 .and_then(|value| value.get("name")?.as_str().map(str::to_owned));
791 return Ok(PushOutcome::Delivered { id });
792 }
793
794 // The error body is read once, here: both the code the mapping
795 // keys off and the text the log carries come out of that parse.
796 let failure = FcmFailure::parse(response.body());
797 // `THIRD_PARTY_AUTH_ERROR` is also a 401, and re-minting our own
798 // token cannot fix a missing APNs key in the Firebase project —
799 // so it is excluded here and rejected below.
800 let ours = status == StatusCode::UNAUTHORIZED
801 && !matches!(
802 failure.code.as_deref(),
803 Some(THIRD_PARTY_AUTH_ERROR | APNS_AUTH_ERROR)
804 );
805 if ours && !reauthenticated {
806 tracing::debug!("fcm rejected the bearer token; re-exchanging and retrying once");
807 live.token.invalidate(&());
808 reauthenticated = true;
809 continue;
810 }
811
812 let detail = &failure.detail;
813 let retry_after = retry_after(response.headers(), live.clock.as_ref());
814 if ours {
815 // A freshly exchanged token was refused too. Retryable, like
816 // the APNs expired-provider-token path: clock skew and a
817 // key mid-rotation both land here, and an outbox backing off
818 // is a better answer than discarding the notification.
819 return Err(PushError::transient_after(
820 format!("fcm 401: {detail} (a freshly exchanged token was refused)"),
821 retry_after,
822 ));
823 }
824 return Err(map_error(status, &failure, retry_after));
825 }
826 }
827}