diff --git a/.env.example b/.env.example index 086faab..224cbb8 100644 --- a/.env.example +++ b/.env.example @@ -13,6 +13,7 @@ BAUER_CREDENTIALS_PATH=/path/to/service-account.json # GITHUB_APP_ID=12345 # GITHUB_APP_PRIVATE_KEY_PATH=/path/to/private-key.pem # GITHUB_APP_INSTALLATION_ID=67890 +# GITHUB_APP_PRIVATE_KEY= # RSA PEM key content (or set GITHUB_APP_PRIVATE_KEY_PATH) # --- OIDC (optional — for API deployments protected by your IdP) --- # BAUER_OIDC_ISSUER=https://auth.example.com diff --git a/cmd/app/main.go b/cmd/app/main.go index 832fd25..e5026ee 100644 --- a/cmd/app/main.go +++ b/cmd/app/main.go @@ -5,6 +5,7 @@ import ( "bauer/cmd/app/types" v1 "bauer/cmd/app/v1" "bauer/internal/artifacts" + "bauer/internal/auth" "bauer/internal/copilotcli" "bauer/internal/orchestrator" "bauer/internal/source" @@ -53,12 +54,19 @@ func run() error { } mux := http.NewServeMux() - mux.HandleFunc("/api/v1/job", v1.JobPost(rc)) + + // Public routes — no auth (K8s probes cannot send tokens) mux.HandleFunc("/api/v1/health", v1.GetHealth) mux.HandleFunc("GET /api/v1/health/ready", v1.ReadinessHandler) - mux.HandleFunc("POST /api/v1/workflows", workflow.ExecuteWorkflowHandler(orch)) - mux.HandleFunc("POST /api/v1/issues", v1.IssuesHandler(cfg)) - mux.HandleFunc("POST /api/v1/webhooks/jira", v1.JiraWebhookHandler(cfg)) + + // Protected routes wrapped in optional JWT middleware + protected := http.NewServeMux() + protected.HandleFunc("POST /api/v1/job", v1.JobPost(rc)) + protected.HandleFunc("POST /api/v1/workflows", workflow.ExecuteWorkflowHandler(orch)) + protected.HandleFunc("POST /api/v1/issues", v1.IssuesHandler(cfg)) + protected.HandleFunc("POST /api/v1/webhooks/jira", v1.JiraWebhookHandler(cfg)) + + mux.Handle("/api/v1/", auth.JWTMiddleware(protected)) slog.Info("starting server", "address", ":8090") err = http.ListenAndServe(":8090", middleware.RequestTrace(mux)) diff --git a/docs/implementation-log.md b/docs/implementation-log.md index 1df1631..3234808 100644 --- a/docs/implementation-log.md +++ b/docs/implementation-log.md @@ -309,9 +309,16 @@ _Parent: `feat/phase-4-api-endpoints`_ **Tasks:** T5.1, T5.2, T5.3 -**Summary:** _(to be filled by agent)_ +**Summary:** T5.1 added GitHub App authentication as the first fallback in `GetGitHubToken()`: when `GITHUB_APP_ID` is set, a short-lived RS256 JWT is signed with the App's RSA private key and exchanged for an installation access token via the GitHub REST API; PAT env vars and `gh auth token` remain as lower-priority fallbacks. T5.2 introduced `internal/auth/middleware.go` with `JWTMiddleware`, which validates Bearer tokens against a JWKS fetched from the OIDC issuer's discovery document; the middleware is bypassed silently (logged at Info) when `BAUER_OIDC_ISSUER` is unset, making it safe for local development. In `cmd/app/main.go`, the health and readiness endpoints remain on the public mux while `POST /api/v1/workflows`, `POST /api/v1/issues`, and `POST /api/v1/webhooks/jira` are now wrapped with `auth.JWTMiddleware` on a separate protected sub-mux. T5.3 added `internal/logging/masking.go` with `MaskSecret` and `MaskPath` helpers plus full unit-test coverage; an audit of existing `slog` calls found no direct logging of raw token values or credential paths in the current codebase. Added `github.com/golang-jwt/jwt/v5` and `github.com/lestrrat-go/jwx/v2` as new direct dependencies. -**Files changed:** _(to be filled by agent)_ +**Files changed:** +- `internal/github/auth.go` — rewrote `GetGitHubToken` to try GitHub App first; added `generateAppInstallationToken` with PEM loading, RSA key parsing, JWT signing (RS256), and installation token exchange via HTTP POST; updated imports to add `crypto/x509`, `encoding/pem`, `encoding/json`, `net/http`, `strconv`, `time`, and `github.com/golang-jwt/jwt/v5` +- `internal/auth/middleware.go` — new: `JWTMiddleware` wrapping protected routes with OIDC-based Bearer token validation; `resolveJWKSURL` fetches the OIDC discovery document; `extractBearerToken` parses the Authorization header; bypass mode when `BAUER_OIDC_ISSUER` is unset +- `internal/logging/masking.go` — new: `MaskSecret` (empty → ``, ≤4 chars → `****`, longer → first 4 + `...`) and `MaskPath` (shows only `filename` prefixed with `.../`) helpers +- `internal/logging/masking_test.go` — new: table-driven tests for `MaskSecret` (5 cases) and `MaskPath` (4 cases) +- `cmd/app/main.go` — imported `bauer/internal/auth`; split route registration into public mux (health endpoints) and protected sub-mux wrapped with `auth.JWTMiddleware` +- `.env.example` — added `GITHUB_APP_PRIVATE_KEY` entry alongside the existing `GITHUB_APP_PRIVATE_KEY_PATH` +- `go.mod` / `go.sum` — added `github.com/golang-jwt/jwt/v5 v5.3.1` and `github.com/lestrrat-go/jwx/v2 v2.1.6` (plus transitive dependencies) --- diff --git a/go.mod b/go.mod index e7b61c1..85c4988 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,11 @@ go 1.24.0 require ( github.com/github/copilot-sdk/go v0.1.15 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/google/go-cmp v0.7.0 + github.com/google/uuid v1.6.0 + github.com/joho/godotenv v1.5.1 + github.com/lestrrat-go/jwx/v2 v2.1.6 golang.org/x/oauth2 v0.33.0 google.golang.org/api v0.257.0 ) @@ -13,15 +17,21 @@ require ( cloud.google.com/go/auth v0.17.0 // indirect cloud.google.com/go/auth/oauth2adapt v0.2.8 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/goccy/go-json v0.10.3 // indirect github.com/google/jsonschema-go v0.4.2 // indirect github.com/google/s2a-go v0.1.9 // indirect - github.com/google/uuid v1.6.0 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.7 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect - github.com/joho/godotenv v1.5.1 // indirect + github.com/lestrrat-go/blackmagic v1.0.3 // indirect + github.com/lestrrat-go/httpcc v1.0.1 // indirect + github.com/lestrrat-go/httprc v1.0.6 // indirect + github.com/lestrrat-go/iter v1.0.2 // indirect + github.com/lestrrat-go/option v1.0.1 // indirect + github.com/segmentio/asm v1.2.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/go.sum b/go.sum index a16412c..9c93daf 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,11 @@ cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIi cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/github/copilot-sdk/go v0.1.15 h1:JmF0DbF1n007FyTfjagfCm4epAW4NIOlCFYP/VXtgXM= @@ -15,6 +18,10 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA= +github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -31,8 +38,25 @@ github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81 github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= +github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc v1.0.6 h1:qgmgIRhpvBqexMJjA/PmwSvhNk679oqD1RbovdCGW8k= +github.com/lestrrat-go/httprc v1.0.6/go.mod h1:mwwz3JMTPBjHUkkDv/IGJ39aALInZLrhBp0X7KGUZlo= +github.com/lestrrat-go/iter v1.0.2 h1:gMXo1q4c2pHmC3dn8LzRhJfP1ceCbgSiT9lUydIzltI= +github.com/lestrrat-go/iter v1.0.2/go.mod h1:Momfcq3AnRlRjI5b5O8/G5/BvpzrhoFTZcn06fEOPt4= +github.com/lestrrat-go/jwx/v2 v2.1.6 h1:hxM1gfDILk/l5ylers6BX/Eq1m/pnxe9NBwW6lVfecA= +github.com/lestrrat-go/jwx/v2 v2.1.6/go.mod h1:Y722kU5r/8mV7fYDifjug0r8FK8mZdw0K0GpJw/l8pU= +github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= +github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -75,5 +99,7 @@ google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM= google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go new file mode 100644 index 0000000..ba6ec12 --- /dev/null +++ b/internal/auth/middleware.go @@ -0,0 +1,116 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "strings" + "time" + + "github.com/lestrrat-go/jwx/v2/jwk" + "github.com/lestrrat-go/jwx/v2/jwt" +) + +func unauthorizedHandler(msg string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) + }) +} + +// JWTMiddleware returns middleware that validates Bearer tokens using JWKS from the OIDC issuer. +// If BAUER_OIDC_ISSUER is not configured, the handler is returned unchanged (bypass mode). +func JWTMiddleware(next http.Handler) http.Handler { + issuer := os.Getenv("BAUER_OIDC_ISSUER") + if issuer == "" { + slog.Info("BAUER_OIDC_ISSUER not set; JWT validation bypassed") + return next + } + + audience := os.Getenv("BAUER_OIDC_AUDIENCE") + jwksURL, err := resolveJWKSURL(issuer) + if err != nil { + slog.Error("Failed to resolve JWKS URL from OIDC discovery", + slog.String("issuer", issuer), + slog.String("error", err.Error()), + ) + return unauthorizedHandler("authentication service unavailable") + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + keySet, err := jwk.Fetch(ctx, jwksURL) + if err != nil { + slog.Error("Failed to fetch JWKS", + slog.String("jwks_url", jwksURL), + slog.String("error", err.Error()), + ) + return unauthorizedHandler("authentication service unavailable") + } + + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rawToken := extractBearerToken(r) + if rawToken == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "missing Authorization header"}) + return + } + + parseOpts := []jwt.ParseOption{ + jwt.WithKeySet(keySet), + jwt.WithIssuer(issuer), + jwt.WithValidate(true), + } + if audience != "" { + parseOpts = append(parseOpts, jwt.WithAudience(audience)) + } + + if _, err := jwt.ParseString(rawToken, parseOpts...); err != nil { + slog.Warn("JWT validation failed", slog.String("error", err.Error())) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid or expired token"}) + return + } + + next.ServeHTTP(w, r) + }) +} + +func extractBearerToken(r *http.Request) string { + authHeader := r.Header.Get("Authorization") + if len(authHeader) < 7 || !strings.EqualFold(authHeader[:7], "bearer ") { + return "" + } + return authHeader[7:] +} + +func resolveJWKSURL(issuer string) (string, error) { + discoveryURL := strings.TrimRight(issuer, "/") + "/.well-known/openid-configuration" + resp, err := http.Get(discoveryURL) //nolint:noctx + if err != nil { + return "", fmt.Errorf("fetching OIDC discovery document: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("OIDC discovery endpoint returned status %d", resp.StatusCode) + } + + var doc struct { + JWKSURI string `json:"jwks_uri"` + } + if err := json.NewDecoder(resp.Body).Decode(&doc); err != nil { + return "", fmt.Errorf("parsing OIDC discovery document: %w", err) + } + if doc.JWKSURI == "" { + return "", fmt.Errorf("jwks_uri not found in OIDC discovery document") + } + return doc.JWKSURI, nil +} diff --git a/internal/github/auth.go b/internal/github/auth.go index 57258bc..882281e 100644 --- a/internal/github/auth.go +++ b/internal/github/auth.go @@ -1,21 +1,44 @@ package github import ( + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" "fmt" + "net/http" "os" "os/exec" + "strconv" "strings" + "time" + + "github.com/golang-jwt/jwt/v5" ) -// GetGitHubToken retrieves a GitHub token from environment variables or gh CLI +// GetGitHubToken retrieves a GitHub token from environment variables or gh CLI. +// Resolution order: +// 1. GitHub App (if GITHUB_APP_ID is set) +// 2. PAT env vars (BAUER_GITHUB_TOKEN, GITHUB_TOKEN, GH_TOKEN) +// 3. gh auth token CLI func GetGitHubToken() (string, error) { + // 1. Try GitHub App installation token + if os.Getenv("GITHUB_APP_ID") != "" { + token, err := generateAppInstallationToken() + if err != nil { + return "", fmt.Errorf("GitHub App auth failed: %w", err) + } + return token, nil + } + + // 2. PAT env vars for _, env := range []string{"BAUER_GITHUB_TOKEN", "GITHUB_TOKEN", "GH_TOKEN"} { if v := os.Getenv(env); v != "" { return v, nil } } - // Get token from gh CLI config + // 3. Get token from gh CLI config cmd := exec.Command("gh", "auth", "token") output, err := cmd.CombinedOutput() if err != nil { @@ -30,15 +53,116 @@ func GetGitHubToken() (string, error) { return token, nil } -// ValidateGitHubAuth checks if GitHub authentication is configured +// generateAppInstallationToken generates a GitHub App installation access token +// using a signed JWT exchanged for an installation token via the GitHub REST API. +func generateAppInstallationToken() (string, error) { + appIDStr := os.Getenv("GITHUB_APP_ID") + appID, err := strconv.ParseInt(appIDStr, 10, 64) + if err != nil { + return "", fmt.Errorf("invalid GITHUB_APP_ID: %w", err) + } + + installIDStr := os.Getenv("GITHUB_APP_INSTALLATION_ID") + installID, err := strconv.ParseInt(installIDStr, 10, 64) + if err != nil { + return "", fmt.Errorf("invalid GITHUB_APP_INSTALLATION_ID: %w", err) + } + + // Load private key from env or file + var pemData []byte + if keyPath := os.Getenv("GITHUB_APP_PRIVATE_KEY_PATH"); keyPath != "" { + pemData, err = os.ReadFile(keyPath) + if err != nil { + return "", fmt.Errorf("reading GITHUB_APP_PRIVATE_KEY_PATH: %w", err) + } + } else if keyContent := os.Getenv("GITHUB_APP_PRIVATE_KEY"); keyContent != "" { + // Replace literal \n with newlines (common in env var storage) + pemData = []byte(strings.ReplaceAll(keyContent, `\n`, "\n")) + } else { + return "", fmt.Errorf("set GITHUB_APP_PRIVATE_KEY or GITHUB_APP_PRIVATE_KEY_PATH") + } + + // Parse RSA private key (try PKCS#1 first, then PKCS#8) + block, _ := pem.Decode(pemData) + if block == nil { + return "", fmt.Errorf("failed to decode PEM block from GitHub App private key") + } + privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + parsedKey, pkcs8Err := x509.ParsePKCS8PrivateKey(block.Bytes) + if pkcs8Err != nil { + return "", fmt.Errorf("parsing GitHub App private key (tried PKCS#1 and PKCS#8): %w", err) + } + rsaKey, ok := parsedKey.(*rsa.PrivateKey) + if !ok { + return "", fmt.Errorf("PKCS#8 key is not an RSA private key") + } + privateKey = rsaKey + } + + // Create JWT (signed with RS256, valid 10 min) + now := time.Now() + claims := jwt.RegisteredClaims{ + IssuedAt: jwt.NewNumericDate(now.Add(-60 * time.Second)), // 60s in the past to handle clock skew + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + Issuer: strconv.FormatInt(appID, 10), + } + jwtToken := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + jwtStr, err := jwtToken.SignedString(privateKey) + if err != nil { + return "", fmt.Errorf("signing GitHub App JWT: %w", err) + } + + // Exchange JWT for installation access token + url := fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens", installID) + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return "", fmt.Errorf("creating installation token request: %w", err) + } + req.Header.Set("Authorization", "Bearer "+jwtStr) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("X-GitHub-Api-Version", "2022-11-28") + + httpClient := &http.Client{Timeout: 30 * time.Second} + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("exchanging JWT for installation token: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated { + return "", fmt.Errorf("installation token exchange failed (status %d)", resp.StatusCode) + } + + var result struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", fmt.Errorf("parsing installation token response: %w", err) + } + if result.Token == "" { + return "", fmt.Errorf("empty installation token in response") + } + return result.Token, nil +} + +// ValidateGitHubAuth checks if GitHub authentication is configured. +// If a token is available via App auth or env vars, gh CLI is not required. func ValidateGitHubAuth() error { - // Get token _, err := GetGitHubToken() if err != nil { return fmt.Errorf("GitHub authentication not configured: %w", err) } - // Authenticate token + // If we have a token from App auth or env vars, we don't need gh CLI + if os.Getenv("GITHUB_APP_ID") != "" || + os.Getenv("BAUER_GITHUB_TOKEN") != "" || + os.Getenv("GITHUB_TOKEN") != "" || + os.Getenv("GH_TOKEN") != "" { + return nil + } + + // Token came from gh CLI — verify it's still valid cmd := exec.Command("gh", "auth", "status") output, err := cmd.CombinedOutput() if err != nil { diff --git a/internal/logging/masking.go b/internal/logging/masking.go new file mode 100644 index 0000000..9d7c07d --- /dev/null +++ b/internal/logging/masking.go @@ -0,0 +1,25 @@ +package logging + +import "path/filepath" + +// MaskSecret returns a masked version of a secret string. +// Empty string returns "". Short strings (≤4 chars) return "****". +// Longer strings return the first 4 chars + "..." (e.g. "ghp_..."). +func MaskSecret(s string) string { + if s == "" { + return "" + } + if len(s) <= 4 { + return "****" + } + return s[:4] + "..." +} + +// MaskPath returns a masked filesystem path showing only the filename. +// Avoids leaking directory structure in logs. +func MaskPath(path string) string { + if path == "" { + return "" + } + return ".../" + filepath.Base(path) +} diff --git a/internal/logging/masking_test.go b/internal/logging/masking_test.go new file mode 100644 index 0000000..d0e17c5 --- /dev/null +++ b/internal/logging/masking_test.go @@ -0,0 +1,40 @@ +package logging + +import "testing" + +func TestMaskSecret(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", ""}, + {"abc", "****"}, + {"abcd", "****"}, + {"abcde", "abcd..."}, + {"ghp_abc123xyz", "ghp_..."}, + } + for _, tc := range tests { + got := MaskSecret(tc.input) + if got != tc.want { + t.Errorf("MaskSecret(%q) = %q, want %q", tc.input, got, tc.want) + } + } +} + +func TestMaskPath(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"", ""}, + {"/home/user/secrets/creds.json", ".../creds.json"}, + {"creds.json", ".../creds.json"}, + {"/tmp/service-account.json", ".../service-account.json"}, + } + for _, tc := range tests { + got := MaskPath(tc.input) + if got != tc.want { + t.Errorf("MaskPath(%q) = %q, want %q", tc.input, got, tc.want) + } + } +}