From 46602e54b82fa0e94deeb330546f5e9e31caaa9c Mon Sep 17 00:00:00 2001 From: Luisina Santos Date: Mon, 31 Aug 2026 12:02:42 -0300 Subject: [PATCH 1/7] fix(config): accept literal \n escapes in GitHub App private key PEM The app-privatekey field renders as a single-line input in the C1 config form, which cannot hold real newlines, so a pasted PEM always failed to parse and the connector could not be configured for GitHub App auth from the form. Unescape literal \n sequences before decoding, mirroring baton-okta's parseRSAPrivateKey. Fixes CXP-963. --- config_schema.json | 2 +- pkg/config/config.go | 2 +- pkg/connector/app_privatekey_test.go | 31 ++++++++++++++++++++++++++++ pkg/connector/connector.go | 6 ++++++ 4 files changed, 39 insertions(+), 2 deletions(-) diff --git a/config_schema.json b/config_schema.json index 035f781f..845ddbc7 100644 --- a/config_schema.json +++ b/config_schema.json @@ -148,7 +148,7 @@ { "name": "app-privatekey", "displayName": "GitHub App private key (PEM)", - "description": "Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set.", + "description": "Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set. Since this field cannot hold newlines when entered through a form, literal \\n escape sequences are also accepted and unescaped before use.", "isSecret": true, "stringField": {} }, diff --git a/pkg/config/config.go b/pkg/config/config.go index 5c676fac..f62d6ff6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -57,7 +57,7 @@ var ( appPrivateKey = field.StringField( "app-privatekey", field.WithDisplayName("GitHub App private key (PEM)"), - field.WithDescription("Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set."), + field.WithDescription("Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set. Since this field cannot hold newlines when entered through a form, literal \\n escape sequences are also accepted and unescaped before use."), field.WithIsSecret(true), ) diff --git a/pkg/connector/app_privatekey_test.go b/pkg/connector/app_privatekey_test.go index 3e455fdc..7a6cd500 100644 --- a/pkg/connector/app_privatekey_test.go +++ b/pkg/connector/app_privatekey_test.go @@ -1,6 +1,11 @@ package connector import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "strings" "testing" cfg "github.com/conductorone/baton-github/pkg/config" @@ -39,3 +44,29 @@ func TestAppPrivateKeyPEM(t *testing.T) { require.Error(t, err) }) } + +func TestLoadPrivateKeyFromString(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + der := x509.MarshalPKCS1PrivateKey(key) + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}) + realNewlines := string(pemBytes) + + t.Run("parses a PEM with real newlines", func(t *testing.T) { + got, err := loadPrivateKeyFromString(realNewlines) + require.NoError(t, err) + require.Equal(t, key.D, got.D) + }) + + t.Run("parses a PEM with literal backslash-n escapes, as a single-line config form field would submit", func(t *testing.T) { + escaped := strings.ReplaceAll(realNewlines, "\n", `\n`) + got, err := loadPrivateKeyFromString(escaped) + require.NoError(t, err) + require.Equal(t, key.D, got.D) + }) + + t.Run("errors on garbage input", func(t *testing.T) { + _, err := loadPrivateKeyFromString("not a pem") + require.Error(t, err) + }) +} diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index b38ed4e8..2785d076 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -485,6 +485,12 @@ func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.T } func loadPrivateKeyFromString(p string) (*rsa.PrivateKey, error) { + // The C1 config form renders this field as a single-line input, so an + // operator pasting a PEM there can only supply literal "\n" escapes + // instead of real newlines. Unescape them before decoding so both the + // form-entered and CLI/env-entered shapes parse. Mirrors baton-okta's + // parseRSAPrivateKey (pkg/oktaauth/oktaauth.go). + p = strings.ReplaceAll(p, `\n`, "\n") block, _ := pem.Decode([]byte(p)) if block == nil || (block.Type != "PRIVATE KEY" && block.Type != "RSA PRIVATE KEY") { return nil, errors.New("invalid private key PEM format") From 461000ba4b4d0a7a8d7c26ffc7e652f07042d2e7 Mon Sep 17 00:00:00 2001 From: Luisina Santos Date: Mon, 31 Aug 2026 12:07:10 -0300 Subject: [PATCH 2/7] refactor: drop explanatory comment from loadPrivateKeyFromString --- pkg/connector/connector.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 2785d076..336980f1 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -485,11 +485,6 @@ func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.T } func loadPrivateKeyFromString(p string) (*rsa.PrivateKey, error) { - // The C1 config form renders this field as a single-line input, so an - // operator pasting a PEM there can only supply literal "\n" escapes - // instead of real newlines. Unescape them before decoding so both the - // form-entered and CLI/env-entered shapes parse. Mirrors baton-okta's - // parseRSAPrivateKey (pkg/oktaauth/oktaauth.go). p = strings.ReplaceAll(p, `\n`, "\n") block, _ := pem.Decode([]byte(p)) if block == nil || (block.Type != "PRIVATE KEY" && block.Type != "RSA PRIVATE KEY") { From b9836f3c2b2c604f3f401f18151fd63e8d6c2c91 Mon Sep 17 00:00:00 2001 From: Luisina Santos Date: Mon, 31 Aug 2026 12:09:33 -0300 Subject: [PATCH 3/7] docs: document escaped-newline PEM entry for the GitHub App config form The C1 config form's GitHub App private key (PEM) field only accepts a single line, so operators need to know to substitute literal \n for line breaks. README.md's flag help text and the Cloud-hosted setup steps in docs/connector.mdx now cover this alongside the existing file-upload option. --- README.md | 2 +- docs/connector.mdx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 49a03e39..c1533354 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Available Commands: Flags: --app-id string The GitHub App to connect to. ($BATON_APP_ID) - --app-privatekey string Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set. ($BATON_APP_PRIVATEKEY) + --app-privatekey string Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set. Since this field cannot hold newlines when entered through a form, literal \n escape sequences are also accepted and unescaped before use. ($BATON_APP_PRIVATEKEY) --app-privatekey-path string Path to private key that is used to connect to the GitHub App. Ignored when app-privatekey is set. ($BATON_APP_PRIVATEKEY_PATH) --client-id string The client ID used to authenticate with ConductorOne ($BATON_CLIENT_ID) --client-secret string The client secret used to authenticate with ConductorOne ($BATON_CLIENT_SECRET) diff --git a/docs/connector.mdx b/docs/connector.mdx index 12e96d0a..b44aacb2 100644 --- a/docs/connector.mdx +++ b/docs/connector.mdx @@ -301,7 +301,9 @@ If you're using a GitHub app to set up the connector: 1. Enter your app ID into the **GitHub app ID** field. - 1. Click **Choose file** and upload your private key file. + 1. Provide the GitHub App's private key one of two ways: + - Click **Choose file** and upload your private key (`.pem`) file, or + - Paste the key's raw PEM contents into the **GitHub App private key (PEM)** field. Because this field only accepts a single line of text, replace every line break in the PEM with a literal `\n` (backslash, then the letter `n`) before pasting it in — the connector unescapes these back into real line breaks. If both fields are filled in, the pasted **GitHub App private key (PEM)** value takes precedence. 1. In the **Organization** field, enter the name of the GitHub organization associated with the GitHub app. **You must enter a single organization name in this field or the connector configuration will fail.** From b902b3c9134cb0b6c6130603afb4b7c661bb9807 Mon Sep 17 00:00:00 2001 From: Luisina Santos Date: Mon, 31 Aug 2026 12:24:36 -0300 Subject: [PATCH 4/7] fix: shorten app-privatekey field description under lint's 200-char line limit golangci-lint's revive line-length-limit flagged the description added in the previous commit. Reworded and wrapped it across two source lines; regenerated config_schema.json and README.md to match. --- README.md | 2 +- config_schema.json | 2 +- pkg/config/config.go | 5 ++++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c1533354..e1ae9d78 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ Available Commands: Flags: --app-id string The GitHub App to connect to. ($BATON_APP_ID) - --app-privatekey string Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set. Since this field cannot hold newlines when entered through a form, literal \n escape sequences are also accepted and unescaped before use. ($BATON_APP_PRIVATEKEY) + --app-privatekey string Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set. Literal \n escape sequences are also accepted and unescaped before use, since this field can't hold newlines when entered through a form. ($BATON_APP_PRIVATEKEY) --app-privatekey-path string Path to private key that is used to connect to the GitHub App. Ignored when app-privatekey is set. ($BATON_APP_PRIVATEKEY_PATH) --client-id string The client ID used to authenticate with ConductorOne ($BATON_CLIENT_ID) --client-secret string The client secret used to authenticate with ConductorOne ($BATON_CLIENT_SECRET) diff --git a/config_schema.json b/config_schema.json index 845ddbc7..6b0dce9b 100644 --- a/config_schema.json +++ b/config_schema.json @@ -148,7 +148,7 @@ { "name": "app-privatekey", "displayName": "GitHub App private key (PEM)", - "description": "Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set. Since this field cannot hold newlines when entered through a form, literal \\n escape sequences are also accepted and unescaped before use.", + "description": "Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set. Literal \\n escape sequences are also accepted and unescaped before use, since this field can't hold newlines when entered through a form.", "isSecret": true, "stringField": {} }, diff --git a/pkg/config/config.go b/pkg/config/config.go index f62d6ff6..edee4c86 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -57,7 +57,10 @@ var ( appPrivateKey = field.StringField( "app-privatekey", field.WithDisplayName("GitHub App private key (PEM)"), - field.WithDescription("Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set. Since this field cannot hold newlines when entered through a form, literal \\n escape sequences are also accepted and unescaped before use."), + field.WithDescription( + "Raw PEM contents of the private key used to connect to the GitHub App. Takes precedence over app-privatekey-path when both are set. "+ + `Literal \n escape sequences are also accepted and unescaped before use, since this field can't hold newlines when entered through a form.`, + ), field.WithIsSecret(true), ) From 17fb4374cbc328a03386505f1fc88eac8ef78c6c Mon Sep 17 00:00:00 2001 From: Luisina Santos Date: Mon, 31 Aug 2026 14:45:31 -0300 Subject: [PATCH 5/7] fix: also unescape \r\n and stray \r in the GitHub App private key PEM A PEM escaped from a CRLF-origin (Windows) file into a single line produces \r\n rather than \n between lines. loadPrivateKeyFromString only unescaped \n, so the leftover \r broke pem.Decode. Use a strings.Replacer that maps \r\n and \n to a real newline and drops any remaining \r. Addresses a bot review suggestion on PR #185. --- pkg/connector/app_privatekey_test.go | 7 +++++++ pkg/connector/connector.go | 7 ++++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/connector/app_privatekey_test.go b/pkg/connector/app_privatekey_test.go index 7a6cd500..b4a9fc3c 100644 --- a/pkg/connector/app_privatekey_test.go +++ b/pkg/connector/app_privatekey_test.go @@ -65,6 +65,13 @@ func TestLoadPrivateKeyFromString(t *testing.T) { require.Equal(t, key.D, got.D) }) + t.Run("parses a PEM with literal backslash-r-backslash-n escapes, as a CRLF-origin file would submit", func(t *testing.T) { + escaped := strings.ReplaceAll(realNewlines, "\n", `\r\n`) + got, err := loadPrivateKeyFromString(escaped) + require.NoError(t, err) + require.Equal(t, key.D, got.D) + }) + t.Run("errors on garbage input", func(t *testing.T) { _, err := loadPrivateKeyFromString("not a pem") require.Error(t, err) diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 336980f1..91d50d7d 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -484,8 +484,13 @@ func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.T return githubv4.NewClient(tc), nil } +// escapedLineBreaks unescapes both LF- and CRLF-escaped line breaks: `\r\n` +// and `\n` become a real newline, and any leftover `\r` (from a lone +// escaped CR) is dropped. +var escapedLineBreaks = strings.NewReplacer(`\r\n`, "\n", `\n`, "\n", `\r`, "") + func loadPrivateKeyFromString(p string) (*rsa.PrivateKey, error) { - p = strings.ReplaceAll(p, `\n`, "\n") + p = escapedLineBreaks.Replace(p) block, _ := pem.Decode([]byte(p)) if block == nil || (block.Type != "PRIVATE KEY" && block.Type != "RSA PRIVATE KEY") { return nil, errors.New("invalid private key PEM format") From 62eb2f42a2a955240a71edbc2f349a1dff776170 Mon Sep 17 00:00:00 2001 From: Luisina Santos Date: Mon, 31 Aug 2026 14:49:56 -0300 Subject: [PATCH 6/7] fix: tolerate more single-line PEM shapes from the config form Beyond \n and \r\n escapes, a browser can flatten a pasted multi-line PEM by stripping newlines entirely or replacing them with spaces, and a leading space before the BEGIN armor breaks pem.Decode (it only recognizes the armor at offset 0 or right after a newline). Normalize all escaped line-break forms to a real newline, trim surrounding whitespace, and force the BEGIN/END armor onto its own line before decoding. Table-driven test now covers all nine input shapes. Addresses carolinaroncaglia's review comment on PR #185. --- pkg/connector/app_privatekey_test.go | 36 ++++++++++++++-------------- pkg/connector/connector.go | 21 ++++++++++++---- 2 files changed, 35 insertions(+), 22 deletions(-) diff --git a/pkg/connector/app_privatekey_test.go b/pkg/connector/app_privatekey_test.go index b4a9fc3c..354233d0 100644 --- a/pkg/connector/app_privatekey_test.go +++ b/pkg/connector/app_privatekey_test.go @@ -52,25 +52,25 @@ func TestLoadPrivateKeyFromString(t *testing.T) { pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: der}) realNewlines := string(pemBytes) - t.Run("parses a PEM with real newlines", func(t *testing.T) { - got, err := loadPrivateKeyFromString(realNewlines) - require.NoError(t, err) - require.Equal(t, key.D, got.D) - }) - - t.Run("parses a PEM with literal backslash-n escapes, as a single-line config form field would submit", func(t *testing.T) { - escaped := strings.ReplaceAll(realNewlines, "\n", `\n`) - got, err := loadPrivateKeyFromString(escaped) - require.NoError(t, err) - require.Equal(t, key.D, got.D) - }) + shapes := map[string]string{ + "real newlines": realNewlines, + "real CRLF": strings.ReplaceAll(realNewlines, "\n", "\r\n"), + "backslash-n escaped, as the docs instruct": strings.ReplaceAll(realNewlines, "\n", `\n`), + "backslash-n escaped, trailing space": strings.ReplaceAll(realNewlines, "\n", `\n`) + " ", + "backslash-n escaped, leading space": " " + strings.ReplaceAll(realNewlines, "\n", `\n`), + "backslash-r-backslash-n escaped (CRLF file)": strings.ReplaceAll(realNewlines, "\n", `\r\n`), + "backslash-r escaped (CR-only file)": strings.ReplaceAll(realNewlines, "\n", `\r`), + "newlines stripped, all one line": strings.ReplaceAll(realNewlines, "\n", ""), + "newlines replaced by spaces": strings.ReplaceAll(realNewlines, "\n", " "), + } - t.Run("parses a PEM with literal backslash-r-backslash-n escapes, as a CRLF-origin file would submit", func(t *testing.T) { - escaped := strings.ReplaceAll(realNewlines, "\n", `\r\n`) - got, err := loadPrivateKeyFromString(escaped) - require.NoError(t, err) - require.Equal(t, key.D, got.D) - }) + for name, shape := range shapes { + t.Run("parses a PEM with "+name, func(t *testing.T) { + got, err := loadPrivateKeyFromString(shape) + require.NoError(t, err) + require.Equal(t, key.D, got.D) + }) + } t.Run("errors on garbage input", func(t *testing.T) { _, err := loadPrivateKeyFromString("not a pem") diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 91d50d7d..2f24d4ae 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "net/url" + "regexp" "strings" "time" @@ -484,13 +485,25 @@ func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.T return githubv4.NewClient(tc), nil } -// escapedLineBreaks unescapes both LF- and CRLF-escaped line breaks: `\r\n` -// and `\n` become a real newline, and any leftover `\r` (from a lone -// escaped CR) is dropped. -var escapedLineBreaks = strings.NewReplacer(`\r\n`, "\n", `\n`, "\n", `\r`, "") +// escapedLineBreaks unescapes LF-, CRLF-, and CR-escaped line breaks (`\r\n`, +// `\n`, `\r`) to a real newline. +var escapedLineBreaks = strings.NewReplacer(`\r\n`, "\n", `\n`, "\n", `\r`, "\n") + +// pemArmorHead and pemArmorTail put the PEM BEGIN/END armor on its own line +// regardless of whether the C1 config form's single-line field submitted the +// PEM with its line breaks escaped, flattened to spaces, or stripped +// entirely. +var ( + pemArmorHead = regexp.MustCompile(`(-----BEGIN [A-Z0-9 ]+-----)[ \t]*`) + pemArmorTail = regexp.MustCompile(`[ \t]*(-----END [A-Z0-9 ]+-----)`) +) func loadPrivateKeyFromString(p string) (*rsa.PrivateKey, error) { p = escapedLineBreaks.Replace(p) + p = strings.TrimSpace(p) + p = pemArmorHead.ReplaceAllString(p, "$1\n") + p = pemArmorTail.ReplaceAllString(p, "\n$1") + block, _ := pem.Decode([]byte(p)) if block == nil || (block.Type != "PRIVATE KEY" && block.Type != "RSA PRIVATE KEY") { return nil, errors.New("invalid private key PEM format") From 2deac9150ff1100332fbeeca3d044da6b8aafa3c Mon Sep 17 00:00:00 2001 From: Luisina Santos Date: Mon, 31 Aug 2026 15:01:11 -0300 Subject: [PATCH 7/7] revert: drop PEM armor normalization, keep only line-break unescaping The armor-repositioning regexes (TrimSpace + BEGIN/END on their own line) went beyond the ticket's scope: they recover from a user not following the documented \n-escaping instructions at all (raw paste mangled by the browser), rather than from a different line-break convention. That's speculative browser behavior, not verified against the actual C1 form, and adds meaningful parsing surface for an unconfirmed edge case. Keep the proportionate fix: unescape \r\n, \n, and \r to a real newline, matching the documented "replace line breaks with \n" contract across LF- and CRLF-origin keys. --- pkg/connector/app_privatekey_test.go | 3 --- pkg/connector/connector.go | 14 -------------- 2 files changed, 17 deletions(-) diff --git a/pkg/connector/app_privatekey_test.go b/pkg/connector/app_privatekey_test.go index 354233d0..5d791aba 100644 --- a/pkg/connector/app_privatekey_test.go +++ b/pkg/connector/app_privatekey_test.go @@ -57,11 +57,8 @@ func TestLoadPrivateKeyFromString(t *testing.T) { "real CRLF": strings.ReplaceAll(realNewlines, "\n", "\r\n"), "backslash-n escaped, as the docs instruct": strings.ReplaceAll(realNewlines, "\n", `\n`), "backslash-n escaped, trailing space": strings.ReplaceAll(realNewlines, "\n", `\n`) + " ", - "backslash-n escaped, leading space": " " + strings.ReplaceAll(realNewlines, "\n", `\n`), "backslash-r-backslash-n escaped (CRLF file)": strings.ReplaceAll(realNewlines, "\n", `\r\n`), "backslash-r escaped (CR-only file)": strings.ReplaceAll(realNewlines, "\n", `\r`), - "newlines stripped, all one line": strings.ReplaceAll(realNewlines, "\n", ""), - "newlines replaced by spaces": strings.ReplaceAll(realNewlines, "\n", " "), } for name, shape := range shapes { diff --git a/pkg/connector/connector.go b/pkg/connector/connector.go index 2f24d4ae..76577f5d 100644 --- a/pkg/connector/connector.go +++ b/pkg/connector/connector.go @@ -10,7 +10,6 @@ import ( "io" "net/http" "net/url" - "regexp" "strings" "time" @@ -489,21 +488,8 @@ func newGitHubGraphqlClient(ctx context.Context, instanceURL string, ts oauth2.T // `\n`, `\r`) to a real newline. var escapedLineBreaks = strings.NewReplacer(`\r\n`, "\n", `\n`, "\n", `\r`, "\n") -// pemArmorHead and pemArmorTail put the PEM BEGIN/END armor on its own line -// regardless of whether the C1 config form's single-line field submitted the -// PEM with its line breaks escaped, flattened to spaces, or stripped -// entirely. -var ( - pemArmorHead = regexp.MustCompile(`(-----BEGIN [A-Z0-9 ]+-----)[ \t]*`) - pemArmorTail = regexp.MustCompile(`[ \t]*(-----END [A-Z0-9 ]+-----)`) -) - func loadPrivateKeyFromString(p string) (*rsa.PrivateKey, error) { p = escapedLineBreaks.Replace(p) - p = strings.TrimSpace(p) - p = pemArmorHead.ReplaceAllString(p, "$1\n") - p = pemArmorTail.ReplaceAllString(p, "\n$1") - block, _ := pem.Decode([]byte(p)) if block == nil || (block.Type != "PRIVATE KEY" && block.Type != "RSA PRIVATE KEY") { return nil, errors.New("invalid private key PEM format")