Files
honeyDueAPI/internal/push/fcm.go
Trey T b679f28e55 Production hardening: security, resilience, observability, and compliance
Password complexity: custom validator requiring uppercase, lowercase, digit (min 8 chars)
Token expiry: 90-day token lifetime with refresh endpoint (60-90 day renewal window)
Health check: /api/health/ now pings Postgres + Redis, returns 503 on failure
Audit logging: async audit_log table for auth events (login, register, delete, etc.)
Circuit breaker: APNs/FCM push sends wrapped with 5-failure threshold, 30s recovery
FK indexes: 27 missing foreign key indexes across all tables (migration 017)
CSP header: default-src 'none'; frame-ancestors 'none'
Gzip compression: level 5 with media endpoint skipper
Prometheus metrics: /metrics endpoint using existing monitoring service
External timeouts: 15s push, 30s SMTP, context timeouts on all external calls

Migrations: 016 (token created_at), 017 (FK indexes), 018 (audit_log)
Tests: circuit breaker (15), audit service (8), token refresh (7), health (4),
       middleware expiry (5), validator (new)
2026-03-26 14:05:28 -05:00

309 lines
8.8 KiB
Go

package push
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
"github.com/rs/zerolog/log"
"golang.org/x/oauth2/google"
"github.com/treytartt/honeydue-api/internal/config"
)
const (
// fcmV1EndpointFmt is the FCM HTTP v1 API endpoint template.
fcmV1EndpointFmt = "https://fcm.googleapis.com/v1/projects/%s/messages:send"
// fcmScope is the OAuth 2.0 scope required for FCM HTTP v1 API.
fcmScope = "https://www.googleapis.com/auth/firebase.messaging"
)
// FCMClient handles communication with Firebase Cloud Messaging
// using the HTTP v1 API and OAuth 2.0 service account authentication.
type FCMClient struct {
projectID string
endpoint string
httpClient *http.Client
}
// --- Request types (FCM v1 API) ---
// fcmV1Request is the top-level request body for the FCM v1 API.
type fcmV1Request struct {
Message *fcmV1Message `json:"message"`
}
// fcmV1Message represents a single FCM v1 message.
type fcmV1Message struct {
Token string `json:"token"`
Notification *FCMNotification `json:"notification,omitempty"`
Data map[string]string `json:"data,omitempty"`
Android *fcmAndroidConfig `json:"android,omitempty"`
}
// FCMNotification represents the notification payload.
type FCMNotification struct {
Title string `json:"title,omitempty"`
Body string `json:"body,omitempty"`
}
// fcmAndroidConfig provides Android-specific message configuration.
type fcmAndroidConfig struct {
Priority string `json:"priority,omitempty"`
}
// --- Response types (FCM v1 API) ---
// fcmV1Response is the successful response from the FCM v1 API.
type fcmV1Response struct {
Name string `json:"name"` // e.g. "projects/myproject/messages/0:1234567890"
}
// fcmV1ErrorResponse is the error response from the FCM v1 API.
type fcmV1ErrorResponse struct {
Error fcmV1Error `json:"error"`
}
// fcmV1Error contains the structured error details.
type fcmV1Error struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
Details json.RawMessage `json:"details,omitempty"`
}
// --- Error types ---
// FCMErrorCode represents well-known FCM v1 error codes for programmatic handling.
type FCMErrorCode string
const (
FCMErrUnregistered FCMErrorCode = "UNREGISTERED"
FCMErrQuotaExceeded FCMErrorCode = "QUOTA_EXCEEDED"
FCMErrUnavailable FCMErrorCode = "UNAVAILABLE"
FCMErrInternal FCMErrorCode = "INTERNAL"
FCMErrInvalidArgument FCMErrorCode = "INVALID_ARGUMENT"
FCMErrSenderIDMismatch FCMErrorCode = "SENDER_ID_MISMATCH"
FCMErrThirdPartyAuth FCMErrorCode = "THIRD_PARTY_AUTH_ERROR"
)
// FCMSendError is a structured error returned when an individual FCM send fails.
type FCMSendError struct {
Token string
StatusCode int
ErrorCode FCMErrorCode
Message string
}
func (e *FCMSendError) Error() string {
return fmt.Sprintf("FCM send failed for token %s: %s (status %d, code %s)",
truncateToken(e.Token), e.Message, e.StatusCode, e.ErrorCode)
}
// IsUnregistered returns true if the device token is no longer valid and should be removed.
func (e *FCMSendError) IsUnregistered() bool {
return e.ErrorCode == FCMErrUnregistered
}
// --- OAuth 2.0 transport ---
// oauth2BearerTransport is an http.RoundTripper that attaches an OAuth 2.0 Bearer
// token to every outgoing request. The token source handles refresh automatically.
type oauth2BearerTransport struct {
base http.RoundTripper
getToken func() (string, error)
}
func (t *oauth2BearerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
accessToken, err := t.getToken()
if err != nil {
return nil, fmt.Errorf("failed to obtain OAuth 2.0 token for FCM: %w", err)
}
r := req.Clone(req.Context())
r.Header.Set("Authorization", "Bearer "+accessToken)
return t.base.RoundTrip(r)
}
// --- Client construction ---
// NewFCMClient creates a new FCM client using the HTTP v1 API with OAuth 2.0
// service account authentication. It accepts either a path to a service account
// JSON file or the raw JSON content directly via config.
func NewFCMClient(cfg *config.PushConfig) (*FCMClient, error) {
if cfg.FCMProjectID == "" {
return nil, fmt.Errorf("FCM project ID not configured (set FCM_PROJECT_ID)")
}
credJSON, err := resolveServiceAccountJSON(cfg)
if err != nil {
return nil, err
}
// Create OAuth 2.0 credentials with the FCM messaging scope.
// The google library handles automatic token refresh.
creds, err := google.CredentialsFromJSON(context.Background(), credJSON, fcmScope)
if err != nil {
return nil, fmt.Errorf("failed to parse FCM service account credentials: %w", err)
}
// Build an HTTP client that automatically attaches and refreshes OAuth tokens.
transport := &oauth2BearerTransport{
base: http.DefaultTransport,
getToken: func() (string, error) {
tok, err := creds.TokenSource.Token()
if err != nil {
return "", err
}
return tok.AccessToken, nil
},
}
httpClient := &http.Client{
Timeout: 15 * time.Second,
Transport: transport,
}
endpoint := fmt.Sprintf(fcmV1EndpointFmt, cfg.FCMProjectID)
log.Info().
Str("project_id", cfg.FCMProjectID).
Str("endpoint", endpoint).
Msg("FCM v1 client initialized with OAuth 2.0")
return &FCMClient{
projectID: cfg.FCMProjectID,
endpoint: endpoint,
httpClient: httpClient,
}, nil
}
// resolveServiceAccountJSON returns the service account JSON bytes from config.
func resolveServiceAccountJSON(cfg *config.PushConfig) ([]byte, error) {
if cfg.FCMServiceAccountJSON != "" {
return []byte(cfg.FCMServiceAccountJSON), nil
}
if cfg.FCMServiceAccountPath != "" {
data, err := os.ReadFile(cfg.FCMServiceAccountPath)
if err != nil {
return nil, fmt.Errorf("failed to read FCM service account file %s: %w", cfg.FCMServiceAccountPath, err)
}
return data, nil
}
return nil, fmt.Errorf("FCM service account not configured (set FCM_SERVICE_ACCOUNT_PATH or FCM_SERVICE_ACCOUNT_JSON)")
}
// --- Sending ---
// Send sends a push notification to Android devices via the FCM HTTP v1 API.
// The v1 API requires one request per device token, so this iterates over all tokens.
// The method signature is kept identical to the previous legacy implementation
// so callers do not need to change.
func (c *FCMClient) Send(ctx context.Context, tokens []string, title, message string, data map[string]string) error {
if len(tokens) == 0 {
return nil
}
var sendErrors []error
successCount := 0
for _, token := range tokens {
err := c.sendOne(ctx, token, title, message, data)
if err != nil {
log.Error().
Err(err).
Str("token", truncateToken(token)).
Msg("FCM v1 notification failed")
sendErrors = append(sendErrors, err)
continue
}
successCount++
log.Debug().
Str("token", truncateToken(token)).
Msg("FCM v1 notification sent successfully")
}
log.Info().
Int("total", len(tokens)).
Int("success", successCount).
Int("failed", len(sendErrors)).
Msg("FCM v1 batch send complete")
if len(sendErrors) > 0 && successCount == 0 {
return fmt.Errorf("all FCM notifications failed: first error: %w", sendErrors[0])
}
return nil
}
// sendOne sends a single FCM v1 message to one device token.
func (c *FCMClient) sendOne(ctx context.Context, token, title, message string, data map[string]string) error {
reqBody := fcmV1Request{
Message: &fcmV1Message{
Token: token,
Notification: &FCMNotification{
Title: title,
Body: message,
},
Data: data,
Android: &fcmAndroidConfig{
Priority: "HIGH",
},
},
}
body, err := json.Marshal(reqBody)
if err != nil {
return fmt.Errorf("failed to marshal FCM v1 message: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create FCM v1 request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("failed to send FCM v1 request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("failed to read FCM v1 response body: %w", err)
}
if resp.StatusCode == http.StatusOK {
return nil
}
// Parse the error response for structured error information.
return parseFCMV1Error(token, resp.StatusCode, respBody)
}
// parseFCMV1Error extracts a structured FCMSendError from the v1 API error response.
func parseFCMV1Error(token string, statusCode int, body []byte) *FCMSendError {
var errResp fcmV1ErrorResponse
if err := json.Unmarshal(body, &errResp); err != nil {
return &FCMSendError{
Token: token,
StatusCode: statusCode,
Message: fmt.Sprintf("unparseable error response: %s", string(body)),
}
}
return &FCMSendError{
Token: token,
StatusCode: statusCode,
ErrorCode: FCMErrorCode(errResp.Error.Status),
Message: errResp.Error.Message,
}
}