From de8618191c26161b9f78ce9927533718dfc5e2fc Mon Sep 17 00:00:00 2001 From: Yared Yemane Date: Wed, 20 May 2026 08:57:26 -0700 Subject: [PATCH] Normalize broken FCM service account JSON (.env PEM newlines). Repair multiline PEM inside private_key before Firebase init; add unit test; use normalized JSON for credentials. Co-authored-by: Cursor --- .../notification/fcm_credentials_normalize.go | 95 +++++++++++++++++++ .../fcm_credentials_normalize_test.go | 40 ++++++++ internal/services/notification/service.go | 10 +- 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 internal/services/notification/fcm_credentials_normalize.go create mode 100644 internal/services/notification/fcm_credentials_normalize_test.go diff --git a/internal/services/notification/fcm_credentials_normalize.go b/internal/services/notification/fcm_credentials_normalize.go new file mode 100644 index 0000000..6a61da4 --- /dev/null +++ b/internal/services/notification/fcm_credentials_normalize.go @@ -0,0 +1,95 @@ +package notificationservice + +import ( + "encoding/json" + "strconv" + "strings" +) + +// normalizeFCMCredentialsJSON returns credential bytes valid for encoding/json and +// Firebase's credentials parser. Repairs the common mistake of pasting PEM with +// literal newlines inside the JSON "private_key" string (.env multiline breakage). +func normalizeFCMCredentialsJSON(raw string) ([]byte, error) { + s := strings.TrimSpace(strings.TrimPrefix(raw, "\ufeff")) + if json.Valid([]byte(s)) { + return []byte(s), nil + } + if fixed := tryRepairFirebasePrivateKeyNewlines(s); fixed != "" && fixed != s { + b := []byte(fixed) + if json.Valid(b) { + return b, nil + } + } + var m map[string]any + err := json.Unmarshal([]byte(strings.TrimSpace(s)), &m) + return nil, err +} + +// tryRepairFirebasePrivateKeyNewlines rewrites `"private_key": "...(PEM with real newlines)..."` +// using strconv.Quote for the inner PEM. Empty string means no rewrite applied. +func tryRepairFirebasePrivateKeyNewlines(s string) string { + const beginRSA = "-----BEGIN RSA PRIVATE KEY-----" + const endRSA = "-----END RSA PRIVATE KEY-----" + const beginPK = "-----BEGIN PRIVATE KEY-----" + const endPK = "-----END PRIVATE KEY-----" + + beginIdx := strings.Index(s, beginRSA) + endMarker := endRSA + endIdx := strings.Index(s, endMarker) + if beginIdx < 0 { + beginIdx = strings.Index(s, beginPK) + endMarker = endPK + endIdx = strings.Index(s, endMarker) + } + if beginIdx < 0 || endIdx < beginIdx { + return "" + } + endInclusive := endIdx + len(endMarker) + + const pkAttr = `"private_key"` + pkIdx := strings.Index(s, pkAttr) + if pkIdx < 0 || pkIdx > beginIdx { + return "" + } + + restAfterKey := s[pkIdx+len(pkAttr):] + colonRel := strings.Index(restAfterKey, ":") + if colonRel < 0 { + return "" + } + searchFrom := pkIdx + len(pkAttr) + colonRel + 1 + + openQuote := -1 + for i := searchFrom; i < beginIdx; i++ { + c := s[i] + if c == '"' { + openQuote = i + break + } + if c != ' ' && c != '\t' && c != '\n' && c != '\r' { + return "" + } + } + if openQuote < 0 { + return "" + } + + closeQuote := -1 + for i := endInclusive; i < len(s); i++ { + c := s[i] + if c == '"' { + closeQuote = i + break + } + if c != ' ' && c != '\t' && c != '\n' && c != '\r' { + return "" + } + } + if closeQuote < 0 || closeQuote <= openQuote { + return "" + } + + inner := s[openQuote+1 : closeQuote] + quoted := strconv.Quote(inner) + return s[:openQuote] + quoted + s[closeQuote+1:] +} diff --git a/internal/services/notification/fcm_credentials_normalize_test.go b/internal/services/notification/fcm_credentials_normalize_test.go new file mode 100644 index 0000000..fb1fda2 --- /dev/null +++ b/internal/services/notification/fcm_credentials_normalize_test.go @@ -0,0 +1,40 @@ +package notificationservice + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestNormalizeFCMCredentialsJSON_multilinePrivateKey(t *testing.T) { + raw := `{` + + `"type":"service_account",` + + `"project_id":"my-proj",` + + `"private_key":"-----BEGIN PRIVATE KEY-----` + + `\nLINETWO\n` + + `-----END PRIVATE KEY-----\n",` + + `"client_email":"x@some.iam.gserviceaccount.com"` + + `}` + raw = strings.ReplaceAll(raw, "\\n", "\n") + + var broken map[string]any + if err := json.Unmarshal([]byte(raw), &broken); err == nil { + t.Fatal("expected broken JSON fixture to fail raw parse") + } + + fixed, err := normalizeFCMCredentialsJSON(raw) + if err != nil { + t.Fatalf("normalize: %v", err) + } + var m map[string]any + if err := json.Unmarshal(fixed, &m); err != nil { + t.Fatalf("unmarshal normalized: %v", err) + } + if got := m["project_id"]; got != "my-proj" { + t.Fatalf("project_id: %v", got) + } + pk, _ := m["private_key"].(string) + if !strings.Contains(pk, "LINETWO") { + t.Fatalf("private_key body missing") + } +} diff --git a/internal/services/notification/service.go b/internal/services/notification/service.go index e99b6ac..726f0b9 100644 --- a/internal/services/notification/service.go +++ b/internal/services/notification/service.go @@ -98,11 +98,15 @@ func (s *Service) initFCMClient() error { var opts []option.ClientOption var fbConfig *firebase.Config if s.config.FCMServiceAccountKey != "" { + credJSON, errNorm := normalizeFCMCredentialsJSON(s.config.FCMServiceAccountKey) + if errNorm != nil { + return fmt.Errorf("invalid FCM service account JSON: %w (hint: use valid JSON — minify with jq -c, set FCM_SERVICE_ACCOUNT_KEY_FILE, or ensure PEM in private_key has no raw line breaks inside the JSON string)", errNorm) + } var sa struct { ProjectID string `json:"project_id"` } - if err := json.Unmarshal([]byte(s.config.FCMServiceAccountKey), &sa); err != nil { - return fmt.Errorf("invalid FCM service account JSON: %w (hint: private_key must escape newlines as \\n; do not paste multiline JSON into .env unless quoted—use minified one line or FCM_SERVICE_ACCOUNT_KEY_FILE)", err) + if err := json.Unmarshal(credJSON, &sa); err != nil { + return fmt.Errorf("invalid FCM service account JSON: %w", err) } if strings.TrimSpace(sa.ProjectID) == "" { return fmt.Errorf("FCM_SERVICE_ACCOUNT_KEY is missing project_id") @@ -110,7 +114,7 @@ func (s *Service) initFCMClient() error { fbConfig = &firebase.Config{ ProjectID: strings.TrimSpace(sa.ProjectID), } - opts = append(opts, option.WithCredentialsJSON([]byte(s.config.FCMServiceAccountKey))) + opts = append(opts, option.WithCredentialsJSON(credJSON)) } // Initialize Firebase app