Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 12 additions & 4 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Comment on lines +58 to 60
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))

Expand Down
11 changes: 9 additions & 2 deletions docs/implementation-log.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 → `<unset>`, ≤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)

---

Expand Down
14 changes: 12 additions & 2 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand All @@ -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
Comment on lines +24 to +33
github.com/segmentio/asm v1.2.0 // indirect
Comment on lines 19 to +34
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
Expand Down
26 changes: 26 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand All @@ -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=
Expand All @@ -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=
Expand Down Expand Up @@ -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=
116 changes: 116 additions & 0 deletions internal/auth/middleware.go
Original file line number Diff line number Diff line change
@@ -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")
}
Comment on lines +35 to +42

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")
}
Comment on lines +34 to +54
Comment on lines +34 to +54

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
}
Comment on lines +56 to +63

parseOpts := []jwt.ParseOption{
jwt.WithKeySet(keySet),
jwt.WithIssuer(issuer),
jwt.WithValidate(true),
}
if audience != "" {
parseOpts = append(parseOpts, jwt.WithAudience(audience))
}

Comment on lines +44 to +73
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)
})
}
Comment on lines +25 to +84

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — unit tests for the JWT middleware would be valuable. Tracking as a follow-up. The middleware is intentionally thin (delegates to lestrrat-go/jwx for the heavy lifting), but testing the fail-closed paths and bearer extraction logic is worth covering.


func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if len(authHeader) < 7 || !strings.EqualFold(authHeader[:7], "bearer ") {
return ""
}
return authHeader[7:]
}
Comment on lines +86 to +92

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)
}
Comment on lines +94 to +111
if doc.JWKSURI == "" {
return "", fmt.Errorf("jwks_uri not found in OIDC discovery document")
}
return doc.JWKSURI, nil
}
Comment on lines +94 to +116
Comment on lines +94 to +116
Loading