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
Open
Conversation
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
Contributor
There was a problem hiding this comment.
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/MaskPathhelpers 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 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 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 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 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) | ||
| } |
| 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 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 |
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 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 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 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 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) | ||
| }) | ||
| } |
Collaborator
Author
There was a problem hiding this comment.
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.
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 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)) | ||
| } | ||
|
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements authentication and security hardening for the API, completing Phase 5.
Tasks Implemented
GetGitHubToken(). Signs RS256 JWT with App private key → exchanges for installation access token. PAT env vars andgh auth tokenremain as lower-priority fallbacks. PEM from file (GITHUB_APP_PRIVATE_KEY_PATH) or env var (GITHUB_APP_PRIVATE_KEY).internal/auth/middleware.go— OIDC JWT validation middleware. Validates Bearer tokens against JWKS from issuer discovery doc. Bypassed whenBAUER_OIDC_ISSUERunset (local dev). Fails closed (503) when configured but JWKS unavailable. Protected endpoints on separate sub-mux.internal/logging/masking.go—MaskSecretandMaskPathhelpers with full unit tests. Audit confirmed no raw token logging in codebase.Security
MaskSecrethandles edge cases: empty →<unset>, ≤4 chars →****, longer → first 4 +...Files Changed
internal/github/auth.go— GitHub App token generationinternal/auth/middleware.go— OIDC JWT middlewareinternal/logging/masking.go+masking_test.go— masking helpers with testscmd/app/main.go— public vs protected mux splitgo.mod/go.sum—golang-jwt/jwt/v5,lestrrat-go/jwx/v2Part of the Bauer v2 stacked PR series (Branch 11 of 12).