// Package apns delivers Apple Push Notification service (APNs) notifications to // iOS devices whose SSE connection is closed. It mirrors the shape of the push // (Web Push) package: an inert Sender when unconfigured, per-subscription // localization via the shared push.Localize machinery, and automatic pruning of // dead device tokens on HTTP 410 (Unregistered) responses. // // APNs uses provider-token authentication: a cached ES256 JWT signed with the // team's .p8 key, refreshed every ~50 minutes (Apple requires 20-60 minutes). // HTTP/2 is negotiated automatically by Go's net/http Transport over TLS. package apns import ( "bytes" "context" "crypto/ecdsa" "crypto/x509" "encoding/json" "encoding/pem" "fmt" "io" "log/slog" "net/http" "strings" "sync" "time" "github.com/golang-jwt/jwt/v5" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/push" ) // apnsTimeout bounds a single Notify call (including all subscriptions for one // device), so a hung APNs endpoint cannot leak goroutines. const apnsTimeout = 15 * time.Second // jwtRefreshAge is how long we cache a provider JWT before minting a fresh one. // Apple requires a new token between 20 and 60 minutes; 50 minutes gives a safe margin. const jwtRefreshAge = 50 * time.Minute // prodBase and sandboxBase are the APNs HTTP/2 endpoints. const ( prodBase = "https://api.push.apple.com" sandboxBase = "https://api.sandbox.push.apple.com" ) // apsPayload is the JSON body for an APNs alert notification. type apsPayload struct { APS apsEnvelope `json:"aps"` Type string `json:"type"` URL string `json:"url"` } type apsEnvelope struct { Alert apsAlert `json:"alert"` Sound string `json:"sound"` } type apsAlert struct { Title string `json:"title"` Body string `json:"body,omitempty"` } // Sender delivers APNs notifications and prunes dead device tokens. A Sender // built without all required config (key/keyID/teamID/topic) is inert: Enabled // reports false and Notify is a no-op. Safe for concurrent use. type Sender struct { queries *db.Queries key *ecdsa.PrivateKey keyID string teamID string topic string base string // APNs base URL; settable for tests client *http.Client mu sync.Mutex cachedJWT string jwtMintedAt time.Time } // NewSender returns a configured APNs Sender. keyPEM is the .p8 PKCS8 EC private // key Apple issues. env is "prod" (default) or "sandbox". When any of keyPEM, // keyID, teamID, or topic is empty/nil the returned Sender is inert (Enabled // returns false, Notify is a no-op) so callers can invoke it unconditionally. // A PEM parse failure is logged and also yields an inert Sender rather than a // hard error, so startup can proceed with APNs disabled. func NewSender(queries *db.Queries, keyPEM []byte, keyID, teamID, topic, env string) *Sender { if len(keyPEM) == 0 || keyID == "" || teamID == "" || topic == "" { return &Sender{} } block, _ := pem.Decode(keyPEM) if block == nil { slog.Error("apns: cannot decode PEM block from .p8 key; APNs disabled") return &Sender{} } raw, err := x509.ParsePKCS8PrivateKey(block.Bytes) if err != nil { slog.Error("apns: cannot parse PKCS8 private key; APNs disabled", "err", err) return &Sender{} } ecKey, ok := raw.(*ecdsa.PrivateKey) if !ok { slog.Error("apns: .p8 key is not an ECDSA private key; APNs disabled") return &Sender{} } base := prodBase switch strings.ToLower(strings.TrimSpace(env)) { case "sandbox": base = sandboxBase case "", "prod", "production": base = prodBase default: // Unknown env is an operator typo; default to prod but log loudly so the // sandbox/prod mismatch footgun (dev tokens → prod endpoint → BadDeviceToken) // surfaces instead of silently selecting the wrong endpoint. slog.Warn("apns: unknown CDROP_APNS_ENV, defaulting to production", "env", env) } return &Sender{ queries: queries, key: ecKey, keyID: keyID, teamID: teamID, topic: topic, base: base, client: &http.Client{Timeout: apnsTimeout}, } } // Enabled reports whether APNs is configured. func (s *Sender) Enabled() bool { return s != nil && s.key != nil } // Notify delivers n to every APNs subscription of (userID, deviceName). // Best-effort and safe to call in a goroutine: failures are logged, a token the // APNs service marks Unregistered (410) is pruned, and a no-op results when // disabled or the device has no APNs subscriptions. func (s *Sender) Notify(ctx context.Context, userID, deviceName string, n push.Notification) { if !s.Enabled() { return } ctx, cancel := context.WithTimeout(ctx, apnsTimeout) defer cancel() subs, err := s.queries.ListAPNsSubscriptionsByUserDevice(ctx, db.ListAPNsSubscriptionsByUserDeviceParams{ UserID: userID, DeviceName: deviceName, }) if err != nil { slog.Error("apns: list subscriptions failed", "user", userID, "device", deviceName, "err", err) return } if len(subs) == 0 { return } jwtStr, err := s.providerJWT() if err != nil { slog.Error("apns: jwt signing failed", "err", err) return } for _, sub := range subs { title, body := push.Localize(n.Type, n.Params, sub.Locale) url := n.URL if url == "" { url = "/" } payload := apsPayload{ APS: apsEnvelope{ Alert: apsAlert{Title: title, Body: body}, Sound: "default", }, Type: n.Type, URL: url, } s.send(ctx, sub.ID, sub.Endpoint, jwtStr, payload) } } func (s *Sender) send( ctx context.Context, subID, deviceToken, jwtStr string, payload apsPayload, ) { body, err := json.Marshal(payload) if err != nil { slog.Error("apns: marshal payload failed", "err", err) return } reqURL := fmt.Sprintf("%s/3/device/%s", s.base, deviceToken) req, err := http.NewRequestWithContext(ctx, http.MethodPost, reqURL, bytes.NewReader(body)) if err != nil { slog.Error("apns: build request failed", "err", err) return } req.Header.Set("Authorization", "bearer "+jwtStr) req.Header.Set("apns-topic", s.topic) req.Header.Set("apns-push-type", "alert") req.Header.Set("apns-priority", "10") req.Header.Set("Content-Type", "application/json") resp, err := s.client.Do(req) if err != nil { slog.Warn("apns: send failed", "token_prefix", tokenPrefix(deviceToken), "err", err) return } defer resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { _ = s.queries.TouchPushSubscription(ctx, db.TouchPushSubscriptionParams{ LastUsedAt: time.Now().Unix(), ID: subID, }) return } // Non-2xx: APNs returns a small JSON body whose `reason` names the exact failure // (BadDeviceToken, InvalidProviderToken, BadTopic, ExpiredProviderToken, …). It is // the single most useful diagnostic, so always surface it rather than logging a // bare numeric status. reason := apnsReason(resp.Body) switch { case resp.StatusCode == http.StatusGone, reason == "BadDeviceToken", reason == "DeviceTokenNotForTopic": // 410 Unregistered, or a permanently-invalid token (the app was uninstalled, // the token rotated, or it's malformed / for the wrong topic). Prune the row // so we stop re-firing a guaranteed-fail request on every future Notify. if err := s.queries.DeletePushSubscription(ctx, subID); err != nil { slog.Error("apns: prune dead token failed", "id", subID, "reason", reason, "err", err) } case reason == "ExpiredProviderToken": // Clock skew pushed the cached provider JWT past Apple's 60-min limit before // our 50-min refresh. Bust the cache so the next call re-mints immediately; // otherwise every push fails for the rest of the refresh window. s.mu.Lock() s.cachedJWT = "" s.mu.Unlock() slog.Warn("apns: provider token expired, busting cache", "token_prefix", tokenPrefix(deviceToken)) default: slog.Warn("apns: rejected", "status", resp.StatusCode, "reason", reason, "token_prefix", tokenPrefix(deviceToken)) } } // apnsReason extracts the `reason` field from an APNs error response body (small // JSON like {"reason":"BadDeviceToken"}). Returns "" if absent or unparseable. func apnsReason(body io.Reader) string { var e struct { Reason string `json:"reason"` } _ = json.NewDecoder(io.LimitReader(body, 1024)).Decode(&e) return e.Reason } // providerJWT returns a cached ES256 provider JWT, re-minting when the cached // token is older than jwtRefreshAge. Safe for concurrent callers (mutex-protected). func (s *Sender) providerJWT() (string, error) { s.mu.Lock() defer s.mu.Unlock() if s.cachedJWT != "" && time.Since(s.jwtMintedAt) < jwtRefreshAge { return s.cachedJWT, nil } now := time.Now() tok := jwt.NewWithClaims(jwt.SigningMethodES256, jwt.MapClaims{ "iss": s.teamID, "iat": now.Unix(), }) tok.Header["kid"] = s.keyID signed, err := tok.SignedString(s.key) if err != nil { return "", fmt.Errorf("sign apns jwt: %w", err) } s.cachedJWT = signed s.jwtMintedAt = now return signed, nil } // tokenPrefix returns the first 8 hex chars of a device token for log // messages — enough to correlate without exposing the full opaque token. func tokenPrefix(token string) string { if len(token) <= 8 { return token } return token[:8] + "..." }