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:
parent
f7c9eddef5
commit
de8618191c
95
internal/services/notification/fcm_credentials_normalize.go
Normal file
95
internal/services/notification/fcm_credentials_normalize.go
Normal 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:]
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user