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 <cursoragent@cursor.com>
This commit is contained in:
Yared Yemane 2026-05-20 08:57:26 -07:00
parent f7c9eddef5
commit de8618191c
3 changed files with 142 additions and 3 deletions

View File

@ -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:]
}

View File

@ -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")
}
}

View File

@ -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