From d32d8038d00ca199c69571ac5fb40abbcec6f39c Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 14:20:33 +0800 Subject: [PATCH 01/13] feat: add JWT and API key auth interceptor examples Config-controlled auth using go-grpc-middleware/v2 auth + golang-jwt/jwt/v5. AuthConfig embedded in config.Config (same pattern as cbConfig.Config), Setup() called from main(). No env vars set = no-op. - JWTAuthFunc: HMAC-SHA256 Bearer token validation with claims context - APIKeyAuthFunc: x-api-key header validation against configured keys - Tests for valid/invalid/expired/missing tokens and keys --- {{cookiecutter.app_name}}/AGENTS.md | 5 +- {{cookiecutter.app_name}}/config/config.go | 2 + {{cookiecutter.app_name}}/local.env.example | 4 + {{cookiecutter.app_name}}/main.go | 5 + .../service/auth/auth.go | 122 +++++++++++++++ .../service/auth/auth_test.go | 144 ++++++++++++++++++ 6 files changed, 281 insertions(+), 1 deletion(-) create mode 100644 {{cookiecutter.app_name}}/service/auth/auth.go create mode 100644 {{cookiecutter.app_name}}/service/auth/auth_test.go diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index c99ae66..41f5940 100644 --- a/{{cookiecutter.app_name}}/AGENTS.md +++ b/{{cookiecutter.app_name}}/AGENTS.md @@ -33,7 +33,10 @@ make run-docker # Run in Docker container │ ├── service.go # Business logic: implements gRPC service interface │ ├── healthcheck.go # Kubernetes liveness/readiness probes │ ├── service_test.go # Unit tests and benchmarks -│ └── healthcheck_test.go +│ ├── healthcheck_test.go +│ └── auth/ +│ ├── auth.go # JWT + API-key auth interceptor examples (uncomment in main.go to enable) +│ └── auth_test.go ├── proto/ │ └── *.proto # Protobuf definitions (source of truth for API) │ └── *.pb.go # GENERATED — do not edit diff --git a/{{cookiecutter.app_name}}/config/config.go b/{{cookiecutter.app_name}}/config/config.go index b62302d..5ccd3d7 100644 --- a/{{cookiecutter.app_name}}/config/config.go +++ b/{{cookiecutter.app_name}}/config/config.go @@ -6,6 +6,7 @@ import ( cbConfig "github.com/go-coldbrew/core/config" "github.com/go-coldbrew/log" "github.com/kelseyhightower/envconfig" + "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/service/auth" ) // defaultConfig is the default configuration for the application @@ -14,6 +15,7 @@ var defaultConfig Config type Config struct { cbConfig.Config + auth.AuthConfig PanicOnConfigError bool `envconfig:"PANIC_ON_CONFIG_ERROR" default:"true"` // App configuration // Remove this line and add your own configuration diff --git a/{{cookiecutter.app_name}}/local.env.example b/{{cookiecutter.app_name}}/local.env.example index 58ed1de..15eef87 100644 --- a/{{cookiecutter.app_name}}/local.env.example +++ b/{{cookiecutter.app_name}}/local.env.example @@ -2,3 +2,7 @@ ENVIRONMENT="dev" # OpenTelemetry tracing — traces flow to Jaeger when obs profile is running OTLP_ENDPOINT=localhost:4317 OTLP_INSECURE=true + +# Authentication — uncomment ONE to enable (see service/auth/auth.go) +# JWT_SECRET=your-secret-key-here +# API_KEYS=key1,key2,key3 diff --git a/{{cookiecutter.app_name}}/main.go b/{{cookiecutter.app_name}}/main.go index 192895d..fc32237 100644 --- a/{{cookiecutter.app_name}}/main.go +++ b/{{cookiecutter.app_name}}/main.go @@ -8,6 +8,7 @@ import ( "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/config" {{cookiecutter.app_name|lower}} "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/proto" "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/service" + "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/service/auth" "{{cookiecutter.source_path}}/{{cookiecutter.app_name}}/version" "github.com/go-coldbrew/core" "github.com/go-coldbrew/log" @@ -101,6 +102,10 @@ func main() { // Set the release name to the git commit hash from the version package cfg.ReleaseName = version.GitCommit + // Register auth interceptors if JWT_SECRET or API_KEYS env vars are set. + // See service/auth/auth.go and https://docs.coldbrew.cloud/howto/auth/ + auth.Setup(config.Get().AuthConfig) + // Initialize the ColdBrew framework with the given configuration // This is a good place to customise the ColdBrew framework configuration if needed cb := core.New(cfg) diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go new file mode 100644 index 0000000..cbed0b3 --- /dev/null +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -0,0 +1,122 @@ +// Package auth provides authentication interceptors for ColdBrew gRPC services. +// +// Auth is config-controlled: set JWT_SECRET or API_KEYS environment variables to enable. +// When neither is set, auth is a no-op. +// +// The AuthConfig struct is embedded in config.Config (same pattern as cbConfig.Config) +// and Setup() is called from main() to register interceptors. +// +// References: +// - go-grpc-middleware auth: https://github.com/grpc-ecosystem/go-grpc-middleware/tree/main/interceptors/auth +// - grpc-go authz (policy-based authorization): https://github.com/grpc/grpc-go/tree/master/authz +// - golang-jwt/jwt: https://github.com/golang-jwt/jwt +package auth + +import ( + "context" + "fmt" + + "github.com/go-coldbrew/interceptors" + "github.com/golang-jwt/jwt/v5" + grpcauth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const apiKeyHeader = "x-api-key" + +// AuthConfig holds authentication configuration loaded from environment variables. +// Embedded in config.Config (same pattern as cbConfig.Config). +type AuthConfig struct { + JWTSecret string `envconfig:"JWT_SECRET"` + APIKeys []string `envconfig:"API_KEYS"` +} + +// Setup registers auth interceptors based on the loaded config. +// Called from main() after config is loaded. If neither JWTSecret nor APIKeys +// are set, this is a no-op. +func Setup(cfg AuthConfig) { + if cfg.JWTSecret != "" { + jwtAuth := JWTAuthFunc(cfg.JWTSecret) + interceptors.AddUnaryServerInterceptor(context.Background(), + grpcauth.UnaryServerInterceptor(jwtAuth)) + interceptors.AddStreamServerInterceptor(context.Background(), + grpcauth.StreamServerInterceptor(jwtAuth)) + } + if len(cfg.APIKeys) > 0 { + apiKeyAuth := APIKeyAuthFunc(cfg.APIKeys) + interceptors.AddUnaryServerInterceptor(context.Background(), + grpcauth.UnaryServerInterceptor(apiKeyAuth)) + interceptors.AddStreamServerInterceptor(context.Background(), + grpcauth.StreamServerInterceptor(apiKeyAuth)) + } +} + +type contextKey struct{} + +// Claims holds the parsed JWT claims, accessible in handlers via ClaimsFromContext. +// Subject, Issuer, ExpiresAt, etc. are available via the embedded RegisteredClaims. +type Claims struct { + jwt.RegisteredClaims +} + +// ClaimsFromContext returns the JWT claims from the context, or nil if not present. +func ClaimsFromContext(ctx context.Context) *Claims { + c, _ := ctx.Value(contextKey{}).(*Claims) + return c +} + +// JWTAuthFunc returns an [grpcauth.AuthFunc] that validates Bearer JWT tokens +// using HMAC-SHA256. The secret is the shared signing key. +// +// To use a different signing method (RSA, ECDSA), replace jwt.SigningMethodHS256 +// with the appropriate method and change the keyFunc to return your public key. +// See https://github.com/golang-jwt/jwt for details. +func JWTAuthFunc(secret string) grpcauth.AuthFunc { + secretBytes := []byte(secret) + keyFunc := func(token *jwt.Token) (any, error) { + if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return secretBytes, nil + } + validMethods := jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}) + return func(ctx context.Context) (context.Context, error) { + tokenStr, err := grpcauth.AuthFromMD(ctx, "bearer") + if err != nil { + return nil, err + } + + claims := &Claims{} + token, err := jwt.ParseWithClaims(tokenStr, claims, keyFunc, validMethods) + if err != nil || !token.Valid { + return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err) + } + + return context.WithValue(ctx, contextKey{}, claims), nil + } +} + +// APIKeyAuthFunc returns an [grpcauth.AuthFunc] that validates API keys from the +// "x-api-key" gRPC metadata header. validKeys is the set of accepted keys. +func APIKeyAuthFunc(validKeys []string) grpcauth.AuthFunc { + keySet := make(map[string]struct{}, len(validKeys)) + for _, k := range validKeys { + keySet[k] = struct{}{} + } + return func(ctx context.Context) (context.Context, error) { + md, ok := metadata.FromIncomingContext(ctx) + if !ok { + return nil, status.Error(codes.Unauthenticated, "missing metadata") + } + keys := md.Get(apiKeyHeader) + if len(keys) == 0 { + return nil, status.Errorf(codes.Unauthenticated, "missing %s header", apiKeyHeader) + } + if _, valid := keySet[keys[0]]; !valid { + return nil, status.Error(codes.Unauthenticated, "invalid API key") + } + return ctx, nil + } +} diff --git a/{{cookiecutter.app_name}}/service/auth/auth_test.go b/{{cookiecutter.app_name}}/service/auth/auth_test.go new file mode 100644 index 0000000..015fcbf --- /dev/null +++ b/{{cookiecutter.app_name}}/service/auth/auth_test.go @@ -0,0 +1,144 @@ +package auth + +import ( + "context" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const testSecret = "test-secret-key" + +func signToken(t *testing.T, claims jwt.Claims, secret string) string { + t.Helper() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + s, err := token.SignedString([]byte(secret)) + require.NoError(t, err) + return s +} + +func ctxWithBearer(token string) context.Context { + md := metadata.Pairs("authorization", "bearer "+token) + return metadata.NewIncomingContext(context.Background(), md) +} + +func ctxWithAPIKey(key string) context.Context { + md := metadata.Pairs(apiKeyHeader, key) + return metadata.NewIncomingContext(context.Background(), md) +} + +func TestJWTAuthFunc_ValidToken(t *testing.T) { + authFunc := JWTAuthFunc(testSecret) + + tokenStr := signToken(t, &Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: "user-123", + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + }, testSecret) + + ctx, err := authFunc(ctxWithBearer(tokenStr)) + require.NoError(t, err) + + claims := ClaimsFromContext(ctx) + require.NotNil(t, claims) + assert.Equal(t, "user-123", claims.Subject) +} + +func TestJWTAuthFunc_ExpiredToken(t *testing.T) { + authFunc := JWTAuthFunc(testSecret) + + tokenStr := signToken(t, &Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(-time.Hour)), + }, + }, testSecret) + + _, err := authFunc(ctxWithBearer(tokenStr)) + require.Error(t, err) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) +} + +func TestJWTAuthFunc_WrongSecret(t *testing.T) { + authFunc := JWTAuthFunc(testSecret) + + tokenStr := signToken(t, &Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + }, "wrong-secret") + + _, err := authFunc(ctxWithBearer(tokenStr)) + require.Error(t, err) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) +} + +func TestJWTAuthFunc_MissingToken(t *testing.T) { + authFunc := JWTAuthFunc(testSecret) + + ctx := metadata.NewIncomingContext(context.Background(), metadata.MD{}) + _, err := authFunc(ctx) + require.Error(t, err) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) +} + +func TestJWTAuthFunc_InvalidSigningMethod(t *testing.T) { + authFunc := JWTAuthFunc(testSecret) + + // Sign with HS384 but the auth func only accepts HS256 + token := jwt.NewWithClaims(jwt.SigningMethodHS384, &Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + }) + tokenStr, err := token.SignedString([]byte(testSecret)) + require.NoError(t, err) + + _, err = authFunc(ctxWithBearer(tokenStr)) + require.Error(t, err) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) +} + +func TestClaimsFromContext_NoClaims(t *testing.T) { + claims := ClaimsFromContext(context.Background()) + assert.Nil(t, claims) +} + +func TestAPIKeyAuthFunc_ValidKey(t *testing.T) { + authFunc := APIKeyAuthFunc([]string{"key-1", "key-2"}) + + ctx, err := authFunc(ctxWithAPIKey("key-1")) + require.NoError(t, err) + assert.NotNil(t, ctx) +} + +func TestAPIKeyAuthFunc_InvalidKey(t *testing.T) { + authFunc := APIKeyAuthFunc([]string{"key-1"}) + + _, err := authFunc(ctxWithAPIKey("wrong-key")) + require.Error(t, err) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) +} + +func TestAPIKeyAuthFunc_MissingHeader(t *testing.T) { + authFunc := APIKeyAuthFunc([]string{"key-1"}) + + ctx := metadata.NewIncomingContext(context.Background(), metadata.MD{}) + _, err := authFunc(ctx) + require.Error(t, err) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) +} + +func TestAPIKeyAuthFunc_MissingMetadata(t *testing.T) { + authFunc := APIKeyAuthFunc([]string{"key-1"}) + + _, err := authFunc(context.Background()) + require.Error(t, err) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) +} From 52468034f3db42488fc251c1ba9e3a1d89b8d07b Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 14:47:12 +0800 Subject: [PATCH 02/13] fix: address PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add golang-jwt/jwt/v5 and promote interceptors to direct deps in go.mod - Support both JWT + API key simultaneously via eitherAuthFunc (accept either) - Sanitize empty/whitespace API keys to prevent accidental bypass - Fix AGENTS.md stale "uncomment" wording → env-var-controlled - Fix local.env.example wording to match config-driven behavior --- {{cookiecutter.app_name}}/AGENTS.md | 2 +- {{cookiecutter.app_name}}/go.mod | 3 +- {{cookiecutter.app_name}}/local.env.example | 3 +- .../service/auth/auth.go | 47 ++++++++++++++----- 4 files changed, 40 insertions(+), 15 deletions(-) diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index 41f5940..a8791c2 100644 --- a/{{cookiecutter.app_name}}/AGENTS.md +++ b/{{cookiecutter.app_name}}/AGENTS.md @@ -35,7 +35,7 @@ make run-docker # Run in Docker container │ ├── service_test.go # Unit tests and benchmarks │ ├── healthcheck_test.go │ └── auth/ -│ ├── auth.go # JWT + API-key auth interceptor examples (uncomment in main.go to enable) +│ ├── auth.go # JWT + API-key auth interceptors (enabled when JWT_SECRET/API_KEYS are set) │ └── auth_test.go ├── proto/ │ └── *.proto # Protobuf definitions (source of truth for API) diff --git a/{{cookiecutter.app_name}}/go.mod b/{{cookiecutter.app_name}}/go.mod index 144ba64..b42954c 100644 --- a/{{cookiecutter.app_name}}/go.mod +++ b/{{cookiecutter.app_name}}/go.mod @@ -14,8 +14,10 @@ require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1 github.com/go-coldbrew/core v0.1.45 github.com/go-coldbrew/errors v0.2.13 + github.com/go-coldbrew/interceptors v0.1.20 github.com/go-coldbrew/log v0.3.1 github.com/go-coldbrew/options v0.3.0 + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 @@ -122,7 +124,6 @@ require ( github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect github.com/ghostiam/protogetter v0.3.20 // indirect - github.com/go-coldbrew/interceptors v0.1.20 // indirect github.com/go-coldbrew/options v0.2.7 // indirect github.com/go-coldbrew/tracing v0.2.1 // indirect github.com/go-critic/go-critic v0.14.3 // indirect diff --git a/{{cookiecutter.app_name}}/local.env.example b/{{cookiecutter.app_name}}/local.env.example index 15eef87..b4f8e6f 100644 --- a/{{cookiecutter.app_name}}/local.env.example +++ b/{{cookiecutter.app_name}}/local.env.example @@ -3,6 +3,7 @@ ENVIRONMENT="dev" OTLP_ENDPOINT=localhost:4317 OTLP_INSECURE=true -# Authentication — uncomment ONE to enable (see service/auth/auth.go) +# Authentication — set either or both to enable (see service/auth/auth.go) +# If both are set, requests can authenticate with either JWT or API key. # JWT_SECRET=your-secret-key-here # API_KEYS=key1,key2,key3 diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go index cbed0b3..01671bb 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth.go +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -15,6 +15,7 @@ package auth import ( "context" "fmt" + "strings" "github.com/go-coldbrew/interceptors" "github.com/golang-jwt/jwt/v5" @@ -37,19 +38,37 @@ type AuthConfig struct { // Called from main() after config is loaded. If neither JWTSecret nor APIKeys // are set, this is a no-op. func Setup(cfg AuthConfig) { - if cfg.JWTSecret != "" { - jwtAuth := JWTAuthFunc(cfg.JWTSecret) - interceptors.AddUnaryServerInterceptor(context.Background(), - grpcauth.UnaryServerInterceptor(jwtAuth)) - interceptors.AddStreamServerInterceptor(context.Background(), - grpcauth.StreamServerInterceptor(jwtAuth)) + var authFunc grpcauth.AuthFunc + switch { + case cfg.JWTSecret != "" && len(cfg.APIKeys) > 0: + // Both configured: accept either JWT or API key. + authFunc = eitherAuthFunc(JWTAuthFunc(cfg.JWTSecret), APIKeyAuthFunc(cfg.APIKeys)) + case cfg.JWTSecret != "": + authFunc = JWTAuthFunc(cfg.JWTSecret) + case len(cfg.APIKeys) > 0: + authFunc = APIKeyAuthFunc(cfg.APIKeys) + default: + return } - if len(cfg.APIKeys) > 0 { - apiKeyAuth := APIKeyAuthFunc(cfg.APIKeys) - interceptors.AddUnaryServerInterceptor(context.Background(), - grpcauth.UnaryServerInterceptor(apiKeyAuth)) - interceptors.AddStreamServerInterceptor(context.Background(), - grpcauth.StreamServerInterceptor(apiKeyAuth)) + interceptors.AddUnaryServerInterceptor(context.Background(), + grpcauth.UnaryServerInterceptor(authFunc)) + interceptors.AddStreamServerInterceptor(context.Background(), + grpcauth.StreamServerInterceptor(authFunc)) +} + +// eitherAuthFunc returns an AuthFunc that succeeds if any of the provided +// auth functions succeed. It tries each in order and returns the first success. +func eitherAuthFunc(authFuncs ...grpcauth.AuthFunc) grpcauth.AuthFunc { + return func(ctx context.Context) (context.Context, error) { + var lastErr error + for _, fn := range authFuncs { + authCtx, err := fn(ctx) + if err == nil { + return authCtx, nil + } + lastErr = err + } + return nil, lastErr } } @@ -103,6 +122,10 @@ func JWTAuthFunc(secret string) grpcauth.AuthFunc { func APIKeyAuthFunc(validKeys []string) grpcauth.AuthFunc { keySet := make(map[string]struct{}, len(validKeys)) for _, k := range validKeys { + k = strings.TrimSpace(k) + if k == "" { + continue + } keySet[k] = struct{}{} } return func(ctx context.Context) (context.Context, error) { From 79a2b298c5d90477a036ae6e505682b8c8a9dc24 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 15:02:53 +0800 Subject: [PATCH 03/13] feat: skip auth for health/ready/reflection methods by default Skip auth for methods matching ColdBrew's default FilterMethods (healthcheck, readycheck, serverreflectioninfo) plus grpc.health.v1.Health so Kubernetes probes work without credentials. --- .../service/auth/auth.go | 22 ++++++++++ .../service/auth/auth_test.go | 40 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go index 01671bb..4b9abee 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth.go +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -20,6 +20,7 @@ import ( "github.com/go-coldbrew/interceptors" "github.com/golang-jwt/jwt/v5" grpcauth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" + "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -27,6 +28,11 @@ import ( const apiKeyHeader = "x-api-key" +// defaultSkipMethods matches ColdBrew's default FilterMethods — these methods +// are skipped by auth interceptors so that health probes and gRPC reflection +// continue to work without credentials. +var defaultSkipMethods = []string{"healthcheck", "readycheck", "serverreflectioninfo", "grpc.health.v1.health"} + // AuthConfig holds authentication configuration loaded from environment variables. // Embedded in config.Config (same pattern as cbConfig.Config). type AuthConfig struct { @@ -50,12 +56,28 @@ func Setup(cfg AuthConfig) { default: return } + authFunc = skipMethodsAuthFunc(authFunc, defaultSkipMethods) interceptors.AddUnaryServerInterceptor(context.Background(), grpcauth.UnaryServerInterceptor(authFunc)) interceptors.AddStreamServerInterceptor(context.Background(), grpcauth.StreamServerInterceptor(authFunc)) } +// skipMethodsAuthFunc wraps an AuthFunc to skip auth for methods whose +// full method name contains any of the given substrings (case-insensitive). +func skipMethodsAuthFunc(fn grpcauth.AuthFunc, methods []string) grpcauth.AuthFunc { + return func(ctx context.Context) (context.Context, error) { + fullMethod, _ := grpc.Method(ctx) + lower := strings.ToLower(fullMethod) + for _, m := range methods { + if strings.Contains(lower, m) { + return ctx, nil + } + } + return fn(ctx) + } +} + // eitherAuthFunc returns an AuthFunc that succeeds if any of the provided // auth functions succeed. It tries each in order and returns the first success. func eitherAuthFunc(authFuncs ...grpcauth.AuthFunc) grpcauth.AuthFunc { diff --git a/{{cookiecutter.app_name}}/service/auth/auth_test.go b/{{cookiecutter.app_name}}/service/auth/auth_test.go index 015fcbf..034f5d0 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth_test.go +++ b/{{cookiecutter.app_name}}/service/auth/auth_test.go @@ -8,6 +8,7 @@ import ( "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" "google.golang.org/grpc/status" @@ -142,3 +143,42 @@ func TestAPIKeyAuthFunc_MissingMetadata(t *testing.T) { require.Error(t, err) assert.Equal(t, codes.Unauthenticated, status.Code(err)) } + +func TestSkipMethodsAuthFunc_HealthCheck(t *testing.T) { + // Auth func that always fails — should be skipped for health methods + authFunc := func(ctx context.Context) (context.Context, error) { + return nil, status.Error(codes.Unauthenticated, "should not be called") + } + wrapped := skipMethodsAuthFunc(authFunc, defaultSkipMethods) + + tests := []struct { + method string + skip bool + }{ + {"/myservice.v1.Svc/HealthCheck", true}, // contains "healthcheck" + {"/myservice.v1.Svc/ReadyCheck", true}, // contains "readycheck" + {"/grpc.health.v1.Health/Check", true}, // contains "grpc.health.v1.health" + {"/grpc.health.v1.Health/Watch", true}, // contains "grpc.health.v1.health" + {"/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", true}, + {"/myservice.v1.Svc/Echo", false}, + } + for _, tt := range tests { + ctx := grpc.NewContextWithServerTransportStream(context.Background(), &fakeStream{method: tt.method}) + _, err := wrapped(ctx) + if tt.skip { + assert.NoError(t, err, "expected skip for %s", tt.method) + } else { + assert.Error(t, err, "expected auth for %s", tt.method) + } + } +} + +// fakeStream implements grpc.ServerTransportStream for testing grpc.Method(). +type fakeStream struct { + method string +} + +func (s *fakeStream) Method() string { return s.method } +func (s *fakeStream) SetHeader(metadata.MD) error { return nil } +func (s *fakeStream) SendHeader(metadata.MD) error { return nil } +func (s *fakeStream) SetTrailer(metadata.MD) error { return nil } From 780ce3d3263168838f22836959f07c199e9bb7b6 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 15:10:24 +0800 Subject: [PATCH 04/13] fix: use exact method paths for auth skip list Replace substring matching with exact full method path matching to prevent methods containing "healthcheck" as a substring from bypassing auth. Uses cookiecutter template variables for the service-specific paths. --- .../service/auth/auth.go | 26 +++++++++++-------- .../service/auth/auth_test.go | 14 ++++++---- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go index 4b9abee..72633e2 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth.go +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -28,10 +28,17 @@ import ( const apiKeyHeader = "x-api-key" -// defaultSkipMethods matches ColdBrew's default FilterMethods — these methods -// are skipped by auth interceptors so that health probes and gRPC reflection -// continue to work without credentials. -var defaultSkipMethods = []string{"healthcheck", "readycheck", "serverreflectioninfo", "grpc.health.v1.health"} +// defaultSkipMethods lists exact gRPC method paths that bypass auth so +// health probes and gRPC reflection work without credentials. +//nolint:gochecknoglobals +var defaultSkipMethods = map[string]struct{}{ + "/{{cookiecutter.grpc_package}}.{{cookiecutter.service_name}}/HealthCheck": {}, + "/{{cookiecutter.grpc_package}}.{{cookiecutter.service_name}}/ReadyCheck": {}, + "/grpc.health.v1.Health/Check": {}, + "/grpc.health.v1.Health/Watch": {}, + "/grpc.reflection.v1.ServerReflection/ServerReflectionInfo": {}, + "/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo": {}, +} // AuthConfig holds authentication configuration loaded from environment variables. // Embedded in config.Config (same pattern as cbConfig.Config). @@ -63,14 +70,11 @@ func Setup(cfg AuthConfig) { grpcauth.StreamServerInterceptor(authFunc)) } -// skipMethodsAuthFunc wraps an AuthFunc to skip auth for methods whose -// full method name contains any of the given substrings (case-insensitive). -func skipMethodsAuthFunc(fn grpcauth.AuthFunc, methods []string) grpcauth.AuthFunc { +// skipMethodsAuthFunc wraps an AuthFunc to skip auth for methods in the skip set. +func skipMethodsAuthFunc(fn grpcauth.AuthFunc, skip map[string]struct{}) grpcauth.AuthFunc { return func(ctx context.Context) (context.Context, error) { - fullMethod, _ := grpc.Method(ctx) - lower := strings.ToLower(fullMethod) - for _, m := range methods { - if strings.Contains(lower, m) { + if fullMethod, ok := grpc.Method(ctx); ok { + if _, skip := skip[fullMethod]; skip { return ctx, nil } } diff --git a/{{cookiecutter.app_name}}/service/auth/auth_test.go b/{{cookiecutter.app_name}}/service/auth/auth_test.go index 034f5d0..c2d96b4 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth_test.go +++ b/{{cookiecutter.app_name}}/service/auth/auth_test.go @@ -144,7 +144,7 @@ func TestAPIKeyAuthFunc_MissingMetadata(t *testing.T) { assert.Equal(t, codes.Unauthenticated, status.Code(err)) } -func TestSkipMethodsAuthFunc_HealthCheck(t *testing.T) { +func TestSkipMethodsAuthFunc(t *testing.T) { // Auth func that always fails — should be skipped for health methods authFunc := func(ctx context.Context) (context.Context, error) { return nil, status.Error(codes.Unauthenticated, "should not be called") @@ -155,11 +155,15 @@ func TestSkipMethodsAuthFunc_HealthCheck(t *testing.T) { method string skip bool }{ - {"/myservice.v1.Svc/HealthCheck", true}, // contains "healthcheck" - {"/myservice.v1.Svc/ReadyCheck", true}, // contains "readycheck" - {"/grpc.health.v1.Health/Check", true}, // contains "grpc.health.v1.health" - {"/grpc.health.v1.Health/Watch", true}, // contains "grpc.health.v1.health" + // Exact matches from defaultSkipMethods + {"/{{cookiecutter.grpc_package}}.{{cookiecutter.service_name}}/HealthCheck", true}, + {"/{{cookiecutter.grpc_package}}.{{cookiecutter.service_name}}/ReadyCheck", true}, + {"/grpc.health.v1.Health/Check", true}, + {"/grpc.health.v1.Health/Watch", true}, {"/grpc.reflection.v1.ServerReflection/ServerReflectionInfo", true}, + {"/grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo", true}, + // Should NOT skip — exact match only, no substring + {"/myservice.v1.Svc/GetHealthCheckStatus", false}, {"/myservice.v1.Svc/Echo", false}, } for _, tt := range tests { From ea0065b202142e238b7a999bcd70201ce8304d81 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 15:32:18 +0800 Subject: [PATCH 05/13] fix: update go.mod with complete dependency graph Add all transitive deps required by auth package (golang-jwt/jwt, go-grpc-middleware auth, interceptors, etc.) so go.mod is self-consistent and the template builds without go mod tidy. --- {{cookiecutter.app_name}}/go.mod | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/{{cookiecutter.app_name}}/go.mod b/{{cookiecutter.app_name}}/go.mod index b42954c..2c438fc 100644 --- a/{{cookiecutter.app_name}}/go.mod +++ b/{{cookiecutter.app_name}}/go.mod @@ -16,11 +16,13 @@ require ( github.com/go-coldbrew/errors v0.2.13 github.com/go-coldbrew/interceptors v0.1.20 github.com/go-coldbrew/log v0.3.1 - github.com/go-coldbrew/options v0.3.0 - github.com/golang-jwt/jwt/v5 v5.2.2 + github.com/golang-jwt/jwt/v5 v5.3.0 + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 github.com/kelseyhightower/envconfig v1.4.0 github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 + github.com/prometheus/client_golang v1.23.2 + github.com/prometheus/client_model v0.6.2 github.com/stretchr/testify v1.11.1 google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 google.golang.org/grpc v1.80.0 @@ -63,6 +65,8 @@ require ( github.com/Microsoft/go-winio v0.6.2 // indirect github.com/MirrexOne/unqueryvet v1.5.4 // indirect github.com/OpenPeeDeeP/depguard/v2 v2.2.1 // indirect + github.com/afex/hystrix-go v0.0.0-20180502004556-fa1af6a1f4f5 // indirect + github.com/airbrake/gobrake/v5 v5.6.2 // indirect github.com/alecthomas/chroma/v2 v2.23.1 // indirect github.com/alecthomas/go-check-sumtype v0.3.1 // indirect github.com/alexkohler/nakedret/v2 v2.0.6 // indirect @@ -87,8 +91,10 @@ require ( github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1 // indirect github.com/butuzov/ireturn v0.4.0 // indirect github.com/butuzov/mirror v1.3.0 // indirect + github.com/caio/go-tdigest/v4 v4.0.1 // indirect github.com/catenacyber/perfsprint v0.10.1 // indirect github.com/ccojocar/zxcvbn-go v1.0.4 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charithe/durationcheck v0.0.11 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect @@ -123,12 +129,12 @@ require ( github.com/firefart/nonamedreturns v1.0.6 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fzipp/gocyclo v0.6.0 // indirect + github.com/getsentry/sentry-go v0.43.0 // indirect github.com/ghostiam/protogetter v0.3.20 // indirect - github.com/go-coldbrew/options v0.2.7 // indirect + github.com/go-coldbrew/hystrixprometheus v0.1.2 // indirect + github.com/go-coldbrew/options v0.3.0 // indirect github.com/go-coldbrew/tracing v0.2.1 // indirect github.com/go-critic/go-critic v0.14.3 // indirect - github.com/go-kit/log v0.2.1 // indirect - github.com/go-logfmt/logfmt v0.6.1 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-toolsmith/astcast v1.1.0 // indirect @@ -143,6 +149,7 @@ require ( github.com/gobwas/glob v0.2.3 // indirect github.com/godoc-lint/godoc-lint v0.11.2 // indirect github.com/gofrs/flock v0.13.0 // indirect + github.com/golang/protobuf v1.5.4 // indirect github.com/golangci/asciicheck v0.5.0 // indirect github.com/golangci/dupl v0.0.0-20250308024227-f665c8d69b32 // indirect github.com/golangci/go-printf-func-name v0.1.1 // indirect @@ -163,6 +170,7 @@ require ( github.com/gostaticanalysis/comment v1.5.0 // indirect github.com/gostaticanalysis/forcetypeassert v0.2.0 // indirect github.com/gostaticanalysis/nilerr v0.1.2 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect github.com/hashicorp/go-version v1.8.0 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect @@ -176,6 +184,7 @@ require ( github.com/jingyugao/rowserrcheck v1.1.1 // indirect github.com/jinzhu/copier v0.4.0 // indirect github.com/jjti/go-spancheck v0.6.5 // indirect + github.com/jonboulle/clockwork v0.3.0 // indirect github.com/julz/importas v0.2.0 // indirect github.com/karamaru-alpha/copyloopvar v1.2.2 // indirect github.com/kisielk/errcheck v1.10.0 // indirect @@ -213,17 +222,18 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nakabonne/nestif v0.3.1 // indirect + github.com/newrelic/go-agent/v3 v3.42.0 // indirect + github.com/newrelic/go-agent/v3/integrations/nrgrpc v1.4.7 // indirect github.com/nishanths/exhaustive v0.12.0 // indirect github.com/nishanths/predeclared v0.2.2 // indirect github.com/nunnatsa/ginkgolinter v0.23.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect - github.com/prometheus/client_golang v1.23.2 // indirect - github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/procfs v0.20.1 // indirect github.com/quasilyte/go-ruleguard v0.4.5 // indirect @@ -236,6 +246,7 @@ require ( github.com/raeperd/recvcheck v0.2.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect + github.com/rollbar/rollbar-go v1.4.8 // indirect github.com/rs/cors v1.11.1 // indirect github.com/rs/zerolog v1.33.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect @@ -302,6 +313,7 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.uber.org/automaxprocs v1.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect From bd8c7b796c1971d2514fbed4c6212e4a1514a041 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 19:43:42 +0800 Subject: [PATCH 06/13] fix: context-first Setup(), don't leak JWT errors to clients - Setup() now takes context.Context as first param (ColdBrew convention) - JWT validation errors return generic "invalid token" instead of exposing parser details to clients --- {{cookiecutter.app_name}}/main.go | 2 +- {{cookiecutter.app_name}}/service/auth/auth.go | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/{{cookiecutter.app_name}}/main.go b/{{cookiecutter.app_name}}/main.go index fc32237..718484c 100644 --- a/{{cookiecutter.app_name}}/main.go +++ b/{{cookiecutter.app_name}}/main.go @@ -104,7 +104,7 @@ func main() { // Register auth interceptors if JWT_SECRET or API_KEYS env vars are set. // See service/auth/auth.go and https://docs.coldbrew.cloud/howto/auth/ - auth.Setup(config.Get().AuthConfig) + auth.Setup(context.Background(), config.Get().AuthConfig) // Initialize the ColdBrew framework with the given configuration // This is a good place to customise the ColdBrew framework configuration if needed diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go index 72633e2..6ad62a6 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth.go +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -50,7 +50,7 @@ type AuthConfig struct { // Setup registers auth interceptors based on the loaded config. // Called from main() after config is loaded. If neither JWTSecret nor APIKeys // are set, this is a no-op. -func Setup(cfg AuthConfig) { +func Setup(ctx context.Context, cfg AuthConfig) { var authFunc grpcauth.AuthFunc switch { case cfg.JWTSecret != "" && len(cfg.APIKeys) > 0: @@ -64,9 +64,9 @@ func Setup(cfg AuthConfig) { return } authFunc = skipMethodsAuthFunc(authFunc, defaultSkipMethods) - interceptors.AddUnaryServerInterceptor(context.Background(), + interceptors.AddUnaryServerInterceptor(ctx, grpcauth.UnaryServerInterceptor(authFunc)) - interceptors.AddStreamServerInterceptor(context.Background(), + interceptors.AddStreamServerInterceptor(ctx, grpcauth.StreamServerInterceptor(authFunc)) } @@ -136,7 +136,7 @@ func JWTAuthFunc(secret string) grpcauth.AuthFunc { claims := &Claims{} token, err := jwt.ParseWithClaims(tokenStr, claims, keyFunc, validMethods) if err != nil || !token.Valid { - return nil, status.Errorf(codes.Unauthenticated, "invalid token: %v", err) + return nil, status.Error(codes.Unauthenticated, "invalid token") } return context.WithValue(ctx, contextKey{}, claims), nil From 6939d5cdc9485ea7456c8579886bf33e35aacdb0 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 19:45:18 +0800 Subject: [PATCH 07/13] feat: add JWT and API key security schemes to OpenAPI/Swagger Swagger UI now shows Authorize button with BearerJWT and APIKey options. Health/ready endpoints marked as no-auth-required. --- .../{{cookiecutter.app_name|lower}}.proto | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/{{cookiecutter.app_name}}/proto/{{cookiecutter.app_name|lower}}.proto b/{{cookiecutter.app_name}}/proto/{{cookiecutter.app_name|lower}}.proto index 2d3a7e5..6467db2 100644 --- a/{{cookiecutter.app_name}}/proto/{{cookiecutter.app_name|lower}}.proto +++ b/{{cookiecutter.app_name}}/proto/{{cookiecutter.app_name|lower}}.proto @@ -21,6 +21,38 @@ option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_swagger) = { } schemes: HTTP; schemes: HTTPS; + security_definitions: { + security: { + key: "BearerJWT"; + value: { + type: TYPE_API_KEY; + in: IN_HEADER; + name: "Authorization"; + description: "JWT Bearer token. Format: \"Bearer {token}\". Set JWT_SECRET env var to enable."; + } + } + security: { + key: "APIKey"; + value: { + type: TYPE_API_KEY; + in: IN_HEADER; + name: "x-api-key"; + description: "API key authentication. Set API_KEYS env var to enable."; + } + } + } + security: { + security_requirement: { + key: "BearerJWT"; + value: {}; + } + } + security: { + security_requirement: { + key: "APIKey"; + value: {}; + } + } }; message EchoRequest{ @@ -38,6 +70,9 @@ service {{cookiecutter.service_name}} { option (google.api.http) = { get: "/healthcheck" }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + security: {}; // No auth required + }; } //ReadinessProbe for the service @@ -45,6 +80,9 @@ service {{cookiecutter.service_name}} { option (google.api.http) = { get: "/readycheck" }; + option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_operation) = { + security: {}; // No auth required + }; } From e2b7baabf27e8586728c25911cbd555badebfb49 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 19:47:12 +0800 Subject: [PATCH 08/13] chore: update example JWT_SECRET to hint at minimum key length --- {{cookiecutter.app_name}}/local.env.example | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/{{cookiecutter.app_name}}/local.env.example b/{{cookiecutter.app_name}}/local.env.example index b4f8e6f..245e599 100644 --- a/{{cookiecutter.app_name}}/local.env.example +++ b/{{cookiecutter.app_name}}/local.env.example @@ -5,5 +5,5 @@ OTLP_INSECURE=true # Authentication — set either or both to enable (see service/auth/auth.go) # If both are set, requests can authenticate with either JWT or API key. -# JWT_SECRET=your-secret-key-here +# JWT_SECRET=a-string-secret-at-least-256-bits-long # API_KEYS=key1,key2,key3 From 4e917889f792d9030d27d3cde7611ef45135fece Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 19:55:46 +0800 Subject: [PATCH 09/13] feat: add GenerateTestToken helper, auth docs in README and AGENTS.md - GenerateTestToken() for local dev/testing JWT generation - README: authentication section with enable commands and test token example - AGENTS.md: authentication added to Key Patterns --- {{cookiecutter.app_name}}/AGENTS.md | 1 + {{cookiecutter.app_name}}/README.md | 19 +++++++++++++++++++ .../service/auth/auth.go | 15 +++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index a8791c2..bc9ab2d 100644 --- a/{{cookiecutter.app_name}}/AGENTS.md +++ b/{{cookiecutter.app_name}}/AGENTS.md @@ -60,6 +60,7 @@ make run-docker # Run in Docker container - **gRPC-first**: All endpoints are defined in `proto/{{cookiecutter.app_name|lower}}.proto`. HTTP/JSON routes are auto-generated via grpc-gateway annotations. Never create HTTP handlers manually. - **Context propagation**: `context.Context` is the first parameter everywhere. Interceptors propagate trace IDs, log fields, and options through it. - **Configuration**: All config via environment variables using `envconfig`. Add fields to `config/config.go` with struct tags. See [ColdBrew config docs](https://pkg.go.dev/github.com/go-coldbrew/core/config#Config) for framework options. +- **Authentication**: JWT and API key auth are built in via `service/auth/`. Config-controlled — set `JWT_SECRET` or `API_KEYS` env vars to enable. Health/ready/reflection RPCs bypass auth automatically. See [Authentication docs](https://docs.coldbrew.cloud/howto/auth/). - **Health checks**: Kubernetes liveness (`/healthcheck`) and readiness (`/readycheck`) are built-in. Service starts as NOT_SERVING until `SetReady()` is called. - **Observability**: Prometheus metrics at `/metrics`, pprof at `/debug/pprof/`, OpenAPI/Swagger at `/swagger/`. - **Graceful shutdown**: ColdBrew handles SIGINT/SIGTERM. The `Stop()` method on your service is called for cleanup. diff --git a/{{cookiecutter.app_name}}/README.md b/{{cookiecutter.app_name}}/README.md index 1631929..8209a85 100644 --- a/{{cookiecutter.app_name}}/README.md +++ b/{{cookiecutter.app_name}}/README.md @@ -95,6 +95,25 @@ You can find the environment variables for local development in the `local.env` A large number of configuration options are powered by [Coldbrew] and used as environment variables. You can find the list of environment variables [here](https://pkg.go.dev/github.com/go-coldbrew/core/config#Config). +## Authentication + +JWT and API key authentication are built in and config-controlled. Set environment variables to enable: + +```console +$ JWT_SECRET=a-string-secret-at-least-256-bits-long make run # Enable JWT auth +$ API_KEYS=key1,key2,key3 make run # Enable API key auth +``` + +When enabled, all endpoints require authentication except health checks (`/healthcheck`, `/readycheck`) and gRPC reflection. Swagger UI includes an Authorize button for testing. + +Generate a test JWT token: + +```go +token, _ := auth.GenerateTestToken("a-string-secret-at-least-256-bits-long", "test-user", 1*time.Hour) +``` + +See [Authentication docs](https://docs.coldbrew.cloud/howto/auth/) for details on claims access, RSA/ECDSA keys, and authorization. + ## Logging This project uses `go-coldbrew/log` to manage logging. You can find documentation [here](https://pkg.go.dev/github.com/go-coldbrew/log). diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go index 6ad62a6..b92426e 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth.go +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -16,6 +16,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/go-coldbrew/interceptors" "github.com/golang-jwt/jwt/v5" @@ -169,3 +170,17 @@ func APIKeyAuthFunc(validKeys []string) grpcauth.AuthFunc { return ctx, nil } } + +// GenerateTestToken creates a signed JWT for local development and testing. +// Do not use in production — use a proper identity provider instead. +func GenerateTestToken(secret string, subject string, duration time.Duration) (string, error) { + claims := Claims{ + RegisteredClaims: jwt.RegisteredClaims{ + Subject: subject, + ExpiresAt: jwt.NewNumericDate(time.Now().Add(duration)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} From b2043c21ce5cda8bf089b7a33c273a00804a3010 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 21:04:11 +0800 Subject: [PATCH 10/13] feat: log auth failures, add HTTP_HEADER_PREFIXES for API key - Log auth failures at warn level with method name and error details (auth runs before ResponseTimeLoggingInterceptor so failures were invisible) - Add HTTP_HEADER_PREFIXES=x-api-key to local.env.example for API key forwarding via grpc-gateway --- {{cookiecutter.app_name}}/local.env.example | 2 ++ {{cookiecutter.app_name}}/service/auth/auth.go | 11 +++++++++++ 2 files changed, 13 insertions(+) diff --git a/{{cookiecutter.app_name}}/local.env.example b/{{cookiecutter.app_name}}/local.env.example index 245e599..080b2ae 100644 --- a/{{cookiecutter.app_name}}/local.env.example +++ b/{{cookiecutter.app_name}}/local.env.example @@ -7,3 +7,5 @@ OTLP_INSECURE=true # If both are set, requests can authenticate with either JWT or API key. # JWT_SECRET=a-string-secret-at-least-256-bits-long # API_KEYS=key1,key2,key3 +# Required for API key auth via HTTP/grpc-gateway (forwards x-api-key header to gRPC metadata) +# HTTP_HEADER_PREFIXES=x-api-key diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go index b92426e..3f4d83e 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth.go +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -19,6 +19,7 @@ import ( "time" "github.com/go-coldbrew/interceptors" + "github.com/go-coldbrew/log" "github.com/golang-jwt/jwt/v5" grpcauth "github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/auth" "google.golang.org/grpc" @@ -131,12 +132,16 @@ func JWTAuthFunc(secret string) grpcauth.AuthFunc { return func(ctx context.Context) (context.Context, error) { tokenStr, err := grpcauth.AuthFromMD(ctx, "bearer") if err != nil { + method, _ := grpc.Method(ctx) + log.Warn(ctx, "msg", "jwt auth failed: missing or malformed authorization header", "method", method, "error", err) return nil, err } claims := &Claims{} token, err := jwt.ParseWithClaims(tokenStr, claims, keyFunc, validMethods) if err != nil || !token.Valid { + method, _ := grpc.Method(ctx) + log.Warn(ctx, "msg", "jwt auth failed: invalid token", "method", method, "error", err) return nil, status.Error(codes.Unauthenticated, "invalid token") } @@ -158,13 +163,19 @@ func APIKeyAuthFunc(validKeys []string) grpcauth.AuthFunc { return func(ctx context.Context) (context.Context, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { + method, _ := grpc.Method(ctx) + log.Warn(ctx, "msg", "api key auth failed: missing metadata", "method", method) return nil, status.Error(codes.Unauthenticated, "missing metadata") } keys := md.Get(apiKeyHeader) if len(keys) == 0 { + method, _ := grpc.Method(ctx) + log.Warn(ctx, "msg", "api key auth failed: missing header", "method", method, "header", apiKeyHeader) return nil, status.Errorf(codes.Unauthenticated, "missing %s header", apiKeyHeader) } if _, valid := keySet[keys[0]]; !valid { + method, _ := grpc.Method(ctx) + log.Warn(ctx, "msg", "api key auth failed: invalid key", "method", method) return nil, status.Error(codes.Unauthenticated, "invalid API key") } return ctx, nil From 3749c762cb1be6965c0c7e4991883ebe858d7589 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 21:55:58 +0800 Subject: [PATCH 11/13] fix: address PR review comments (round 3) - Normalize API keys in Setup() before len() check (whitespace-only bypass) - Move auth logging to withAuthLogging/eitherAuthFunc (no noisy logs on fallthrough) - Rename shadowed skip variable to found - Add eitherAuthFunc tests (both-configured path) --- .../service/auth/auth.go | 45 ++++++++++++------- .../service/auth/auth_test.go | 38 ++++++++++++++++ 2 files changed, 67 insertions(+), 16 deletions(-) diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go index 3f4d83e..f9cbf68 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth.go +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -53,15 +53,23 @@ type AuthConfig struct { // Called from main() after config is loaded. If neither JWTSecret nor APIKeys // are set, this is a no-op. func Setup(ctx context.Context, cfg AuthConfig) { + // Normalize API keys — strip whitespace-only entries before checking length + validKeys := make([]string, 0, len(cfg.APIKeys)) + for _, k := range cfg.APIKeys { + if k = strings.TrimSpace(k); k != "" { + validKeys = append(validKeys, k) + } + } + var authFunc grpcauth.AuthFunc switch { - case cfg.JWTSecret != "" && len(cfg.APIKeys) > 0: + case cfg.JWTSecret != "" && len(validKeys) > 0: // Both configured: accept either JWT or API key. - authFunc = eitherAuthFunc(JWTAuthFunc(cfg.JWTSecret), APIKeyAuthFunc(cfg.APIKeys)) + authFunc = eitherAuthFunc(JWTAuthFunc(cfg.JWTSecret), APIKeyAuthFunc(validKeys)) case cfg.JWTSecret != "": - authFunc = JWTAuthFunc(cfg.JWTSecret) - case len(cfg.APIKeys) > 0: - authFunc = APIKeyAuthFunc(cfg.APIKeys) + authFunc = withAuthLogging(JWTAuthFunc(cfg.JWTSecret)) + case len(validKeys) > 0: + authFunc = withAuthLogging(APIKeyAuthFunc(validKeys)) default: return } @@ -76,7 +84,7 @@ func Setup(ctx context.Context, cfg AuthConfig) { func skipMethodsAuthFunc(fn grpcauth.AuthFunc, skip map[string]struct{}) grpcauth.AuthFunc { return func(ctx context.Context) (context.Context, error) { if fullMethod, ok := grpc.Method(ctx); ok { - if _, skip := skip[fullMethod]; skip { + if _, found := skip[fullMethod]; found { return ctx, nil } } @@ -84,8 +92,21 @@ func skipMethodsAuthFunc(fn grpcauth.AuthFunc, skip map[string]struct{}) grpcaut } } +// withAuthLogging wraps an AuthFunc to log failures at warn level. +func withAuthLogging(fn grpcauth.AuthFunc) grpcauth.AuthFunc { + return func(ctx context.Context) (context.Context, error) { + authCtx, err := fn(ctx) + if err != nil { + method, _ := grpc.Method(ctx) + log.Warn(ctx, "msg", "auth failed", "method", method, "error", err) + } + return authCtx, err + } +} + // eitherAuthFunc returns an AuthFunc that succeeds if any of the provided // auth functions succeed. It tries each in order and returns the first success. +// Only logs a warning when all auth methods fail. func eitherAuthFunc(authFuncs ...grpcauth.AuthFunc) grpcauth.AuthFunc { return func(ctx context.Context) (context.Context, error) { var lastErr error @@ -96,6 +117,8 @@ func eitherAuthFunc(authFuncs ...grpcauth.AuthFunc) grpcauth.AuthFunc { } lastErr = err } + method, _ := grpc.Method(ctx) + log.Warn(ctx, "msg", "auth failed: all methods exhausted", "method", method, "error", lastErr) return nil, lastErr } } @@ -132,16 +155,12 @@ func JWTAuthFunc(secret string) grpcauth.AuthFunc { return func(ctx context.Context) (context.Context, error) { tokenStr, err := grpcauth.AuthFromMD(ctx, "bearer") if err != nil { - method, _ := grpc.Method(ctx) - log.Warn(ctx, "msg", "jwt auth failed: missing or malformed authorization header", "method", method, "error", err) return nil, err } claims := &Claims{} token, err := jwt.ParseWithClaims(tokenStr, claims, keyFunc, validMethods) if err != nil || !token.Valid { - method, _ := grpc.Method(ctx) - log.Warn(ctx, "msg", "jwt auth failed: invalid token", "method", method, "error", err) return nil, status.Error(codes.Unauthenticated, "invalid token") } @@ -163,19 +182,13 @@ func APIKeyAuthFunc(validKeys []string) grpcauth.AuthFunc { return func(ctx context.Context) (context.Context, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { - method, _ := grpc.Method(ctx) - log.Warn(ctx, "msg", "api key auth failed: missing metadata", "method", method) return nil, status.Error(codes.Unauthenticated, "missing metadata") } keys := md.Get(apiKeyHeader) if len(keys) == 0 { - method, _ := grpc.Method(ctx) - log.Warn(ctx, "msg", "api key auth failed: missing header", "method", method, "header", apiKeyHeader) return nil, status.Errorf(codes.Unauthenticated, "missing %s header", apiKeyHeader) } if _, valid := keySet[keys[0]]; !valid { - method, _ := grpc.Method(ctx) - log.Warn(ctx, "msg", "api key auth failed: invalid key", "method", method) return nil, status.Error(codes.Unauthenticated, "invalid API key") } return ctx, nil diff --git a/{{cookiecutter.app_name}}/service/auth/auth_test.go b/{{cookiecutter.app_name}}/service/auth/auth_test.go index c2d96b4..b6628b5 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth_test.go +++ b/{{cookiecutter.app_name}}/service/auth/auth_test.go @@ -186,3 +186,41 @@ func (s *fakeStream) Method() string { return s.method } func (s *fakeStream) SetHeader(metadata.MD) error { return nil } func (s *fakeStream) SendHeader(metadata.MD) error { return nil } func (s *fakeStream) SetTrailer(metadata.MD) error { return nil } + +func TestEitherAuthFunc_APIKeyWhenBothConfigured(t *testing.T) { + jwtAuth := JWTAuthFunc(testSecret) + apiKeyAuth := APIKeyAuthFunc([]string{"valid-key"}) + combined := eitherAuthFunc(jwtAuth, apiKeyAuth) + + // API key should work even without JWT + ctx, err := combined(ctxWithAPIKey("valid-key")) + require.NoError(t, err) + assert.NotNil(t, ctx) +} + +func TestEitherAuthFunc_InvalidJWTFallsThrough(t *testing.T) { + jwtAuth := JWTAuthFunc(testSecret) + apiKeyAuth := APIKeyAuthFunc([]string{"valid-key"}) + combined := eitherAuthFunc(jwtAuth, apiKeyAuth) + + // Invalid JWT but valid API key — should succeed via fallthrough + md := metadata.Join( + metadata.Pairs("authorization", "bearer invalid-token"), + metadata.Pairs(apiKeyHeader, "valid-key"), + ) + ctx := metadata.NewIncomingContext(context.Background(), md) + _, err := combined(ctx) + require.NoError(t, err) +} + +func TestEitherAuthFunc_AllFail(t *testing.T) { + jwtAuth := JWTAuthFunc(testSecret) + apiKeyAuth := APIKeyAuthFunc([]string{"valid-key"}) + combined := eitherAuthFunc(jwtAuth, apiKeyAuth) + + // No valid credentials at all + ctx := metadata.NewIncomingContext(context.Background(), metadata.MD{}) + _, err := combined(ctx) + require.Error(t, err) + assert.Equal(t, codes.Unauthenticated, status.Code(err)) +} From 9ee942d5bebcb521da772892820efd1814008b8f Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 22:42:08 +0800 Subject: [PATCH 12/13] fix: TrimSpace JWTSecret, consistent log keys, clarify admin endpoints - TrimSpace cfg.JWTSecret to prevent whitespace-only secrets enabling auth - Use "err" log key consistently (matches template convention) - README: clarify auth applies to gRPC RPCs only, not HTTP admin endpoints --- {{cookiecutter.app_name}}/README.md | 2 +- {{cookiecutter.app_name}}/service/auth/auth.go | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/{{cookiecutter.app_name}}/README.md b/{{cookiecutter.app_name}}/README.md index 8209a85..6c38114 100644 --- a/{{cookiecutter.app_name}}/README.md +++ b/{{cookiecutter.app_name}}/README.md @@ -104,7 +104,7 @@ $ JWT_SECRET=a-string-secret-at-least-256-bits-long make run # Enable JWT auth $ API_KEYS=key1,key2,key3 make run # Enable API key auth ``` -When enabled, all endpoints require authentication except health checks (`/healthcheck`, `/readycheck`) and gRPC reflection. Swagger UI includes an Authorize button for testing. +When enabled, all gRPC RPCs require authentication except health checks, readiness checks, and gRPC reflection. HTTP admin endpoints (`/metrics`, `/debug/pprof/`, `/swagger/`) are not affected by gRPC auth — use `ADMIN_PORT` to isolate them on a separate port. Swagger UI includes an Authorize button for testing. Generate a test JWT token: diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go index f9cbf68..cf686f0 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth.go +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -53,6 +53,8 @@ type AuthConfig struct { // Called from main() after config is loaded. If neither JWTSecret nor APIKeys // are set, this is a no-op. func Setup(ctx context.Context, cfg AuthConfig) { + cfg.JWTSecret = strings.TrimSpace(cfg.JWTSecret) + // Normalize API keys — strip whitespace-only entries before checking length validKeys := make([]string, 0, len(cfg.APIKeys)) for _, k := range cfg.APIKeys { @@ -98,7 +100,7 @@ func withAuthLogging(fn grpcauth.AuthFunc) grpcauth.AuthFunc { authCtx, err := fn(ctx) if err != nil { method, _ := grpc.Method(ctx) - log.Warn(ctx, "msg", "auth failed", "method", method, "error", err) + log.Warn(ctx, "msg", "auth failed", "method", method, "err", err) } return authCtx, err } From 0de9b3716c1c506b1b98e207146500efe5a6c823 Mon Sep 17 00:00:00 2001 From: Ankur Shrivastava Date: Fri, 10 Apr 2026 23:49:39 +0800 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20last=20"error"=20log=20key=20?= =?UTF-8?q?=E2=86=92=20"err"=20in=20eitherAuthFunc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- {{cookiecutter.app_name}}/service/auth/auth.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go index cf686f0..3ce2c93 100644 --- a/{{cookiecutter.app_name}}/service/auth/auth.go +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -120,7 +120,7 @@ func eitherAuthFunc(authFuncs ...grpcauth.AuthFunc) grpcauth.AuthFunc { lastErr = err } method, _ := grpc.Method(ctx) - log.Warn(ctx, "msg", "auth failed: all methods exhausted", "method", method, "error", lastErr) + log.Warn(ctx, "msg", "auth failed: all methods exhausted", "method", method, "err", lastErr) return nil, lastErr } }