{"record":{"id":"9766641c1a45ba3d","repo":"router-for-me/CLIProxyAPI","slug":"service-account-missing-private-key","errorCode":null,"errorMessage":"service account missing private_key","messagePattern":"service account missing private_key","errorType":"validation","errorClass":null,"httpStatus":400,"severity":"error","filePath":"internal/auth/vertex/keyutil.go","lineNumber":43,"sourceCode":"\tif err != nil {\n\t\treturn raw, err\n\t}\n\tout, err := json.Marshal(normalized)\n\tif err != nil {\n\t\treturn raw, err\n\t}\n\treturn out, nil\n}\n\n// NormalizeServiceAccountMap returns a copy of the given service account map with\n// a sanitized private_key field that is guaranteed to contain a valid RSA PRIVATE KEY PEM block.\nfunc NormalizeServiceAccountMap(sa map[string]any) (map[string]any, error) {\n\tif sa == nil {\n\t\treturn nil, fmt.Errorf(\"service account payload is empty\")\n\t}\n\tpk, _ := sa[\"private_key\"].(string)\n\tif strings.TrimSpace(pk) == \"\" {\n\t\treturn nil, fmt.Errorf(\"service account missing private_key\")\n\t}\n\tnormalized, err := sanitizePrivateKey(pk)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\tclone := make(map[string]any, len(sa))\n\tfor k, v := range sa {\n\t\tclone[k] = v\n\t}\n\tclone[\"private_key\"] = normalized\n\treturn clone, nil\n}\n\nfunc sanitizePrivateKey(raw string) (string, error) {\n\tpk := strings.ReplaceAll(raw, \"\\r\\n\", \"\\n\")\n\tpk = strings.ReplaceAll(pk, \"\\r\", \"\\n\")\n\tpk = stripANSIEscape(pk)\n\tpk = strings.ToValidUTF8(pk, \"\")","sourceCodeStart":25,"sourceCodeEnd":61,"githubUrl":"https://github.com/router-for-me/CLIProxyAPI/blob/78f0c4079e3e6273d65d03b5549cffc898703264/internal/auth/vertex/keyutil.go#L25-L61","documentation":"Thrown by NormalizeServiceAccountMap when the service account JSON parses but its private_key field is missing, empty, or whitespace-only (keyutil.go:41-43). The type assertion sa[\"private_key\"].(string) failing (field absent or non-string) also yields an empty pk. Vertex JWT signing requires this RSA key, so normalization aborts.","triggerScenarios":"Uploading a service account JSON where private_key was redacted for security; copying the wrong JSON (e.g. a GCP 'client_secret' OAuth file or a key-info stub) as the Vertex credential; the field present but containing only newlines/whitespace.","commonSituations":"Teams commit sanitized service account templates with the key stripped, then deploy them accidentally; users confuse the OAuth client JSON with the service account key JSON; key truncated during copy-paste into an env var.","solutions":["Regenerate the key in GCP (IAM & Admin > Service Accounts > Keys > Add key > JSON) and use the fresh file","Verify the file actually contains a private_key field: jq -r '.private_key | length' service-account.json (should be > 1600)","Ensure you are using a service account key file, not an OAuth client credentials file","If passing via env/store, confirm the value was not truncated or placeholder-replaced"],"exampleFix":"# before\njq -r '.private_key' sa.json   # null or empty\n# after: re-download the key and check\njq -r '.private_key | startswith(\"-----BEGIN\")' sa.json   # true","handlingStrategy":"validation","validationCode":"if pk, ok := sa[\"private_key\"].(string); !ok || strings.TrimSpace(pk) == \"\" {\n    return fmt.Errorf(\"service account JSON lacks private_key; re-download the key from GCP\")\n}","typeGuard":"func hasPrivateKey(sa map[string]any) bool {\n    pk, ok := sa[\"private_key\"].(string)\n    return ok && strings.TrimSpace(pk) != \"\"\n}","tryCatchPattern":null,"preventionTips":["Never deploy redacted/sanitized service account templates","Automate key delivery from GCP/API rather than copy-paste","Add CI checks that the private_key field is non-empty in deployed secrets"],"tags":["vertex","gcp","service-account","private-key"],"backgroundTag":null,"analyzedSha":"78f0c4079e3e6273d65d03b5549cffc898703264","analyzedAt":"2026-08-15T12:26:37.444Z","schemaVersion":2},"datasetVersion":"2026-08-15T22:17:37.221Z"}