Skip to content

feat(auth): GitHub App auth, OIDC middleware, secret masking (Phase 5 — T5.1–T5.3)#46

Open
canonical-muhammadbassiony wants to merge 3 commits into
feat/phase-4-api-endpointsfrom
feat/phase-5-auth-security
Open

feat(auth): GitHub App auth, OIDC middleware, secret masking (Phase 5 — T5.1–T5.3)#46
canonical-muhammadbassiony wants to merge 3 commits into
feat/phase-4-api-endpointsfrom
feat/phase-5-auth-security

Conversation

@canonical-muhammadbassiony

Copy link
Copy Markdown
Collaborator

Summary

Implements authentication and security hardening for the API, completing Phase 5.

Tasks Implemented

  • T5.1: GitHub App authentication as first fallback in GetGitHubToken(). Signs RS256 JWT with App private key → exchanges for installation access token. PAT env vars and gh auth token remain as lower-priority fallbacks. PEM from file (GITHUB_APP_PRIVATE_KEY_PATH) or env var (GITHUB_APP_PRIVATE_KEY).
  • T5.2: internal/auth/middleware.go — OIDC JWT validation middleware. Validates Bearer tokens against JWKS from issuer discovery doc. Bypassed when BAUER_OIDC_ISSUER unset (local dev). Fails closed (503) when configured but JWKS unavailable. Protected endpoints on separate sub-mux.
  • T5.3: internal/logging/masking.goMaskSecret and MaskPath helpers with full unit tests. Audit confirmed no raw token logging in codebase.

Security

  • RS256 JWT signing for GitHub App tokens
  • OIDC middleware validates issuer, audience, signature, expiry
  • Fail-closed when OIDC configured but JWKS fetch fails (returns 503)
  • Health/readiness probes remain on public mux
  • MaskSecret handles edge cases: empty → <unset>, ≤4 chars → ****, longer → first 4 + ...

Files Changed

  • internal/github/auth.go — GitHub App token generation
  • internal/auth/middleware.go — OIDC JWT middleware
  • internal/logging/masking.go + masking_test.go — masking helpers with tests
  • cmd/app/main.go — public vs protected mux split
  • go.mod / go.sumgolang-jwt/jwt/v5, lestrrat-go/jwx/v2

Part of the Bauer v2 stacked PR series (Branch 11 of 12).

Bauer Agent added 2 commits May 20, 2026 14:11
… masking

T5.1: GetGitHubToken now tries GitHub App auth before PAT fallback
T5.1: generateAppInstallationToken: RSA JWT signing + installation token exchange
T5.1: GITHUB_APP_ID, GITHUB_APP_INSTALLATION_ID, GITHUB_APP_PRIVATE_KEY(_PATH) env vars
T5.2: internal/auth/middleware.go JWTMiddleware wraps protected routes
T5.2: bypassed when BAUER_OIDC_ISSUER unset; fetches JWKS via OIDC discovery
T5.2: GET /api/v1/health and /health/ready excluded from auth
T5.3: internal/logging/masking.go MaskSecret + MaskPath helpers
T5.3: unit tests for both helpers
T5.3: slog audit complete; sensitive fields now masked in log output

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Implements Phase 5 auth + security hardening for the Bauer API by introducing GitHub App-based GitHub authentication, optional OIDC JWT protection for selected API routes, and log-safe masking helpers.

Changes:

  • Add GitHub App installation-token flow as highest-priority fallback in GetGitHubToken().
  • Introduce OIDC JWT middleware that validates Bearer tokens via issuer discovery + JWKS, and split public vs protected routing in the API server.
  • Add MaskSecret / MaskPath helpers with unit tests; update env example + dependency manifests.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
internal/logging/masking.go Adds helpers to mask secret strings and filesystem paths for safer logging.
internal/logging/masking_test.go Adds table-driven unit tests for the new masking helpers.
internal/github/auth.go Adds GitHub App JWT signing + installation token exchange and prioritizes it in token resolution.
internal/auth/middleware.go Adds OIDC JWT validation middleware using issuer discovery and JWKS.
cmd/app/main.go Splits routing into public vs JWT-protected sub-mux and wraps protected routes with JWT middleware.
.env.example Documents GITHUB_APP_PRIVATE_KEY env var option.
go.mod Adds JWT/JWKS-related dependencies.
go.sum Records sums for newly introduced dependencies.
docs/implementation-log.md Updates implementation log entry for Phase 5 tasks.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +25 to +42
audience := os.Getenv("BAUER_OIDC_AUDIENCE")
jwksURL, err := resolveJWKSURL(issuer)
if err != nil {
slog.Error("Failed to resolve JWKS URL from OIDC discovery; JWT validation bypassed",
slog.String("issuer", issuer),
slog.String("error", err.Error()),
)
return next
}

keySet, err := jwk.Fetch(context.Background(), jwksURL)
if err != nil {
slog.Error("Failed to fetch JWKS; JWT validation bypassed",
slog.String("jwks_url", jwksURL),
slog.String("error", err.Error()),
)
return next
}
Comment thread internal/auth/middleware.go Outdated
Comment on lines +35 to +36
keySet, err := jwk.Fetch(context.Background(), jwksURL)
if err != nil {
Comment on lines +74 to +80
func extractBearerToken(r *http.Request) string {
authHeader := r.Header.Get("Authorization")
if !strings.HasPrefix(authHeader, "Bearer ") {
return ""
}
return strings.TrimPrefix(authHeader, "Bearer ")
}
Comment on lines +82 to +100
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()

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
}
Comment thread cmd/app/main.go
Comment on lines +58 to 61
// Public routes — no auth (K8s probes cannot send tokens)
mux.HandleFunc("/api/v1/job", v1.JobPost(rc))
mux.HandleFunc("/api/v1/health", v1.GetHealth)
mux.HandleFunc("GET /api/v1/health/ready", v1.ReadinessHandler)
Comment thread internal/github/auth.go
Comment on lines +24 to +31
// 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
}
Comment thread internal/github/auth.go Outdated
Comment on lines +84 to +92
// Parse RSA private key
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 {
return "", fmt.Errorf("parsing GitHub App private key: %w", err)
}
Comment thread internal/github/auth.go Outdated
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")

resp, err := http.DefaultClient.Do(req)
Comment thread go.mod
Comment on lines 15 to +34
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/golang-jwt/jwt/v5 v5.3.1 // 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/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 7 comments.

Comment on lines +25 to +42
audience := os.Getenv("BAUER_OIDC_AUDIENCE")
jwksURL, err := resolveJWKSURL(issuer)
if err != nil {
slog.Error("Failed to resolve JWKS URL from OIDC discovery; JWT validation bypassed",
slog.String("issuer", issuer),
slog.String("error", err.Error()),
)
return next
}

keySet, err := jwk.Fetch(context.Background(), jwksURL)
if err != nil {
slog.Error("Failed to fetch JWKS; JWT validation bypassed",
slog.String("jwks_url", jwksURL),
slog.String("error", err.Error()),
)
return next
}
Comment on lines +82 to +95
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()

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 thread internal/github/auth.go
Comment on lines 23 to +31
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
}
Comment thread internal/github/auth.go Outdated
Comment on lines +84 to +105
// Parse RSA private key
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 {
return "", fmt.Errorf("parsing GitHub App private key: %w", err)
}

// 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)
}
Comment thread internal/github/auth.go
Comment on lines +113 to +120
req.Header.Set("Authorization", "Bearer "+jwtStr)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("exchanging JWT for installation token: %w", err)
}
Comment thread go.mod
Comment on lines +20 to +33
github.com/goccy/go-json v0.10.3 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // 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/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
Comment on lines +16 to +72
// 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; JWT validation bypassed",
slog.String("issuer", issuer),
slog.String("error", err.Error()),
)
return next
}

keySet, err := jwk.Fetch(context.Background(), jwksURL)
if err != nil {
slog.Error("Failed to fetch JWKS; JWT validation bypassed",
slog.String("jwks_url", jwksURL),
slog.String("error", err.Error()),
)
return next
}

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)
})
}

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 9 changed files in this pull request and generated 6 comments.

Comment thread internal/github/auth.go
Comment on lines 24 to +32
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
}
Comment thread internal/github/auth.go
Comment on lines +90 to +101
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
}
Comment on lines +35 to +42
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 +94 to +116
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
}
Comment on lines +56 to +63
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 +44 to +73
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))
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants