diff --git a/{{cookiecutter.app_name}}/AGENTS.md b/{{cookiecutter.app_name}}/AGENTS.md index c99ae66..bc9ab2d 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 interceptors (enabled when JWT_SECRET/API_KEYS are set) +│ └── auth_test.go ├── proto/ │ └── *.proto # Protobuf definitions (source of truth for API) │ └── *.pb.go # GENERATED — do not edit @@ -57,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..6c38114 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 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: + +```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}}/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}}/go.mod b/{{cookiecutter.app_name}}/go.mod index 144ba64..2c438fc 100644 --- a/{{cookiecutter.app_name}}/go.mod +++ b/{{cookiecutter.app_name}}/go.mod @@ -14,11 +14,15 @@ 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.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 @@ -61,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 @@ -85,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 @@ -121,13 +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/interceptors v0.1.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 @@ -142,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 @@ -162,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 @@ -175,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 @@ -212,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 @@ -235,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 @@ -301,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 diff --git a/{{cookiecutter.app_name}}/local.env.example b/{{cookiecutter.app_name}}/local.env.example index 58ed1de..080b2ae 100644 --- a/{{cookiecutter.app_name}}/local.env.example +++ b/{{cookiecutter.app_name}}/local.env.example @@ -2,3 +2,10 @@ ENVIRONMENT="dev" # OpenTelemetry tracing — traces flow to Jaeger when obs profile is running OTLP_ENDPOINT=localhost:4317 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=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}}/main.go b/{{cookiecutter.app_name}}/main.go index 192895d..718484c 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(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 cb := core.New(cfg) 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 + }; } diff --git a/{{cookiecutter.app_name}}/service/auth/auth.go b/{{cookiecutter.app_name}}/service/auth/auth.go new file mode 100644 index 0000000..3ce2c93 --- /dev/null +++ b/{{cookiecutter.app_name}}/service/auth/auth.go @@ -0,0 +1,212 @@ +// 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" + "strings" + "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" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" +) + +const apiKeyHeader = "x-api-key" + +// 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). +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(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 { + if k = strings.TrimSpace(k); k != "" { + validKeys = append(validKeys, k) + } + } + + var authFunc grpcauth.AuthFunc + switch { + case cfg.JWTSecret != "" && len(validKeys) > 0: + // Both configured: accept either JWT or API key. + authFunc = eitherAuthFunc(JWTAuthFunc(cfg.JWTSecret), APIKeyAuthFunc(validKeys)) + case cfg.JWTSecret != "": + authFunc = withAuthLogging(JWTAuthFunc(cfg.JWTSecret)) + case len(validKeys) > 0: + authFunc = withAuthLogging(APIKeyAuthFunc(validKeys)) + default: + return + } + authFunc = skipMethodsAuthFunc(authFunc, defaultSkipMethods) + interceptors.AddUnaryServerInterceptor(ctx, + grpcauth.UnaryServerInterceptor(authFunc)) + interceptors.AddStreamServerInterceptor(ctx, + grpcauth.StreamServerInterceptor(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) { + if fullMethod, ok := grpc.Method(ctx); ok { + if _, found := skip[fullMethod]; found { + return ctx, nil + } + } + return fn(ctx) + } +} + +// 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, "err", 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 + for _, fn := range authFuncs { + authCtx, err := fn(ctx) + if err == nil { + return authCtx, nil + } + lastErr = err + } + method, _ := grpc.Method(ctx) + log.Warn(ctx, "msg", "auth failed: all methods exhausted", "method", method, "err", lastErr) + return nil, lastErr + } +} + +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.Error(codes.Unauthenticated, "invalid token") + } + + 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 { + k = strings.TrimSpace(k) + if k == "" { + continue + } + 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 + } +} + +// 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)) +} 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..b6628b5 --- /dev/null +++ b/{{cookiecutter.app_name}}/service/auth/auth_test.go @@ -0,0 +1,226 @@ +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" + "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)) +} + +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") + } + wrapped := skipMethodsAuthFunc(authFunc, defaultSkipMethods) + + tests := []struct { + method string + skip bool + }{ + // 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 { + 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 } + +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)) +}