From 024a90903ac84277ade60603a38c726b89af5b00 Mon Sep 17 00:00:00 2001 From: rodrigodh Date: Wed, 15 Jul 2026 16:10:46 -0300 Subject: [PATCH 01/22] feat(middleware)!: migrate to Fiber v3, cut /v3 major Bump gofiber/fiber v2->v3.4.0 and cut the /v3 module major. Consume the published betas lib-commons/v6 v6.0.0-beta.2 and lib-observability/v2 v2.0.0-beta.1 (no local replace). Authorize's handler is now func(c fiber.Ctx) (interface by value) and uses c.Context(); merged with develop's product-param rename. Bump golang.org/x/text to v0.39.0 (CVE-2026-56852). BREAKING CHANGE: module path is now github.com/LerianStudio/lib-auth/v3 and the Authorize middleware requires Fiber v3. X-Lerian-Ref: 0x1 --- README.md | 6 +-- auth/middleware/middleware.go | 18 ++++----- auth/middleware/middlewareGRPC.go | 6 +-- auth/middleware/middleware_test.go | 4 +- go.mod | 36 +++++++++-------- go.sum | 62 +++++++++++++++++------------- 6 files changed, 73 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index cafc32d..c334bd7 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Repository: [lib-auth](https://github.com/LerianStudio/lib-auth) ## 📦 Installation ```bash -go get -u github.com/LerianStudio/lib-auth/v2 +go get -u github.com/LerianStudio/lib-auth/v3 ``` ## 🚀 How to Use @@ -37,7 +37,7 @@ logger := zap.InitializeLogger() ``` ```go -import "github.com/LerianStudio/lib-auth/v2/auth/middleware" +import "github.com/LerianStudio/lib-auth/v3/auth/middleware" authClient := middleware.NewAuthClient(cfg.Address, cfg.Enabled, &logger) ``` @@ -99,7 +99,7 @@ Secure a gRPC server with the unary interceptor using per-method policies. It re import ( "context" "google.golang.org/grpc" - "github.com/LerianStudio/lib-auth/v2/auth/middleware" + "github.com/LerianStudio/lib-auth/v3/auth/middleware" ) // Create the auth client once (same as HTTP) diff --git a/auth/middleware/middleware.go b/auth/middleware/middleware.go index 5cc508a..7378aee 100644 --- a/auth/middleware/middleware.go +++ b/auth/middleware/middleware.go @@ -13,16 +13,16 @@ import ( "strings" "time" - observability "github.com/LerianStudio/lib-observability" - "github.com/LerianStudio/lib-observability/log" - "github.com/LerianStudio/lib-observability/tracing" - "github.com/LerianStudio/lib-observability/zap" + observability "github.com/LerianStudio/lib-observability/v2" + "github.com/LerianStudio/lib-observability/v2/log" + "github.com/LerianStudio/lib-observability/v2/tracing" + "github.com/LerianStudio/lib-observability/v2/zap" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/LerianStudio/lib-commons/v5/commons" - libHTTP "github.com/LerianStudio/lib-commons/v5/commons/net/http" - "github.com/gofiber/fiber/v2" + "github.com/LerianStudio/lib-commons/v6/commons" + libHTTP "github.com/LerianStudio/lib-commons/v6/commons/net/http" + "github.com/gofiber/fiber/v3" jwt "github.com/golang-jwt/jwt/v5" ) @@ -215,8 +215,8 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient // product identifies the product/application owning the route (e.g. "midaz"); it builds the M2M role and is forwarded for user-flow isolation. // If the user is authorized, the request is passed to the next handler; otherwise, a 403 Forbidden status is returned. func (auth *AuthClient) Authorize(product, resource, action string) fiber.Handler { - return func(c *fiber.Ctx) error { - ctx := tracing.ExtractHTTPContext(c.UserContext(), c) + return func(c fiber.Ctx) error { + ctx := tracing.ExtractHTTPContext(c.Context(), c) _, tracer, reqID, _ := observability.NewTrackingFromContext(ctx) diff --git a/auth/middleware/middlewareGRPC.go b/auth/middleware/middlewareGRPC.go index 4b1fb79..8ad97f3 100644 --- a/auth/middleware/middlewareGRPC.go +++ b/auth/middleware/middlewareGRPC.go @@ -8,9 +8,9 @@ import ( "os" "strings" - "github.com/LerianStudio/lib-commons/v5/commons" - observability "github.com/LerianStudio/lib-observability" - "github.com/LerianStudio/lib-observability/tracing" + "github.com/LerianStudio/lib-commons/v6/commons" + observability "github.com/LerianStudio/lib-observability/v2" + "github.com/LerianStudio/lib-observability/v2/tracing" jwt "github.com/golang-jwt/jwt/v5" "go.opentelemetry.io/otel/attribute" "google.golang.org/grpc" diff --git a/auth/middleware/middleware_test.go b/auth/middleware/middleware_test.go index bcf05d6..da94495 100644 --- a/auth/middleware/middleware_test.go +++ b/auth/middleware/middleware_test.go @@ -8,8 +8,8 @@ import ( "strings" "testing" - observability "github.com/LerianStudio/lib-observability" - "github.com/LerianStudio/lib-observability/log" + observability "github.com/LerianStudio/lib-observability/v2" + "github.com/LerianStudio/lib-observability/v2/log" jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" diff --git a/go.mod b/go.mod index dc5b6ac..ee5c4f7 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,8 @@ -module github.com/LerianStudio/lib-auth/v2 +module github.com/LerianStudio/lib-auth/v3 go 1.26.3 require ( - github.com/LerianStudio/lib-commons/v5 v5.7.0 - github.com/LerianStudio/lib-observability v1.1.0 - github.com/gofiber/fiber/v2 v2.52.13 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.44.0 @@ -13,14 +10,21 @@ require ( google.golang.org/grpc v1.81.1 ) -require github.com/google/uuid v1.6.0 // indirect +require ( + github.com/gofiber/schema v1.8.0 // indirect + github.com/gofiber/utils/v2 v2.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect + github.com/tinylib/msgp v1.6.4 // indirect +) require ( - github.com/andybalholm/brotli v1.2.1 // indirect + github.com/LerianStudio/lib-commons/v6 v6.0.0-beta.2 + github.com/LerianStudio/lib-observability/v2 v2.0.0-beta.1 + github.com/andybalholm/brotli v1.2.2 // indirect github.com/bxcodec/dbresolver/v2 v2.2.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -29,6 +33,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.30.3 // indirect + github.com/gofiber/fiber/v3 v3.4.0 github.com/golang-migrate/migrate/v4 v4.19.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect @@ -36,12 +41,11 @@ require ( github.com/jackc/pgx/v5 v5.10.0 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/joho/godotenv v1.5.1 // indirect - github.com/klauspost/compress v1.18.6 // indirect + github.com/klauspost/compress v1.19.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/lib/pq v1.12.3 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.22 // indirect - github.com/mattn/go-runewidth v0.0.24 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shopspring/decimal v1.4.0 // indirect @@ -49,7 +53,7 @@ require ( github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.71.0 // indirect + github.com/valyala/fasthttp v1.72.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect github.com/xdg-go/stringprep v1.0.4 // indirect @@ -66,16 +70,16 @@ require ( go.opentelemetry.io/otel/metric v1.44.0 // indirect go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect go.opentelemetry.io/otel/sdk/metric v1.44.0 // indirect - go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/mock v0.6.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.55.0 // indirect - golang.org/x/sync v0.20.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.39.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect google.golang.org/protobuf v1.36.11 // indirect diff --git a/go.sum b/go.sum index 05074c3..a709a4a 100644 --- a/go.sum +++ b/go.sum @@ -4,14 +4,14 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/LerianStudio/lib-commons/v5 v5.7.0 h1:8cxcIBCFBmZ9vEshXW/gJVEO2Ek3UnPOCqxzt3SY0UU= -github.com/LerianStudio/lib-commons/v5 v5.7.0/go.mod h1:QFluaXHnCTWzHhVvh568JFoBn64pCH8roeIurBRZNC4= -github.com/LerianStudio/lib-observability v1.1.0 h1:e/OrIoTKo8gI1RKXgKDyDDKcrddR4beh53al5ZmB6vQ= -github.com/LerianStudio/lib-observability v1.1.0/go.mod h1:PBtmXygWXmcsTnyNLBetyYb/OtsWq6WT9fSJvoppeUQ= +github.com/LerianStudio/lib-commons/v6 v6.0.0-beta.2 h1:UwSvTUe3jDclz2M2huMuYIqVzbJ+WccMKON9rPda+jA= +github.com/LerianStudio/lib-commons/v6 v6.0.0-beta.2/go.mod h1:wJzcspXD+S810PJUJn8SfFXIQgfy0OVFUeEA7QSREbg= +github.com/LerianStudio/lib-observability/v2 v2.0.0-beta.1 h1:jqeROYaFDqdT0d8Wqv0GlbdOkMwvWiMxFKI6H7gGsR8= +github.com/LerianStudio/lib-observability/v2 v2.0.0-beta.1/go.mod h1:gcpmb8FoTsuxUHVgW3sY8s2Pu558T4eDdboEnEowBrI= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= -github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= +github.com/andybalholm/brotli v1.2.2/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/bxcodec/dbresolver/v2 v2.2.1 h1:bjIZm3YXK40dX36qHHj6Vhitj6C1XF88X4d3P3k8Jtw= github.com/bxcodec/dbresolver/v2 v2.2.1/go.mod h1:xWb3HT8vrWUnoLVA7KQ+IcD9RvnzfRBqOkO9rKsg1rQ= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -20,8 +20,6 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= -github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= @@ -49,6 +47,8 @@ github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -67,8 +67,12 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8= github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc= -github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk= -github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofiber/fiber/v3 v3.4.0 h1:F0aND4vwZF7dR7cbvSwFQQEpBU902XHKWxrLsFBkVqw= +github.com/gofiber/fiber/v3 v3.4.0/go.mod h1:nAhJfdxUIJJph2tPWPmqWf8QDIN2iiqQiQf3lENZpdk= +github.com/gofiber/schema v1.8.0 h1:NGsC9toPHmj8Xg4KpznuXBzNmHG6V5YV0tXKpKMcmis= +github.com/gofiber/schema v1.8.0/go.mod h1:lmbXPQ8hvzXSLkdS2DS7pb4kpunC2Roh7Sj3HMjGfzA= +github.com/gofiber/utils/v2 v2.1.1 h1:kGnoGjwEnFW6w0x45W+kLlmMJvqBGkuUA4oMWKn/T/I= +github.com/gofiber/utils/v2 v2.1.1/go.mod h1:DdOgEVwQTi8cou/AKWPqhXOR4fHGRVhA/rEWL3IXG7Q= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= @@ -93,8 +97,8 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= -github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.0 h1:sXLILfc9jV2QYWkzFOPWStmcUVH2RHEB1JCdY2oVvCQ= +github.com/klauspost/compress v1.19.0/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -111,8 +115,6 @@ github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= -github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= -github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= @@ -137,6 +139,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -146,6 +150,8 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/shamaton/msgpack/v3 v3.1.2 h1:d5gWAIyMU4M0WgDjz6IFSCuXJUA2dFwRHBpDclE8CLw= +github.com/shamaton/msgpack/v3 v3.1.2/go.mod h1:DcQG8jrdrQCIxr3HlMYkiXdMhK+KfN2CitkyzsQV4uc= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY= @@ -165,14 +171,18 @@ github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44Xt github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 h1:GCbb1ndrF7OTDiIvxXyItaDab4qkzTFJ48LKFdM7EIo= github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0/go.mod h1:IRPBaI8jXdrNfD0e4Zm7Fbcgaz5shKxOQv4axiL09xs= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/tklauser/go-sysconf v0.4.0 h1:7H0uAN+7RkwWRaxhYXDLqa5V3LPrJeV8wmD9dRUgPQU= github.com/tklauser/go-sysconf v0.4.0/go.mod h1:8mTNWyog7H+MpKijp4VmKJAd2bbYQ2zuUwkYRbUArPI= github.com/tklauser/numcpus v0.12.0 h1:NR85qdvHA9pFse3x3weVZ0r0ST8R6l5RHbZrlRaqob4= github.com/tklauser/numcpus v0.12.0/go.mod h1:ABHeXzJnr/qqwguhClkZKT1/8VABcYrsyUiUGobwWJg= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k= -github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA= +github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M= +github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= @@ -236,18 +246,18 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= -golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -255,16 +265,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus= +golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= From 4597cc147e427fb63ffeca1449bb7322917cd2e4 Mon Sep 17 00:00:00 2001 From: rodrigodh Date: Wed, 15 Jul 2026 16:10:46 -0300 Subject: [PATCH 02/22] chore(ci): accept GO-2026-5932 (unmaintained x/crypto/openpgp) Fiber v3 pulls golang.org/x/crypto via acme/autocert; the openpgp advisory has no fixed version and the package is not imported. X-Lerian-Ref: 0x1 --- .trivyignore | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .trivyignore diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..3554440 --- /dev/null +++ b/.trivyignore @@ -0,0 +1,5 @@ +# GO-2026-5932: golang.org/x/crypto/openpgp is unmaintained (no upstream fix). +# Introduced transitively by gofiber/fiber/v3 -> golang.org/x/crypto/acme/autocert. +# The openpgp package is not imported by this module. Accepted risk. +# Revisit 2026-07-30. +GO-2026-5932 exp:2026-07-30 From c78123e95f3e5ac622158a1cd502a1f62d704162 Mon Sep 17 00:00:00 2001 From: rodrigodh Date: Wed, 15 Jul 2026 16:45:01 -0300 Subject: [PATCH 03/22] docs(middleware): drop go get -u from install example go get -u upgrades all of the consumer's dependencies; pin to @latest so the install snippet only affects lib-auth. (CodeRabbit #121) X-Lerian-Ref: 0x1 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c334bd7..3b65fc3 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Repository: [lib-auth](https://github.com/LerianStudio/lib-auth) ## 📦 Installation ```bash -go get -u github.com/LerianStudio/lib-auth/v3 +go get github.com/LerianStudio/lib-auth/v3@latest ``` ## 🚀 How to Use From 564e71c44b56924aff01565910dd0e29f515e8a0 Mon Sep 17 00:00:00 2001 From: lerian-studio Date: Wed, 15 Jul 2026 19:48:12 +0000 Subject: [PATCH 04/22] chore(release): 3.0.0-beta.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## [3.0.0-beta.1](https://github.com/LerianStudio/lib-auth/compare/v2.9.0...v3.0.0-beta.1) (2026-07-15) ### ⚠ BREAKING CHANGES * **middleware:** module path is now github.com/LerianStudio/lib-auth/v3 and the Authorize middleware requires Fiber v3. X-Lerian-Ref: 0x1 ### Features * **middleware:** migrate to Fiber v3, cut /v3 major ([024a909](https://github.com/LerianStudio/lib-auth/commit/024a90903ac84277ade60603a38c726b89af5b00)) --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 890ef86..424ecc4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## [3.0.0-beta.1](https://github.com/LerianStudio/lib-auth/compare/v2.9.0...v3.0.0-beta.1) (2026-07-15) + + +### ⚠ BREAKING CHANGES + +* **middleware:** module path is now github.com/LerianStudio/lib-auth/v3 and the +Authorize middleware requires Fiber v3. + +X-Lerian-Ref: 0x1 + +### Features + +* **middleware:** migrate to Fiber v3, cut /v3 major ([024a909](https://github.com/LerianStudio/lib-auth/commit/024a90903ac84277ade60603a38c726b89af5b00)) + ## [2.9.0](https://github.com/LerianStudio/lib-auth/compare/v2.8.1...v2.9.0) (2026-06-26) From e31ac531043129eee3394afd88d70cd6ad1b8b7e Mon Sep 17 00:00:00 2001 From: Brecci Date: Thu, 16 Jul 2026 16:14:05 -0300 Subject: [PATCH 05/22] feat: real M2M subject and token type whitelist deriveSubject now switches on the token type. application (M2M) tokens use their real sub claim instead of the fabricated admin/{product}-editor-role; the enforcer ignores that subject and resolves roles dynamically. Any type outside {normal-user, application}, including empty or absent, fails closed with 401 before the authorize call, closing the asymmetry where an empty type fell into the M2M path. normal-user is unchanged; product is forwarded only for normal-user flows. gRPC telemetry mirrors the request body. Part of Item 17 (Tier A), Phase 1. X-Lerian-Ref: 0x1 Co-Authored-By: Claude Opus 4.8 (1M context) --- auth/middleware/middleware.go | 83 +++++++++------ auth/middleware/middlewareGRPC.go | 67 +++++++++--- auth/middleware/middlewareGRPC_test.go | 135 +++++++++++++++++++++++++ auth/middleware/middleware_test.go | 103 ++++++++++++++----- 4 files changed, 317 insertions(+), 71 deletions(-) diff --git a/auth/middleware/middleware.go b/auth/middleware/middleware.go index 5cc508a..0b82e46 100644 --- a/auth/middleware/middleware.go +++ b/auth/middleware/middleware.go @@ -47,8 +47,9 @@ type oauth2Token struct { } const ( - normalUser string = "normal-user" - pluginName string = "plugin-auth" + normalUser string = "normal-user" + application string = "application" + pluginName string = "plugin-auth" ) // sharedHTTPClient is a package-level HTTP client with a custom transport @@ -212,7 +213,8 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient } // Authorize is a middleware function for the Fiber framework that checks if a user is authorized to perform a specific action on a resource. -// product identifies the product/application owning the route (e.g. "midaz"); it builds the M2M role and is forwarded for user-flow isolation. +// product identifies the product/application owning the route (e.g. "midaz"); it is forwarded only for normal-user flows so the auth service can +// isolate permissions by product. M2M (application) tokens carry no product isolation and are identified by their own subject claim. // If the user is authorized, the request is passed to the next handler; otherwise, a 403 Forbidden status is returned. func (auth *AuthClient) Authorize(product, resource, action string) fiber.Handler { return func(c *fiber.Ctx) error { @@ -261,42 +263,63 @@ func (auth *AuthClient) Authorize(product, resource, action string) fiber.Handle } } -// checkAuthorization sends an authorization request to the external service and returns whether the action is authorized. -// product identifies the product/plugin owning the route. The subject is derived from it: M2M tokens map to the product's -// editor role, while normal users are identified by their JWT (owner/userId) and the product is forwarded so the auth -// service can isolate permissions by product. Empty product keeps the previous behavior. -// deriveSubject builds the authorization subject from the token claims. -// For M2M tokens it is the product's editor role ("admin/-editor-role"); -// for normal-user tokens it is the JWT identity ("/"), failing -// closed with 401 when the owner or sub claim is missing. -func (auth *AuthClient) deriveSubject(ctx context.Context, span trace.Span, claims jwt.MapClaims, userType, product string) (string, int, error) { - if userType != normalUser { - return fmt.Sprintf("admin/%s-editor-role", product), http.StatusOK, nil - } +// deriveSubject builds the authorization subject from the token claims based on +// the whitelisted token type. It fails closed (401) for any type outside +// {normal-user, application}: +// - normal-user: the JWT identity ("/"), failing closed with 401 +// when the owner or sub claim is missing. +// - application (M2M): the token's own sub claim (already in "/" +// form), failing closed with 401 when sub is missing. No product-scoped role +// is fabricated, since M2M has no product isolation. +// - any other type (including empty/absent): rejected with 401. +func (auth *AuthClient) deriveSubject(ctx context.Context, span trace.Span, claims jwt.MapClaims, userType string) (string, int, error) { + switch userType { + case normalUser: + owner, _ := claims["owner"].(string) + if owner == "" { + logErrorf(ctx, auth.Logger, "Missing owner claim in token") + + err := errors.New("missing owner claim in token") + + tracing.HandleSpanError(span, "Missing owner claim in token", err) + + return "", http.StatusUnauthorized, err + } - owner, _ := claims["owner"].(string) - if owner == "" { - logErrorf(ctx, auth.Logger, "Missing owner claim in token") + userID, _ := claims["sub"].(string) + if userID == "" { + logErrorf(ctx, auth.Logger, "Missing sub claim in token") - err := errors.New("missing owner claim in token") + err := errors.New("missing sub claim in token") - tracing.HandleSpanError(span, "Missing owner claim in token", err) + tracing.HandleSpanError(span, "Missing sub claim in token", err) - return "", http.StatusUnauthorized, err - } + return "", http.StatusUnauthorized, err + } - userID, _ := claims["sub"].(string) - if userID == "" { - logErrorf(ctx, auth.Logger, "Missing sub claim in token") + return fmt.Sprintf("%s/%s", owner, userID), http.StatusOK, nil + case application: + sub, _ := claims["sub"].(string) + if sub == "" { + logErrorf(ctx, auth.Logger, "Missing sub claim in application token") - err := errors.New("missing sub claim in token") + err := errors.New("missing sub claim in token") - tracing.HandleSpanError(span, "Missing sub claim in token", err) + tracing.HandleSpanError(span, "Missing sub claim in application token", err) + + return "", http.StatusUnauthorized, err + } + + return sub, http.StatusOK, nil + default: + logErrorf(ctx, auth.Logger, "Unsupported token type: %q", userType) + + err := errors.New("unsupported token type") + + tracing.HandleSpanError(span, "Unsupported token type", err) return "", http.StatusUnauthorized, err } - - return fmt.Sprintf("%s/%s", owner, userID), http.StatusOK, nil } func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resource, action, accessToken string) (bool, int, error) { @@ -333,7 +356,7 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resourc userType, _ := claims["type"].(string) - sub, statusCode, err := auth.deriveSubject(ctx, span, claims, userType, product) + sub, statusCode, err := auth.deriveSubject(ctx, span, claims, userType) if err != nil { return false, statusCode, err } diff --git a/auth/middleware/middlewareGRPC.go b/auth/middleware/middlewareGRPC.go index 4b1fb79..fae4011 100644 --- a/auth/middleware/middlewareGRPC.go +++ b/auth/middleware/middlewareGRPC.go @@ -27,12 +27,13 @@ type Policy struct { } // PolicyConfig binds gRPC methods to Policies and optional product resolution. -// - MethodPolicies keyed by info.FullMethod ("/pkg.Service/Method"). -// - DefaultPolicy used when a method mapping is absent. -// - SubResolver derives the product identifier (e.g., "midaz") that is forwarded -// to checkAuthorization as its product argument. For M2M tokens it becomes the -// subject "admin/-editor-role"; for normal-user tokens it is forwarded -// for product isolation. Return "" when not applicable. +// - MethodPolicies keyed by info.FullMethod ("/pkg.Service/Method"). +// - DefaultPolicy used when a method mapping is absent. +// - SubResolver derives the product identifier (e.g., "midaz") passed to +// checkAuthorization as its product argument. It is forwarded only for +// normal-user tokens (product isolation); M2M (application) tokens carry no +// product isolation and are identified by their own subject claim. Return "" +// when not applicable. type PolicyConfig struct { MethodPolicies map[string]Policy DefaultPolicy *Policy @@ -45,8 +46,10 @@ type PolicyConfig struct { // - Optionally derives the product using cfg.SubResolver (e.g., "midaz"). Empty product is valid. // - Rejects missing tokens with codes.Unauthenticated; misconfiguration returns codes.Internal. // Telemetry: -// - Sets app.request.request_id. -// - Sets app.request.payload with {product, resource, action} per standard. +// - Sets app.request.request_id. +// - Sets app.request.payload with {resource, action}, including product only when +// it is actually forwarded to the auth service (normal-user flows with a +// non-empty product), mirroring the request body. func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { if auth == nil || !auth.Enabled || auth.Address == "" { @@ -73,7 +76,8 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer } // product is the resolved product identifier passed as checkAuthorization's - // product argument (M2M subject base and normal-user isolation key). + // product argument. It is forwarded only for normal-user flows; M2M + // (application) tokens carry no product isolation. var product string if cfg.SubResolver != nil { @@ -87,11 +91,7 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer } } - payload := map[string]string{ - "product": product, - "resource": pol.Resource, - "action": pol.Action, - } + payload := authPayload(token, product, pol.Resource, pol.Action) if err := tracing.SetSpanAttributesFromValue(span, "app.request.payload", payload, nil); err != nil { tracing.HandleSpanError(span, "failed to set span payload", err) } @@ -209,6 +209,42 @@ func SubFromMetadata(key string) func(ctx context.Context, fullMethod string, re } } +// authPayload builds the telemetry payload mirroring the request body sent to the +// auth service by checkAuthorization: product is included only when it is actually +// forwarded (normal-user flows with a non-empty product). +func authPayload(token, product, resource, action string) map[string]string { + payload := map[string]string{ + "resource": resource, + "action": action, + } + + if tokenTypeClaim(token) == normalUser && product != "" { + payload["product"] = product + } + + return payload +} + +// tokenTypeClaim returns the "type" claim from an unverified JWT, or "" when the +// token cannot be parsed. Best-effort and telemetry-only: the authorization +// decision (and its fail-closed handling) still goes through checkAuthorization, +// which re-parses and validates the token. +func tokenTypeClaim(tokenString string) string { + token, _, err := new(jwt.Parser).ParseUnverified(tokenString, jwt.MapClaims{}) + if err != nil { + return "" + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + return "" + } + + t, _ := claims["type"].(string) + + return t +} + // extractTenantClaims extracts tenant-related claims from a JWT without signature verification. // Returns tenantID, tenantSlug, and owner from the token's custom claims. // Used by gRPC interceptors to propagate tenant context to downstream services. @@ -254,7 +290,8 @@ func NewGRPCAuthStreamPolicy(auth *AuthClient, cfg PolicyConfig) grpc.StreamServ } // product is the resolved product identifier passed as checkAuthorization's - // product argument (M2M subject base and normal-user isolation key). + // product argument. It is forwarded only for normal-user flows; M2M + // (application) tokens carry no product isolation. var product string if cfg.SubResolver != nil { diff --git a/auth/middleware/middlewareGRPC_test.go b/auth/middleware/middlewareGRPC_test.go index e092375..a89ba97 100644 --- a/auth/middleware/middlewareGRPC_test.go +++ b/auth/middleware/middlewareGRPC_test.go @@ -2,7 +2,9 @@ package middleware import ( "context" + "encoding/json" "net/http" + "net/http/httptest" "testing" jwt "github.com/golang-jwt/jwt/v5" @@ -749,6 +751,139 @@ func TestNewGRPCAuthUnaryPolicy_TenantPropagation(t *testing.T) { }) } +// --------------------------------------------------------------------------- +// authPayload - product gating in telemetry +// --------------------------------------------------------------------------- + +func TestAuthPayload_ProductGating(t *testing.T) { + t.Parallel() + + t.Run("normal_user_with_product_includes_product", func(t *testing.T) { + t.Parallel() + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "acme-org", + "sub": "user123", + }) + + payload := authPayload(token, "midaz", "resource", "read") + + // resource/action are always present. + assert.Equal(t, "resource", payload["resource"]) + assert.Equal(t, "read", payload["action"]) + // product is forwarded for normal-user flows with a non-empty product. + assert.Equal(t, "midaz", payload["product"]) + }) + + t.Run("application_omits_product", func(t *testing.T) { + t.Parallel() + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + payload := authPayload(token, "midaz", "resource", "read") + + assert.Equal(t, "resource", payload["resource"]) + assert.Equal(t, "read", payload["action"]) + // M2M carries no product isolation, so product is not recorded. + _, hasProduct := payload["product"] + assert.False(t, hasProduct) + }) + + t.Run("normal_user_with_empty_product_omits_product", func(t *testing.T) { + t.Parallel() + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "acme-org", + "sub": "user123", + }) + + payload := authPayload(token, "", "resource", "read") + + assert.Equal(t, "resource", payload["resource"]) + assert.Equal(t, "read", payload["action"]) + // Empty product is never forwarded. + _, hasProduct := payload["product"] + assert.False(t, hasProduct) + }) +} + +// --------------------------------------------------------------------------- +// NewGRPCAuthUnaryPolicy - M2M subject construction +// --------------------------------------------------------------------------- + +func TestNewGRPCAuthUnaryPolicy_ApplicationSubject(t *testing.T) { + t.Parallel() + + // An application (M2M) token flowing through the gRPC unary path must be + // identified by its real sub claim, with product not forwarded in the body. + var capturedBody map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { + t.Errorf("mock server: failed to decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if err := json.NewEncoder(w).Encode(AuthResponse{Authorized: true}); err != nil { + t.Errorf("mock server: failed to encode response: %v", err) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + defaultPol := Policy{Resource: "res", Action: "read"} + cfg := PolicyConfig{ + DefaultPolicy: &defaultPol, + SubResolver: func(_ context.Context, _ string, _ any) (string, error) { + return "midaz", nil + }, + } + interceptor := NewGRPCAuthUnaryPolicy(auth, cfg) + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + called := false + handler := func(_ context.Context, _ any) (any, error) { + called = true + return "ok", nil + } + + info := &grpc.UnaryServerInfo{FullMethod: "/pkg.Service/DoThing"} + + resp, err := interceptor(ctx, "req", info, handler) + require.NoError(t, err) + assert.Equal(t, "ok", resp) + assert.True(t, called) + + // Subject is the real sub of the application token. + assert.Equal(t, "acme-org/my-app", capturedBody["sub"]) + // Product is not forwarded for M2M tokens. + _, hasProduct := capturedBody["product"] + assert.False(t, hasProduct) +} + // --------------------------------------------------------------------------- // NewGRPCAuthStreamPolicy // --------------------------------------------------------------------------- diff --git a/auth/middleware/middleware_test.go b/auth/middleware/middleware_test.go index bcf05d6..15eecc2 100644 --- a/auth/middleware/middleware_test.go +++ b/auth/middleware/middleware_test.go @@ -118,7 +118,8 @@ func TestCheckAuthorization_NormalUser_SubjectConstruction(t *testing.T) { func TestCheckAuthorization_ApplicationUser_SubjectConstruction(t *testing.T) { t.Parallel() - // Documents the current behavior: non-normal-user types get "admin/-editor-role". + // Application (M2M) tokens are identified by their real sub claim (already in + // "owner/name" form); no product-editor-role is fabricated and product is not forwarded. var capturedBody map[string]string server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -159,8 +160,8 @@ func TestCheckAuthorization_ApplicationUser_SubjectConstruction(t *testing.T) { assert.True(t, authorized) assert.Equal(t, http.StatusOK, statusCode) - // For M2M, the subject is built from the product: "admin/-editor-role". - assert.Equal(t, "admin/my-app-editor-role", capturedBody["sub"]) + // For M2M, the subject is the real sub claim of the application token. + assert.Equal(t, "app-sub", capturedBody["sub"]) // Product is NOT forwarded for non-normal-user tokens. _, hasProduct := capturedBody["product"] assert.False(t, hasProduct) @@ -401,28 +402,45 @@ func TestCheckAuthorization_InvalidToken(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, statusCode) } -func TestCheckAuthorization_EmptyTypeClaim_TreatedAsNonNormalUser(t *testing.T) { +func TestCheckAuthorization_EmptyTypeClaim_Rejected(t *testing.T) { t.Parallel() - // When the "type" claim is empty or absent, userType != normalUser, - // so the code takes the admin/ branch. - var capturedBody map[string]string + // When the "type" claim is empty or absent it is not in the whitelist + // {normal-user, application}, so the request must fail closed with 401 and + // the auth backend must never be reached. + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Errorf("auth backend must not be called when the type claim is absent") + })) + defer server.Close() - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - err := json.NewDecoder(r.Body).Decode(&capturedBody) - if err != nil { - t.Errorf("mock server: failed to decode request body: %v", err) - } + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) + // No "type" claim at all -> defaults to empty string -> not whitelisted. + token := createTestJWT(jwt.MapClaims{ + "sub": "some-app", + }) - resp := AuthResponse{Authorized: true} + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "some-app", "resource", "action", token, + ) - encErr := json.NewEncoder(w).Encode(resp) - if encErr != nil { - t.Errorf("mock server: failed to encode response: %v", encErr) - } + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusUnauthorized, statusCode) + assert.Contains(t, err.Error(), "unsupported token type") +} + +func TestCheckAuthorization_ApplicationUser_MissingSubClaim_FailsClosed(t *testing.T) { + t.Parallel() + + // An application token without a "sub" claim must fail closed with 401 before + // the auth backend is reached. + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Errorf("auth backend must not be called when the application sub claim is missing") })) defer server.Close() @@ -432,19 +450,52 @@ func TestCheckAuthorization_EmptyTypeClaim_TreatedAsNonNormalUser(t *testing.T) Logger: &testLogger{}, } - // No "type" claim at all -> defaults to empty string -> non-normal-user path token := createTestJWT(jwt.MapClaims{ - "sub": "some-app", + "type": "application", + "name": "my-app", + // "sub" is intentionally missing }) authorized, statusCode, err := auth.checkAuthorization( - context.Background(), "some-app", "resource", "action", token, + context.Background(), "my-app", "resource", "action", token, ) - require.NoError(t, err) - assert.True(t, authorized) - assert.Equal(t, http.StatusOK, statusCode) - assert.Equal(t, "admin/some-app-editor-role", capturedBody["sub"]) + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusUnauthorized, statusCode) + assert.Contains(t, err.Error(), "missing sub claim") +} + +func TestCheckAuthorization_NonCanonicalType_Rejected(t *testing.T) { + t.Parallel() + + // Any type outside the whitelist {normal-user, application} must fail closed + // with 401 and never reach the auth backend. + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Errorf("auth backend must not be called for a non-canonical token type") + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "service-account", + "owner": "acme-org", + "sub": "svc-1", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "action", token, + ) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusUnauthorized, statusCode) + assert.Contains(t, err.Error(), "unsupported token type") } func TestCheckAuthorization_MockServerDown(t *testing.T) { From e7dfa91de553e01e8ea362940a5dc89350a4fa64 Mon Sep 17 00:00:00 2001 From: lerian-studio Date: Thu, 16 Jul 2026 21:16:19 +0000 Subject: [PATCH 06/22] chore(release): 3.0.0-beta.2 ## [3.0.0-beta.2](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2026-07-16) ### Features * real M2M subject and token type whitelist ([e31ac53](https://github.com/LerianStudio/lib-auth/commit/e31ac531043129eee3394afd88d70cd6ad1b8b7e)) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 424ecc4..44847e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [3.0.0-beta.2](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2026-07-16) + + +### Features + +* real M2M subject and token type whitelist ([e31ac53](https://github.com/LerianStudio/lib-auth/commit/e31ac531043129eee3394afd88d70cd6ad1b8b7e)) + ## [3.0.0-beta.1](https://github.com/LerianStudio/lib-auth/compare/v2.9.0...v3.0.0-beta.1) (2026-07-15) From 93c44dbc7155db5a4d5e857139e9b35d7ba5847f Mon Sep 17 00:00:00 2001 From: Brecci Date: Fri, 17 Jul 2026 18:26:20 -0300 Subject: [PATCH 07/22] feat(middleware): forward product on M2M auth (flag-gated) M2M (application-token) authorization calls sent no product, so once Custom permission resources are prefixed with "{product}/" (Item 17 F2), a bare M2M request stops matching the stored resource and the auth service denies it (403). Forward the route product for application tokens too, gated by AUTH_M2M_PRODUCT_FORWARD_ENABLED (default off) so deploy != release: prefix isolation activates by flipping the flag, with no consumer code change. Normal-user behavior is unchanged. A shared shouldForwardProduct helper keeps the request body and telemetry payload in lockstep. X-Lerian-Ref: 0x1 Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 6 ++ auth/middleware/middleware.go | 50 ++++++--- auth/middleware/middlewareGRPC.go | 27 ++--- auth/middleware/middlewareGRPC_test.go | 115 +++++++++++++++++++-- auth/middleware/middleware_test.go | 137 ++++++++++++++++++++++++- 5 files changed, 303 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 3b65fc3..2349735 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,12 @@ In your environment configuration or `.env` file, set the following environment ```dotenv PLUGIN_AUTH_ADDRESS=http://localhost:4000 PLUGIN_AUTH_ENABLED=true + +# Optional. When "true", the client also forwards the route product on M2M +# (application-token) authorization calls, so the auth service can isolate +# permissions by product (matching product-prefixed resources). Defaults to +# false, preserving the previous behavior of sending no product for M2M. +AUTH_M2M_PRODUCT_FORWARD_ENABLED=false ``` ### 2. Create a new instance of the middleware: diff --git a/auth/middleware/middleware.go b/auth/middleware/middleware.go index b572ee5..ee55324 100644 --- a/auth/middleware/middleware.go +++ b/auth/middleware/middleware.go @@ -30,6 +30,15 @@ type AuthClient struct { Address string Enabled bool Logger log.Logger + + // ForwardM2MProduct, when true, forwards the route product on M2M + // (application-token) authorization calls, letting the auth service strip the + // "{product}/" prefix from stored resources and dual-match a bare request. + // It is read once from AUTH_M2M_PRODUCT_FORWARD_ENABLED at construction; the + // default (false) preserves the prior behavior of sending no product for M2M. + // Gating it by env keeps deploy != release: flipping the flag activates M2M + // product isolation without a code change in consumers. + ForwardM2MProduct bool } type AuthResponse struct { @@ -165,11 +174,14 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient } } + forwardM2MProduct := os.Getenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED") == "true" + if !enabled || address == "" { return &AuthClient{ - Address: address, - Enabled: enabled, - Logger: l, + Address: address, + Enabled: enabled, + Logger: l, + ForwardM2MProduct: forwardM2MProduct, } } @@ -182,21 +194,21 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient if err != nil { logErrorf(context.Background(), l, failedToConnectMsg, err) - return &AuthClient{Address: address, Enabled: enabled, Logger: l} + return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct} } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { logErrorf(context.Background(), l, failedToConnectMsg, resp.Status) - return &AuthClient{Address: address, Enabled: enabled, Logger: l} + return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct} } body, err := io.ReadAll(resp.Body) if err != nil { logErrorf(context.Background(), l, "Failed to read response body: %v", err) - return &AuthClient{Address: address, Enabled: enabled, Logger: l} + return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct} } if string(body) == "healthy" { @@ -206,15 +218,16 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient } return &AuthClient{ - Address: address, - Enabled: enabled, - Logger: l, + Address: address, + Enabled: enabled, + Logger: l, + ForwardM2MProduct: forwardM2MProduct, } } // Authorize is a middleware function for the Fiber framework that checks if a user is authorized to perform a specific action on a resource. -// product identifies the product/application owning the route (e.g. "midaz"); it is forwarded only for normal-user flows so the auth service can -// isolate permissions by product. M2M (application) tokens carry no product isolation and are identified by their own subject claim. +// product identifies the product/application owning the route (e.g. "midaz"); it is forwarded for normal-user flows, and for M2M (application) +// flows when AUTH_M2M_PRODUCT_FORWARD_ENABLED is set, so the auth service can isolate permissions by product. M2M tokens are identified by their own subject claim. // If the user is authorized, the request is passed to the next handler; otherwise, a 403 Forbidden status is returned. func (auth *AuthClient) Authorize(product, resource, action string) fiber.Handler { return func(c fiber.Ctx) error { @@ -322,6 +335,19 @@ func (auth *AuthClient) deriveSubject(ctx context.Context, span trace.Span, clai } } +// shouldForwardProduct reports whether the route product must be forwarded to the +// auth service so it can isolate permissions by product (strip the "{product}/" +// prefix from stored resources and dual-match a bare request). It is forwarded for +// normal-user flows, and for M2M (application) flows when forwardM2MProduct is +// enabled; an empty product is never forwarded (gate-by-presence). +func shouldForwardProduct(userType, product string, forwardM2MProduct bool) bool { + if product == "" { + return false + } + + return userType == normalUser || (userType == application && forwardM2MProduct) +} + func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resource, action, accessToken string) (bool, int, error) { _, tracer, reqID, _ := observability.NewTrackingFromContext(ctx) @@ -367,7 +393,7 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resourc "action": action, } - if userType == normalUser && product != "" { + if shouldForwardProduct(userType, product, auth.ForwardM2MProduct) { requestBody["product"] = product } diff --git a/auth/middleware/middlewareGRPC.go b/auth/middleware/middlewareGRPC.go index b032e5a..41d6f54 100644 --- a/auth/middleware/middlewareGRPC.go +++ b/auth/middleware/middlewareGRPC.go @@ -30,9 +30,9 @@ type Policy struct { // - MethodPolicies keyed by info.FullMethod ("/pkg.Service/Method"). // - DefaultPolicy used when a method mapping is absent. // - SubResolver derives the product identifier (e.g., "midaz") passed to -// checkAuthorization as its product argument. It is forwarded only for -// normal-user tokens (product isolation); M2M (application) tokens carry no -// product isolation and are identified by their own subject claim. Return "" +// checkAuthorization as its product argument. It is forwarded for normal-user +// tokens, and for M2M (application) tokens when AUTH_M2M_PRODUCT_FORWARD_ENABLED +// is set; M2M tokens are identified by their own subject claim. Return "" // when not applicable. type PolicyConfig struct { MethodPolicies map[string]Policy @@ -48,8 +48,8 @@ type PolicyConfig struct { // Telemetry: // - Sets app.request.request_id. // - Sets app.request.payload with {resource, action}, including product only when -// it is actually forwarded to the auth service (normal-user flows with a -// non-empty product), mirroring the request body. +// it is actually forwarded to the auth service (normal-user, or M2M when +// AUTH_M2M_PRODUCT_FORWARD_ENABLED is set), mirroring the request body. func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { if auth == nil || !auth.Enabled || auth.Address == "" { @@ -76,8 +76,8 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer } // product is the resolved product identifier passed as checkAuthorization's - // product argument. It is forwarded only for normal-user flows; M2M - // (application) tokens carry no product isolation. + // product argument. It is forwarded for normal-user flows, and for M2M + // (application) flows when AUTH_M2M_PRODUCT_FORWARD_ENABLED is set. var product string if cfg.SubResolver != nil { @@ -91,7 +91,7 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer } } - payload := authPayload(token, product, pol.Resource, pol.Action) + payload := authPayload(token, product, pol.Resource, pol.Action, auth.ForwardM2MProduct) if err := tracing.SetSpanAttributesFromValue(span, "app.request.payload", payload, nil); err != nil { tracing.HandleSpanError(span, "failed to set span payload", err) } @@ -211,14 +211,15 @@ func SubFromMetadata(key string) func(ctx context.Context, fullMethod string, re // authPayload builds the telemetry payload mirroring the request body sent to the // auth service by checkAuthorization: product is included only when it is actually -// forwarded (normal-user flows with a non-empty product). -func authPayload(token, product, resource, action string) map[string]string { +// forwarded — normal-user flows with a non-empty product, or M2M (application) +// flows with a non-empty product when forwardM2MProduct is enabled. +func authPayload(token, product, resource, action string, forwardM2MProduct bool) map[string]string { payload := map[string]string{ "resource": resource, "action": action, } - if tokenTypeClaim(token) == normalUser && product != "" { + if shouldForwardProduct(tokenTypeClaim(token), product, forwardM2MProduct) { payload["product"] = product } @@ -290,8 +291,8 @@ func NewGRPCAuthStreamPolicy(auth *AuthClient, cfg PolicyConfig) grpc.StreamServ } // product is the resolved product identifier passed as checkAuthorization's - // product argument. It is forwarded only for normal-user flows; M2M - // (application) tokens carry no product isolation. + // product argument. It is forwarded for normal-user flows, and for M2M + // (application) flows when AUTH_M2M_PRODUCT_FORWARD_ENABLED is set. var product string if cfg.SubResolver != nil { diff --git a/auth/middleware/middlewareGRPC_test.go b/auth/middleware/middlewareGRPC_test.go index a89ba97..a6f942f 100644 --- a/auth/middleware/middlewareGRPC_test.go +++ b/auth/middleware/middlewareGRPC_test.go @@ -767,7 +767,7 @@ func TestAuthPayload_ProductGating(t *testing.T) { "sub": "user123", }) - payload := authPayload(token, "midaz", "resource", "read") + payload := authPayload(token, "midaz", "resource", "read", false) // resource/action are always present. assert.Equal(t, "resource", payload["resource"]) @@ -776,7 +776,7 @@ func TestAuthPayload_ProductGating(t *testing.T) { assert.Equal(t, "midaz", payload["product"]) }) - t.Run("application_omits_product", func(t *testing.T) { + t.Run("application_flag_off_omits_product", func(t *testing.T) { t.Parallel() token := createTestJWT(jwt.MapClaims{ @@ -785,11 +785,46 @@ func TestAuthPayload_ProductGating(t *testing.T) { "sub": "acme-org/my-app", }) - payload := authPayload(token, "midaz", "resource", "read") + payload := authPayload(token, "midaz", "resource", "read", false) assert.Equal(t, "resource", payload["resource"]) assert.Equal(t, "read", payload["action"]) - // M2M carries no product isolation, so product is not recorded. + // With the flag off, M2M forwards no product isolation, so it is not recorded. + _, hasProduct := payload["product"] + assert.False(t, hasProduct) + }) + + t.Run("application_flag_on_includes_product", func(t *testing.T) { + t.Parallel() + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + payload := authPayload(token, "midaz", "resource", "read", true) + + assert.Equal(t, "resource", payload["resource"]) + assert.Equal(t, "read", payload["action"]) + // With the flag on, M2M forwards the product, so telemetry mirrors it. + assert.Equal(t, "midaz", payload["product"]) + }) + + t.Run("application_flag_on_empty_product_omits_product", func(t *testing.T) { + t.Parallel() + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + payload := authPayload(token, "", "resource", "read", true) + + assert.Equal(t, "resource", payload["resource"]) + assert.Equal(t, "read", payload["action"]) + // Empty product is never forwarded, even with the flag on. _, hasProduct := payload["product"] assert.False(t, hasProduct) }) @@ -803,7 +838,7 @@ func TestAuthPayload_ProductGating(t *testing.T) { "sub": "user123", }) - payload := authPayload(token, "", "resource", "read") + payload := authPayload(token, "", "resource", "read", false) assert.Equal(t, "resource", payload["resource"]) assert.Equal(t, "read", payload["action"]) @@ -879,11 +914,79 @@ func TestNewGRPCAuthUnaryPolicy_ApplicationSubject(t *testing.T) { // Subject is the real sub of the application token. assert.Equal(t, "acme-org/my-app", capturedBody["sub"]) - // Product is not forwarded for M2M tokens. + // Product is not forwarded for M2M tokens when the flag is off (default). _, hasProduct := capturedBody["product"] assert.False(t, hasProduct) } +func TestNewGRPCAuthUnaryPolicy_ApplicationSubject_ForwardM2MProductEnabled(t *testing.T) { + t.Parallel() + + // With ForwardM2MProduct enabled, an application (M2M) token flowing through the + // gRPC unary path must forward the resolved product in the request body so the + // auth service can dual-match the "{product}/" prefix on stored resources. + var capturedBody map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&capturedBody); err != nil { + t.Errorf("mock server: failed to decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + if err := json.NewEncoder(w).Encode(AuthResponse{Authorized: true}); err != nil { + t.Errorf("mock server: failed to encode response: %v", err) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + ForwardM2MProduct: true, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + defaultPol := Policy{Resource: "res", Action: "read"} + cfg := PolicyConfig{ + DefaultPolicy: &defaultPol, + SubResolver: func(_ context.Context, _ string, _ any) (string, error) { + return "midaz", nil + }, + } + interceptor := NewGRPCAuthUnaryPolicy(auth, cfg) + + ctx := metadata.NewIncomingContext( + context.Background(), + metadata.Pairs("authorization", "Bearer "+token), + ) + + called := false + handler := func(_ context.Context, _ any) (any, error) { + called = true + return "ok", nil + } + + info := &grpc.UnaryServerInfo{FullMethod: "/pkg.Service/DoThing"} + + resp, err := interceptor(ctx, "req", info, handler) + require.NoError(t, err) + assert.Equal(t, "ok", resp) + assert.True(t, called) + + // Subject is the real sub of the application token. + assert.Equal(t, "acme-org/my-app", capturedBody["sub"]) + // Product IS forwarded for M2M tokens when the flag is on. + assert.Equal(t, "midaz", capturedBody["product"]) +} + // --------------------------------------------------------------------------- // NewGRPCAuthStreamPolicy // --------------------------------------------------------------------------- diff --git a/auth/middleware/middleware_test.go b/auth/middleware/middleware_test.go index bfaae7f..948f3a1 100644 --- a/auth/middleware/middleware_test.go +++ b/auth/middleware/middleware_test.go @@ -162,7 +162,110 @@ func TestCheckAuthorization_ApplicationUser_SubjectConstruction(t *testing.T) { // For M2M, the subject is the real sub claim of the application token. assert.Equal(t, "app-sub", capturedBody["sub"]) - // Product is NOT forwarded for non-normal-user tokens. + // Product is NOT forwarded for application tokens when ForwardM2MProduct is off (default). + _, hasProduct := capturedBody["product"] + assert.False(t, hasProduct) +} + +func TestCheckAuthorization_Application_ForwardM2MProductEnabled_ForwardsProduct(t *testing.T) { + t.Parallel() + + // With ForwardM2MProduct enabled, an application (M2M) token forwards the route + // product so the auth service can strip the "{product}/" prefix from stored + // resources and dual-match a bare request. The subject stays the real sub claim. + var capturedBody map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&capturedBody) + if err != nil { + t.Errorf("mock server: failed to decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := AuthResponse{Authorized: true} + + encErr := json.NewEncoder(w).Encode(resp) + if encErr != nil { + t.Errorf("mock server: failed to encode response: %v", encErr) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + ForwardM2MProduct: true, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "action", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) + + // Subject is still the real sub of the application token. + assert.Equal(t, "acme-org/my-app", capturedBody["sub"]) + // Product IS forwarded for M2M when ForwardM2MProduct is enabled. + assert.Equal(t, "midaz", capturedBody["product"]) +} + +func TestCheckAuthorization_Application_ForwardM2MProductEnabled_EmptyProduct_NotForwarded(t *testing.T) { + t.Parallel() + + // Even with ForwardM2MProduct enabled, an empty product is never forwarded + // (gate-by-presence preserved). + var capturedBody map[string]string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + err := json.NewDecoder(r.Body).Decode(&capturedBody) + if err != nil { + t.Errorf("mock server: failed to decode request body: %v", err) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + + resp := AuthResponse{Authorized: true} + + encErr := json.NewEncoder(w).Encode(resp) + if encErr != nil { + t.Errorf("mock server: failed to encode response: %v", encErr) + } + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + ForwardM2MProduct: true, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "application", + "name": "my-app", + "sub": "acme-org/my-app", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "", "resource", "action", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) + _, hasProduct := capturedBody["product"] assert.False(t, hasProduct) } @@ -560,6 +663,38 @@ func TestCheckAuthorization_ServerReturnsInvalidJSON(t *testing.T) { assert.Contains(t, err.Error(), "failed to unmarshal") } +// --------------------------------------------------------------------------- +// NewAuthClient - ForwardM2MProduct flag +// --------------------------------------------------------------------------- + +func TestNewAuthClient_ReadsForwardM2MProductFlag(t *testing.T) { + // Cannot use t.Parallel(): subtests use t.Setenv which modifies process env. + // enabled=false / empty address returns early without any network call, so the + // flag wiring is exercised in isolation. + logger := log.Logger(&testLogger{}) + + t.Run("flag_true_enables_forward", func(t *testing.T) { + t.Setenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED", "true") + + client := NewAuthClient("", false, &logger) + assert.True(t, client.ForwardM2MProduct) + }) + + t.Run("flag_absent_defaults_false", func(t *testing.T) { + t.Setenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED", "") + + client := NewAuthClient("", false, &logger) + assert.False(t, client.ForwardM2MProduct) + }) + + t.Run("flag_non_true_value_is_false", func(t *testing.T) { + t.Setenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED", "1") + + client := NewAuthClient("", false, &logger) + assert.False(t, client.ForwardM2MProduct) + }) +} + // --------------------------------------------------------------------------- // GetApplicationToken // --------------------------------------------------------------------------- From 4cebe96c6d5cad2a5a8a275db3af8ba2d675aef7 Mon Sep 17 00:00:00 2001 From: lerian-studio Date: Fri, 17 Jul 2026 21:41:35 +0000 Subject: [PATCH 08/22] chore(release): 3.0.0-beta.3 ## [3.0.0-beta.3](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.2...v3.0.0-beta.3) (2026-07-17) ### Features * **middleware:** forward product on M2M auth (flag-gated) ([93c44db](https://github.com/LerianStudio/lib-auth/commit/93c44dbc7155db5a4d5e857139e9b35d7ba5847f)) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44847e9..135e694 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [3.0.0-beta.3](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.2...v3.0.0-beta.3) (2026-07-17) + + +### Features + +* **middleware:** forward product on M2M auth (flag-gated) ([93c44db](https://github.com/LerianStudio/lib-auth/commit/93c44dbc7155db5a4d5e857139e9b35d7ba5847f)) + ## [3.0.0-beta.2](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.1...v3.0.0-beta.2) (2026-07-16) From 20341984c8c3ca57db0abf7c6bd57c85b981d15a Mon Sep 17 00:00:00 2001 From: Brecci Date: Mon, 20 Jul 2026 14:23:52 -0300 Subject: [PATCH 09/22] feat(middleware): add M2M authentication gate Add M2MAuthenticator, a Fiber v3 middleware (RequireM2M) that authenticates machine-to-machine callers by verifying a Casdoor-issued RS256 application token offline against an injected RSA public key (the issuer certificate), so any service can reuse it by supplying its own cert. No network round-trip and no Casdoor SDK dependency. It authenticates only: RS256 is pinned (closing none/HS256 alg substitution), expiry is required, and the token type must be "application". It does not authorize and does not bind the caller to a target slug; that ownership check is intentionally out of scope, with possession of a valid, manually-provisioned M2M token as the trust boundary. The authenticated identity (sub, azp) is exposed to downstream handlers via M2MSubjectFromContext / M2MClientIDFromContext. Every failure path fails closed (401/403); when disabled the middleware is a pass-through, and an enabled authenticator requires a parseable cert at construction. X-Lerian-Ref: 0x1 Co-Authored-By: Claude Opus 4.8 (1M context) --- auth/middleware/m2m.go | 222 +++++++++++++++++++++ auth/middleware/m2m_test.go | 380 ++++++++++++++++++++++++++++++++++++ 2 files changed, 602 insertions(+) create mode 100644 auth/middleware/m2m.go create mode 100644 auth/middleware/m2m_test.go diff --git a/auth/middleware/m2m.go b/auth/middleware/m2m.go new file mode 100644 index 0000000..78181d7 --- /dev/null +++ b/auth/middleware/m2m.go @@ -0,0 +1,222 @@ +package middleware + +import ( + "context" + "crypto/rsa" + "errors" + "fmt" + stdlog "log" + "net/http" + + observability "github.com/LerianStudio/lib-observability/v2" + "github.com/LerianStudio/lib-observability/v2/log" + "github.com/LerianStudio/lib-observability/v2/tracing" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" + + "github.com/LerianStudio/lib-commons/v6/commons" + libHTTP "github.com/LerianStudio/lib-commons/v6/commons/net/http" + "github.com/gofiber/fiber/v3" + jwt "github.com/golang-jwt/jwt/v5" +) + +// Fiber locals keys under which RequireM2M stores the authenticated application +// identity for downstream handlers. Read them via the typed helpers +// (M2MSubjectFromContext / M2MClientIDFromContext), not the raw keys. +const ( + contextKeyM2MSubject = "lib_auth.m2m.subject" + contextKeyM2MClientID = "lib_auth.m2m.client_id" +) + +// M2MAuthenticator authenticates machine-to-machine (application) callers by +// verifying a Casdoor-issued RS256 access token offline against an injected +// public key (the issuer's certificate). +// +// It authenticates only; it does NOT authorize. There is no RBAC round-trip and +// no binding of the caller to a target resource or slug: possession of a valid +// M2M token — whose issuing application is provisioned only by a trusted +// operator — is the trust boundary. Callers that need per-resource ownership +// must enforce it themselves downstream. +// +// Verification is fully local (no network call, no Casdoor SDK dependency), so +// any service can reuse this by supplying its own issuer certificate. +type M2MAuthenticator struct { + publicKey *rsa.PublicKey + enabled bool + logger log.Logger +} + +// NewM2MAuthenticator builds an M2MAuthenticator from the issuer's certificate +// (a PEM-encoded X.509 certificate or RSA public key, e.g. Casdoor's +// token_jwt_key.pem). +// +// When enabled is false the authenticator is a pass-through (RequireM2M calls +// c.Next() without inspecting the request) and the certificate may be empty — +// this mirrors AuthClient behavior for local/dev where auth is disabled. When +// enabled is true a parseable certificate is required; an empty or invalid PEM +// returns an error so a misconfigured service fails to start rather than +// silently accepting unverified tokens. +func NewM2MAuthenticator(certificatePEM string, enabled bool, logger *log.Logger) (*M2MAuthenticator, error) { + var l log.Logger + + if logger != nil { + l = *logger + } else { + var err error + + l, err = initializeDefaultLogger() + if err != nil { + stdlog.Printf("failed to initialize logger, using NopLogger: %v", err) + + l = log.NewNop() + } + } + + if !enabled { + return &M2MAuthenticator{publicKey: nil, enabled: false, logger: l}, nil + } + + publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(certificatePEM)) + if err != nil { + return nil, fmt.Errorf("failed to parse issuer certificate: %w", err) + } + + return &M2MAuthenticator{publicKey: publicKey, enabled: true, logger: l}, nil +} + +// RequireM2M is a Fiber middleware that rejects any request not carrying a +// valid, unexpired, RS256-signed Casdoor application (M2M) token. On success the +// application identity is stored in the request locals (see +// M2MSubjectFromContext / M2MClientIDFromContext) and the request proceeds. +// +// All failure modes fail closed: +// - missing token -> 401 Unauthorized +// - unparseable / bad signature / expired -> 401 Unauthorized +// - authentic token whose type is not +// "application" (e.g. a normal-user) -> 403 Forbidden +func (m *M2MAuthenticator) RequireM2M() fiber.Handler { + return func(c fiber.Ctx) error { + if !m.enabled { + return c.Next() + } + + ctx := tracing.ExtractHTTPContext(c.Context(), c) + + _, tracer, reqID, _ := observability.NewTrackingFromContext(ctx) + + ctx, span := tracer.Start(ctx, "lib_auth.require_m2m") + defer span.End() + + span.SetAttributes( + attribute.String("app.request.request_id", reqID), + ) + + accessToken := libHTTP.ExtractTokenFromHeader(c) + + if commons.IsNilOrEmpty(&accessToken) { + return c.Status(http.StatusUnauthorized).SendString("Missing Token") + } + + claims, statusCode, err := m.verify(ctx, span, accessToken) + if err != nil { + return c.Status(statusCode).SendString(http.StatusText(statusCode)) + } + + subject, _ := claims["sub"].(string) + clientID, _ := claims["azp"].(string) + + c.Locals(contextKeyM2MSubject, subject) + c.Locals(contextKeyM2MClientID, clientID) + + span.SetAttributes( + attribute.String("app.auth.m2m.subject", subject), + attribute.String("app.auth.m2m.client_id", clientID), + ) + + return c.Next() + } +} + +// verify parses and cryptographically validates the access token, enforcing +// RS256 against the injected public key and the default expiry/not-before +// checks, then requires the whitelisted "application" token type. It never logs +// the token itself. +func (m *M2MAuthenticator) verify(ctx context.Context, span trace.Span, accessToken string) (jwt.MapClaims, int, error) { + if m.publicKey == nil { + err := errors.New("m2m authenticator has no verification key") + + logErrorf(ctx, m.logger, "M2M authentication misconfigured: missing verification key") + tracing.HandleSpanError(span, "M2M authentication misconfigured", err) + + return nil, http.StatusUnauthorized, err + } + + claims := jwt.MapClaims{} + + // WithValidMethods pins the accepted signing algorithm to RS256, closing the + // alg-substitution hole (a token forged with "none" or with HS256 using the + // public key as the shared secret is rejected before the keyfunc runs). + // WithExpirationRequired rejects a validly-signed token that omits "exp", so a + // non-expiring credential cannot slip through (Casdoor always emits exp). + parser := jwt.NewParser( + jwt.WithValidMethods([]string{"RS256"}), + jwt.WithExpirationRequired(), + ) + + token, err := parser.ParseWithClaims(accessToken, claims, func(_ *jwt.Token) (any, error) { + return m.publicKey, nil + }) + if err != nil { + logErrorf(ctx, m.logger, "Failed to verify M2M token: %v", err) + tracing.HandleSpanError(span, "Failed to verify M2M token", err) + + return nil, http.StatusUnauthorized, errors.New("invalid token") + } + + if !token.Valid { + err := errors.New("invalid token") + + logErrorf(ctx, m.logger, "M2M token failed validation") + tracing.HandleSpanError(span, "M2M token failed validation", err) + + return nil, http.StatusUnauthorized, err + } + + userType, _ := claims["type"].(string) + if userType != application { + err := errors.New("token is not an application token") + + logErrorf(ctx, m.logger, "Rejected non-application token on M2M-only route: type=%q", userType) + tracing.HandleSpanError(span, "Non-application token on M2M route", err) + + return nil, http.StatusForbidden, err + } + + subject, _ := claims["sub"].(string) + if subject == "" { + err := errors.New("missing sub claim in application token") + + logErrorf(ctx, m.logger, "Missing sub claim in application token") + tracing.HandleSpanError(span, "Missing sub claim in application token", err) + + return nil, http.StatusUnauthorized, err + } + + return claims, http.StatusOK, nil +} + +// M2MSubjectFromContext returns the authenticated application subject (the "sub" +// claim, "/" form) stored by RequireM2M, or ("", false) when absent. +func M2MSubjectFromContext(c fiber.Ctx) (string, bool) { + v, ok := c.Locals(contextKeyM2MSubject).(string) + + return v, ok && v != "" +} + +// M2MClientIDFromContext returns the authenticated application client id (the +// "azp" claim) stored by RequireM2M, or ("", false) when absent. +func M2MClientIDFromContext(c fiber.Ctx) (string, bool) { + v, ok := c.Locals(contextKeyM2MClientID).(string) + + return v, ok && v != "" +} diff --git a/auth/middleware/m2m_test.go b/auth/middleware/m2m_test.go new file mode 100644 index 0000000..d397b59 --- /dev/null +++ b/auth/middleware/m2m_test.go @@ -0,0 +1,380 @@ +package middleware + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/pem" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/LerianStudio/lib-observability/v2/log" + "github.com/gofiber/fiber/v3" + jwt "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.opentelemetry.io/otel/trace" +) + +// noopSpan returns a non-recording span for exercising verify() directly. +func noopSpan() trace.Span { + return trace.SpanFromContext(context.Background()) +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// newTestRSAKeyPEM generates an RSA key pair and returns the private key plus +// the PKIX PEM encoding of its public half (the form NewM2MAuthenticator parses). +func newTestRSAKeyPEM(t *testing.T) (*rsa.PrivateKey, string) { + t.Helper() + + key, err := rsa.GenerateKey(rand.Reader, 2048) + require.NoError(t, err) + + der, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + require.NoError(t, err) + + pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: der}) + + return key, string(pemBytes) +} + +// signRS256 signs claims with the given RSA private key using RS256. +func signRS256(t *testing.T, key *rsa.PrivateKey, claims jwt.MapClaims) string { + t.Helper() + + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + + signed, err := token.SignedString(key) + require.NoError(t, err) + + return signed +} + +// applicationClaims mirrors a real Casdoor client_credentials (M2M) token. +func applicationClaims() jwt.MapClaims { + return jwt.MapClaims{ + "type": "application", + "sub": "admin/3a09ac44-1faf-4e66-843c-5152b09b19dc", + "name": "3a09ac44-1faf-4e66-843c-5152b09b19dc", + "azp": "66bac70fbea746daa760", + "exp": float64(time.Now().Add(time.Hour).Unix()), + "iat": float64(time.Now().Add(-time.Minute).Unix()), + } +} + +func newTestM2MAuthenticator(t *testing.T, pubPEM string) *M2MAuthenticator { + t.Helper() + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator(pubPEM, true, &logger) + require.NoError(t, err) + + return m +} + +// newTestM2MApp builds a Fiber app whose single route is gated by RequireM2M and +// echoes the authenticated identity back through response headers. +func newTestM2MApp(m *M2MAuthenticator) *fiber.App { + app := fiber.New() + + app.Put("/declarations/:slug", m.RequireM2M(), func(c fiber.Ctx) error { + sub, _ := M2MSubjectFromContext(c) + clientID, _ := M2MClientIDFromContext(c) + + c.Set("X-M2M-Subject", sub) + c.Set("X-M2M-Client-Id", clientID) + + return c.SendStatus(http.StatusOK) + }) + + return app +} + +// --------------------------------------------------------------------------- +// NewM2MAuthenticator +// --------------------------------------------------------------------------- + +func TestNewM2MAuthenticator_DisabledAllowsEmptyCert(t *testing.T) { + t.Parallel() + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator("", false, &logger) + require.NoError(t, err) + require.NotNil(t, m) + assert.False(t, m.enabled) + assert.Nil(t, m.publicKey) +} + +func TestNewM2MAuthenticator_EnabledRejectsInvalidCert(t *testing.T) { + t.Parallel() + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator("not-a-pem", true, &logger) + require.Error(t, err) + assert.Nil(t, m) +} + +func TestNewM2MAuthenticator_EnabledParsesValidCert(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator(pubPEM, true, &logger) + require.NoError(t, err) + require.NotNil(t, m) + assert.True(t, m.enabled) + assert.NotNil(t, m.publicKey) +} + +// --------------------------------------------------------------------------- +// verify - cryptographic core +// --------------------------------------------------------------------------- + +func TestVerify_ValidApplicationToken(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + token := signRS256(t, key, applicationClaims()) + + claims, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) + assert.Equal(t, "admin/3a09ac44-1faf-4e66-843c-5152b09b19dc", claims["sub"]) + assert.Equal(t, "66bac70fbea746daa760", claims["azp"]) +} + +func TestVerify_WrongKey_FailsClosed(t *testing.T) { + t.Parallel() + + // Token signed by an attacker key that is NOT the authenticator's key. + attackerKey, _ := newTestRSAKeyPEM(t) + _, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + token := signRS256(t, attackerKey, applicationClaims()) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_AlgConfusionHS256_Rejected(t *testing.T) { + t.Parallel() + + // Classic RS/HS confusion: attacker forges an HS256 token using the public + // PEM bytes as the shared secret. WithValidMethods([]string{"RS256"}) must + // reject it before the keyfunc runs. + _, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + forged := jwt.NewWithClaims(jwt.SigningMethodHS256, applicationClaims()) + + signed, err := forged.SignedString([]byte(pubPEM)) + require.NoError(t, err) + + _, statusCode, verr := m.verify(context.Background(), noopSpan(), signed) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_AlgNone_Rejected(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + unsigned := jwt.NewWithClaims(jwt.SigningMethodNone, applicationClaims()) + + signed, err := unsigned.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + + _, statusCode, verr := m.verify(context.Background(), noopSpan(), signed) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_ExpiredToken_Rejected(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + claims := applicationClaims() + claims["exp"] = float64(time.Now().Add(-time.Hour).Unix()) + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_MissingExp_Rejected(t *testing.T) { + t.Parallel() + + // A validly-signed token that omits "exp" must be rejected (WithExpirationRequired). + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + claims := applicationClaims() + delete(claims, "exp") + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_ApplicationMissingSub_Rejected(t *testing.T) { + t.Parallel() + + // A correctly-signed application token without a "sub" claim fails closed: + // the stashed identity would otherwise be empty. + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + claims := applicationClaims() + delete(claims, "sub") + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_NonApplicationType_Forbidden(t *testing.T) { + t.Parallel() + + // An authentic, correctly-signed token that is a normal-user (not M2M) must + // be rejected with 403: authentication succeeds, but the route is M2M-only. + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + claims := applicationClaims() + claims["type"] = "normal-user" + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusForbidden, statusCode) +} + +func TestVerify_MalformedToken_Rejected(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticator(t, pubPEM) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), "not-a-jwt") + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerify_NilKey_FailsClosed(t *testing.T) { + t.Parallel() + + // Defensive: an enabled authenticator with no key must deny, never allow. + m := &M2MAuthenticator{publicKey: nil, enabled: true, logger: &testLogger{}} + + _, statusCode, err := m.verify(context.Background(), noopSpan(), "any-token") + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +// --------------------------------------------------------------------------- +// RequireM2M - Fiber middleware +// --------------------------------------------------------------------------- + +func TestRequireM2M_Disabled_PassesThrough(t *testing.T) { + t.Parallel() + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator("", false, &logger) + require.NoError(t, err) + + app := newTestM2MApp(m) + + // No Authorization header at all: a disabled authenticator must not block. + req := httptest.NewRequest(http.MethodPut, "/declarations/plugin-fees", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) +} + +func TestRequireM2M_MissingToken_Unauthorized(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + app := newTestM2MApp(newTestM2MAuthenticator(t, pubPEM)) + + req := httptest.NewRequest(http.MethodPut, "/declarations/plugin-fees", nil) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusUnauthorized, resp.StatusCode) +} + +func TestRequireM2M_ValidToken_AllowsAndExposesIdentity(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + app := newTestM2MApp(newTestM2MAuthenticator(t, pubPEM)) + + token := signRS256(t, key, applicationClaims()) + + req := httptest.NewRequest(http.MethodPut, "/declarations/plugin-fees", nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + assert.Equal(t, "admin/3a09ac44-1faf-4e66-843c-5152b09b19dc", resp.Header.Get("X-M2M-Subject")) + assert.Equal(t, "66bac70fbea746daa760", resp.Header.Get("X-M2M-Client-Id")) +} + +func TestRequireM2M_NormalUserToken_Forbidden(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + app := newTestM2MApp(newTestM2MAuthenticator(t, pubPEM)) + + claims := applicationClaims() + claims["type"] = "normal-user" + + token := signRS256(t, key, claims) + + req := httptest.NewRequest(http.MethodPut, "/declarations/plugin-fees", nil) + req.Header.Set("Authorization", "Bearer "+token) + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusForbidden, resp.StatusCode) +} From 5afd3055d9d4a8439e71c5fa78ef75afc68f7624 Mon Sep 17 00:00:00 2001 From: Brecci Date: Mon, 20 Jul 2026 14:47:39 -0300 Subject: [PATCH 10/22] feat(middleware): add optional issuer pinning to M2M gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The M2M gate verified the RS256 signature but ignored the "iss" claim, so any application token signed by the same key passed regardless of issuer. This is safe in a single-issuer deployment but becomes cross-issuer token reuse if one signing key ever serves multiple issuers/orgs. NewM2MAuthenticator now takes an expectedIssuer: when non-empty it pins the token issuer via jwt.WithIssuer; empty keeps the prior behavior (no issuer check) for single-issuer/dev setups. Audience is deliberately not pinned here — a Casdoor client_credentials token carries the caller's own clientId as aud, so pinning specific audiences would be the app<->slug ownership binding that is intentionally out of scope (Option B). X-Lerian-Ref: 0x1 Co-Authored-By: Claude Opus 4.8 (1M context) --- auth/middleware/m2m.go | 31 ++++++++++++++----- auth/middleware/m2m_test.go | 59 +++++++++++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 12 deletions(-) diff --git a/auth/middleware/m2m.go b/auth/middleware/m2m.go index 78181d7..2d00354 100644 --- a/auth/middleware/m2m.go +++ b/auth/middleware/m2m.go @@ -42,8 +42,13 @@ const ( // any service can reuse this by supplying its own issuer certificate. type M2MAuthenticator struct { publicKey *rsa.PublicKey - enabled bool - logger log.Logger + // expectedIssuer, when non-empty, pins the accepted token "iss" claim so a + // token minted by a different issuer sharing the same signing key is + // rejected. Empty means the issuer is not checked (acceptable only in a + // single-issuer deployment). + expectedIssuer string + enabled bool + logger log.Logger } // NewM2MAuthenticator builds an M2MAuthenticator from the issuer's certificate @@ -56,7 +61,12 @@ type M2MAuthenticator struct { // enabled is true a parseable certificate is required; an empty or invalid PEM // returns an error so a misconfigured service fails to start rather than // silently accepting unverified tokens. -func NewM2MAuthenticator(certificatePEM string, enabled bool, logger *log.Logger) (*M2MAuthenticator, error) { +// +// expectedIssuer, when non-empty, pins the token "iss" claim (defense in depth +// against reuse of a token minted by a different issuer that shares the same +// signing key). Pass "" to skip the issuer check; this is only safe in a +// single-issuer deployment and setting it is recommended otherwise. +func NewM2MAuthenticator(certificatePEM, expectedIssuer string, enabled bool, logger *log.Logger) (*M2MAuthenticator, error) { var l log.Logger if logger != nil { @@ -73,7 +83,7 @@ func NewM2MAuthenticator(certificatePEM string, enabled bool, logger *log.Logger } if !enabled { - return &M2MAuthenticator{publicKey: nil, enabled: false, logger: l}, nil + return &M2MAuthenticator{publicKey: nil, expectedIssuer: expectedIssuer, enabled: false, logger: l}, nil } publicKey, err := jwt.ParseRSAPublicKeyFromPEM([]byte(certificatePEM)) @@ -81,7 +91,7 @@ func NewM2MAuthenticator(certificatePEM string, enabled bool, logger *log.Logger return nil, fmt.Errorf("failed to parse issuer certificate: %w", err) } - return &M2MAuthenticator{publicKey: publicKey, enabled: true, logger: l}, nil + return &M2MAuthenticator{publicKey: publicKey, expectedIssuer: expectedIssuer, enabled: true, logger: l}, nil } // RequireM2M is a Fiber middleware that rejects any request not carrying a @@ -158,10 +168,17 @@ func (m *M2MAuthenticator) verify(ctx context.Context, span trace.Span, accessTo // public key as the shared secret is rejected before the keyfunc runs). // WithExpirationRequired rejects a validly-signed token that omits "exp", so a // non-expiring credential cannot slip through (Casdoor always emits exp). - parser := jwt.NewParser( + // WithIssuer, when an expected issuer is configured, rejects a token minted by + // a different issuer that shares the same signing key. + opts := []jwt.ParserOption{ jwt.WithValidMethods([]string{"RS256"}), jwt.WithExpirationRequired(), - ) + } + if m.expectedIssuer != "" { + opts = append(opts, jwt.WithIssuer(m.expectedIssuer)) + } + + parser := jwt.NewParser(opts...) token, err := parser.ParseWithClaims(accessToken, claims, func(_ *jwt.Token) (any, error) { return m.publicKey, nil diff --git a/auth/middleware/m2m_test.go b/auth/middleware/m2m_test.go index d397b59..629c996 100644 --- a/auth/middleware/m2m_test.go +++ b/auth/middleware/m2m_test.go @@ -73,7 +73,18 @@ func newTestM2MAuthenticator(t *testing.T, pubPEM string) *M2MAuthenticator { logger := log.Logger(&testLogger{}) - m, err := NewM2MAuthenticator(pubPEM, true, &logger) + m, err := NewM2MAuthenticator(pubPEM, "", true, &logger) + require.NoError(t, err) + + return m +} + +func newTestM2MAuthenticatorWithIssuer(t *testing.T, pubPEM, issuer string) *M2MAuthenticator { + t.Helper() + + logger := log.Logger(&testLogger{}) + + m, err := NewM2MAuthenticator(pubPEM, issuer, true, &logger) require.NoError(t, err) return m @@ -106,7 +117,7 @@ func TestNewM2MAuthenticator_DisabledAllowsEmptyCert(t *testing.T) { logger := log.Logger(&testLogger{}) - m, err := NewM2MAuthenticator("", false, &logger) + m, err := NewM2MAuthenticator("", "", false, &logger) require.NoError(t, err) require.NotNil(t, m) assert.False(t, m.enabled) @@ -118,7 +129,7 @@ func TestNewM2MAuthenticator_EnabledRejectsInvalidCert(t *testing.T) { logger := log.Logger(&testLogger{}) - m, err := NewM2MAuthenticator("not-a-pem", true, &logger) + m, err := NewM2MAuthenticator("not-a-pem", "", true, &logger) require.Error(t, err) assert.Nil(t, m) } @@ -130,7 +141,7 @@ func TestNewM2MAuthenticator_EnabledParsesValidCert(t *testing.T) { logger := log.Logger(&testLogger{}) - m, err := NewM2MAuthenticator(pubPEM, true, &logger) + m, err := NewM2MAuthenticator(pubPEM, "", true, &logger) require.NoError(t, err) require.NotNil(t, m) assert.True(t, m.enabled) @@ -264,6 +275,44 @@ func TestVerify_ApplicationMissingSub_Rejected(t *testing.T) { assert.Equal(t, http.StatusUnauthorized, statusCode) } +func TestVerify_MatchingIssuer_Allows(t *testing.T) { + t.Parallel() + + const issuer = "http://plugin-access-manager-auth-backend:8000" + + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticatorWithIssuer(t, pubPEM, issuer) + + claims := applicationClaims() + claims["iss"] = issuer + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.NoError(t, err) + assert.Equal(t, http.StatusOK, statusCode) +} + +func TestVerify_WrongIssuer_Rejected(t *testing.T) { + t.Parallel() + + // A validly-signed application token from a different issuer (sharing the same + // signing key) must be rejected when an expected issuer is pinned. + key, pubPEM := newTestRSAKeyPEM(t) + m := newTestM2MAuthenticatorWithIssuer(t, pubPEM, "http://expected-issuer:8000") + + claims := applicationClaims() + claims["iss"] = "http://attacker-issuer:8000" + + token := signRS256(t, key, claims) + + _, statusCode, err := m.verify(context.Background(), noopSpan(), token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + func TestVerify_NonApplicationType_Forbidden(t *testing.T) { t.Parallel() @@ -316,7 +365,7 @@ func TestRequireM2M_Disabled_PassesThrough(t *testing.T) { logger := log.Logger(&testLogger{}) - m, err := NewM2MAuthenticator("", false, &logger) + m, err := NewM2MAuthenticator("", "", false, &logger) require.NoError(t, err) app := newTestM2MApp(m) From dc3ca0d481f039b6b7c40d834a114f89587f1ff3 Mon Sep 17 00:00:00 2001 From: lerian-studio Date: Mon, 20 Jul 2026 18:41:04 +0000 Subject: [PATCH 11/22] chore(release): 3.0.0-beta.4 ## [3.0.0-beta.4](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.3...v3.0.0-beta.4) (2026-07-20) ### Features * **middleware:** add M2M authentication gate ([2034198](https://github.com/LerianStudio/lib-auth/commit/20341984c8c3ca57db0abf7c6bd57c85b981d15a)) * **middleware:** add optional issuer pinning to M2M gate ([5afd305](https://github.com/LerianStudio/lib-auth/commit/5afd3055d9d4a8439e71c5fa78ef75afc68f7624)) --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 135e694..84c3ef2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [3.0.0-beta.4](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.3...v3.0.0-beta.4) (2026-07-20) + + +### Features + +* **middleware:** add M2M authentication gate ([2034198](https://github.com/LerianStudio/lib-auth/commit/20341984c8c3ca57db0abf7c6bd57c85b981d15a)) +* **middleware:** add optional issuer pinning to M2M gate ([5afd305](https://github.com/LerianStudio/lib-auth/commit/5afd3055d9d4a8439e71c5fa78ef75afc68f7624)) + ## [3.0.0-beta.3](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.2...v3.0.0-beta.3) (2026-07-17) From c48393ac6cbeb0494524dccfb8e35ee6eb5e5375 Mon Sep 17 00:00:00 2001 From: Brecci Date: Mon, 20 Jul 2026 19:13:56 -0300 Subject: [PATCH 12/22] feat(middleware): expose M2M identity via request context RequireM2M now stores the authenticated application identity on the request Go context (context.Context) via c.SetContext, in addition to setting the existing span attributes. Downstream consumers read it with a single framework-agnostic accessor, M2MIdentityFromContext(ctx), which works for fiber-native handlers (c.Context()), humafiber-derived Huma handlers, and gRPC alike. Motivation: a consumer (plugin-access-manager) registers a Huma operation via humafiber, whose handler receives a Go context derived from Fiber's c.Context(). The previous Fiber-Locals-only accessors (M2MSubjectFromContext / M2MClientIDFromContext, keyed by c.Locals) could not be reached cleanly from a Huma handler. Propagating the identity on the Go context resolves this with one typed accessor. The identity is added onto c.Context() (not the tracing/span context), so this change carries only the identity value and does not alter span topology. The Fiber-Locals accessors were introduced in this same beta line (3.0.0-beta.x, PR #125) and have no production consumers, so they are removed in favor of the single M2MIdentityFromContext(ctx) API. A fiber-native caller reads the identity via M2MIdentityFromContext(c.Context()). A typed, unexported context key avoids string-key collisions. Verification logic (RS256/issuer/expiry/type/fail-closed) is unchanged. X-Lerian-Ref: 0x1 Co-Authored-By: Claude Opus 4.8 (1M context) --- auth/middleware/m2m.go | 51 +++++++++++++++++---------------- auth/middleware/m2m_test.go | 56 +++++++++++++++++++++++++++++++++---- 2 files changed, 78 insertions(+), 29 deletions(-) diff --git a/auth/middleware/m2m.go b/auth/middleware/m2m.go index 2d00354..646438f 100644 --- a/auth/middleware/m2m.go +++ b/auth/middleware/m2m.go @@ -20,13 +20,18 @@ import ( jwt "github.com/golang-jwt/jwt/v5" ) -// Fiber locals keys under which RequireM2M stores the authenticated application -// identity for downstream handlers. Read them via the typed helpers -// (M2MSubjectFromContext / M2MClientIDFromContext), not the raw keys. -const ( - contextKeyM2MSubject = "lib_auth.m2m.subject" - contextKeyM2MClientID = "lib_auth.m2m.client_id" -) +// M2MIdentity is the authenticated application identity extracted from a verified +// M2M token. RequireM2M stores it on the request Go context; read it back with +// M2MIdentityFromContext. +type M2MIdentity struct { + Subject string // "sub" claim, "/" form + ClientID string // "azp" claim (the Casdoor application clientId) +} + +// m2mIdentityContextKey is the unexported, typed key under which RequireM2M stores +// the M2MIdentity on the request context. A dedicated type (rather than a string) +// avoids collisions with any other context value. +type m2mIdentityContextKey struct{} // M2MAuthenticator authenticates machine-to-machine (application) callers by // verifying a Casdoor-issued RS256 access token offline against an injected @@ -96,8 +101,10 @@ func NewM2MAuthenticator(certificatePEM, expectedIssuer string, enabled bool, lo // RequireM2M is a Fiber middleware that rejects any request not carrying a // valid, unexpired, RS256-signed Casdoor application (M2M) token. On success the -// application identity is stored in the request locals (see -// M2MSubjectFromContext / M2MClientIDFromContext) and the request proceeds. +// application identity is stored on the request Go context (readable via +// M2MIdentityFromContext, framework-agnostically, by any downstream handler — +// fiber-native via c.Context(), humafiber-derived, or gRPC) and the request +// proceeds. // // All failure modes fail closed: // - missing token -> 401 Unauthorized @@ -135,8 +142,10 @@ func (m *M2MAuthenticator) RequireM2M() fiber.Handler { subject, _ := claims["sub"].(string) clientID, _ := claims["azp"].(string) - c.Locals(contextKeyM2MSubject, subject) - c.Locals(contextKeyM2MClientID, clientID) + // Store the identity on the request Go context (derived from c.Context(), + // NOT the tracing ctx) so this adds only the identity value without altering + // span/tracing topology. Downstream handlers read it via M2MIdentityFromContext. + c.SetContext(context.WithValue(c.Context(), m2mIdentityContextKey{}, M2MIdentity{Subject: subject, ClientID: clientID})) span.SetAttributes( attribute.String("app.auth.m2m.subject", subject), @@ -222,18 +231,12 @@ func (m *M2MAuthenticator) verify(ctx context.Context, span trace.Span, accessTo return claims, http.StatusOK, nil } -// M2MSubjectFromContext returns the authenticated application subject (the "sub" -// claim, "/" form) stored by RequireM2M, or ("", false) when absent. -func M2MSubjectFromContext(c fiber.Ctx) (string, bool) { - v, ok := c.Locals(contextKeyM2MSubject).(string) - - return v, ok && v != "" -} - -// M2MClientIDFromContext returns the authenticated application client id (the -// "azp" claim) stored by RequireM2M, or ("", false) when absent. -func M2MClientIDFromContext(c fiber.Ctx) (string, bool) { - v, ok := c.Locals(contextKeyM2MClientID).(string) +// M2MIdentityFromContext returns the authenticated M2M identity that RequireM2M +// stored on the request context, or (zero, false) when absent. A stored identity +// with an empty Subject is reported as absent so callers never treat a +// half-populated identity as authenticated. +func M2MIdentityFromContext(ctx context.Context) (M2MIdentity, bool) { + id, ok := ctx.Value(m2mIdentityContextKey{}).(M2MIdentity) - return v, ok && v != "" + return id, ok && id.Subject != "" } diff --git a/auth/middleware/m2m_test.go b/auth/middleware/m2m_test.go index 629c996..46dca63 100644 --- a/auth/middleware/m2m_test.go +++ b/auth/middleware/m2m_test.go @@ -91,16 +91,17 @@ func newTestM2MAuthenticatorWithIssuer(t *testing.T, pubPEM, issuer string) *M2M } // newTestM2MApp builds a Fiber app whose single route is gated by RequireM2M and -// echoes the authenticated identity back through response headers. +// echoes the authenticated identity back through response headers. The downstream +// handler reads the identity from the framework-agnostic Go context +// (c.Context()), the same path humafiber-derived handlers rely on. func newTestM2MApp(m *M2MAuthenticator) *fiber.App { app := fiber.New() app.Put("/declarations/:slug", m.RequireM2M(), func(c fiber.Ctx) error { - sub, _ := M2MSubjectFromContext(c) - clientID, _ := M2MClientIDFromContext(c) + id, _ := M2MIdentityFromContext(c.Context()) - c.Set("X-M2M-Subject", sub) - c.Set("X-M2M-Client-Id", clientID) + c.Set("X-M2M-Subject", id.Subject) + c.Set("X-M2M-Client-Id", id.ClientID) return c.SendStatus(http.StatusOK) }) @@ -394,6 +395,9 @@ func TestRequireM2M_MissingToken_Unauthorized(t *testing.T) { func TestRequireM2M_ValidToken_AllowsAndExposesIdentity(t *testing.T) { t.Parallel() + // End-to-end proof that RequireM2M propagates the identity onto the Go + // context: the downstream handler reads it via M2MIdentityFromContext(c.Context()) + // (see newTestM2MApp), the same mechanism humafiber-derived handlers rely on. key, pubPEM := newTestRSAKeyPEM(t) app := newTestM2MApp(newTestM2MAuthenticator(t, pubPEM)) @@ -427,3 +431,45 @@ func TestRequireM2M_NormalUserToken_Forbidden(t *testing.T) { require.NoError(t, err) assert.Equal(t, http.StatusForbidden, resp.StatusCode) } + +// --------------------------------------------------------------------------- +// M2MIdentityFromContext - framework-agnostic accessor +// --------------------------------------------------------------------------- + +func TestM2MIdentityFromContext_Present_RoundTrips(t *testing.T) { + t.Parallel() + + want := M2MIdentity{ + Subject: "admin/3a09ac44-1faf-4e66-843c-5152b09b19dc", + ClientID: "66bac70fbea746daa760", + } + ctx := context.WithValue(context.Background(), m2mIdentityContextKey{}, want) + + got, ok := M2MIdentityFromContext(ctx) + + require.True(t, ok) + assert.Equal(t, want, got) +} + +func TestM2MIdentityFromContext_Absent_ReturnsZeroFalse(t *testing.T) { + t.Parallel() + + got, ok := M2MIdentityFromContext(context.Background()) + + assert.False(t, ok) + assert.Equal(t, M2MIdentity{}, got) +} + +func TestM2MIdentityFromContext_EmptySubject_TreatedAsAbsent(t *testing.T) { + t.Parallel() + + // A value with an empty Subject must be reported as absent (ok == false) so a + // caller that only checks ok never treats a half-populated identity as + // authenticated. RequireM2M never stores such a value (verify rejects an empty + // sub), so this only guards against manual/defensive construction. + ctx := context.WithValue(context.Background(), m2mIdentityContextKey{}, M2MIdentity{Subject: "", ClientID: "azp-only"}) + + _, ok := M2MIdentityFromContext(ctx) + + assert.False(t, ok) +} From 25fc4ec9a44aff13964117ef96420d59468530a2 Mon Sep 17 00:00:00 2001 From: Brecci Date: Mon, 20 Jul 2026 19:19:01 -0300 Subject: [PATCH 13/22] fix(middleware): return zero M2MIdentity when reporting absent M2MIdentityFromContext returned the stored (possibly half-populated) value alongside ok==false when the subject was empty, contradicting the documented (zero, false) contract. Return M2MIdentity{} on the absent/empty-subject path so a caller that ignores ok cannot observe partial identity data. Addresses a CodeRabbit review comment on PR #126. X-Lerian-Ref: 0x1 Co-Authored-By: Claude Opus 4.8 (1M context) --- auth/middleware/m2m.go | 5 ++++- auth/middleware/m2m_test.go | 3 ++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/auth/middleware/m2m.go b/auth/middleware/m2m.go index 646438f..853fa9e 100644 --- a/auth/middleware/m2m.go +++ b/auth/middleware/m2m.go @@ -237,6 +237,9 @@ func (m *M2MAuthenticator) verify(ctx context.Context, span trace.Span, accessTo // half-populated identity as authenticated. func M2MIdentityFromContext(ctx context.Context) (M2MIdentity, bool) { id, ok := ctx.Value(m2mIdentityContextKey{}).(M2MIdentity) + if !ok || id.Subject == "" { + return M2MIdentity{}, false + } - return id, ok && id.Subject != "" + return id, true } diff --git a/auth/middleware/m2m_test.go b/auth/middleware/m2m_test.go index 46dca63..b0019df 100644 --- a/auth/middleware/m2m_test.go +++ b/auth/middleware/m2m_test.go @@ -469,7 +469,8 @@ func TestM2MIdentityFromContext_EmptySubject_TreatedAsAbsent(t *testing.T) { // sub), so this only guards against manual/defensive construction. ctx := context.WithValue(context.Background(), m2mIdentityContextKey{}, M2MIdentity{Subject: "", ClientID: "azp-only"}) - _, ok := M2MIdentityFromContext(ctx) + id, ok := M2MIdentityFromContext(ctx) assert.False(t, ok) + assert.Equal(t, M2MIdentity{}, id, "an empty-subject identity must be reported as the zero value") } From 9feddbb6ad5a3ddfe66d4855c0b326375cfa8189 Mon Sep 17 00:00:00 2001 From: lerian-studio Date: Mon, 20 Jul 2026 22:22:47 +0000 Subject: [PATCH 14/22] chore(release): 3.0.0-beta.5 ## [3.0.0-beta.5](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.4...v3.0.0-beta.5) (2026-07-20) ### Features * **middleware:** expose M2M identity via request context ([c48393a](https://github.com/LerianStudio/lib-auth/commit/c48393ac6cbeb0494524dccfb8e35ee6eb5e5375)), closes [#125](https://github.com/LerianStudio/lib-auth/issues/125) ### Bug Fixes * **middleware:** return zero M2MIdentity when reporting absent ([25fc4ec](https://github.com/LerianStudio/lib-auth/commit/25fc4ec9a44aff13964117ef96420d59468530a2)), closes [#126](https://github.com/LerianStudio/lib-auth/issues/126) --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84c3ef2..e8fcc9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## [3.0.0-beta.5](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.4...v3.0.0-beta.5) (2026-07-20) + + +### Features + +* **middleware:** expose M2M identity via request context ([c48393a](https://github.com/LerianStudio/lib-auth/commit/c48393ac6cbeb0494524dccfb8e35ee6eb5e5375)), closes [#125](https://github.com/LerianStudio/lib-auth/issues/125) + + +### Bug Fixes + +* **middleware:** return zero M2MIdentity when reporting absent ([25fc4ec](https://github.com/LerianStudio/lib-auth/commit/25fc4ec9a44aff13964117ef96420d59468530a2)), closes [#126](https://github.com/LerianStudio/lib-auth/issues/126) + ## [3.0.0-beta.4](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.3...v3.0.0-beta.4) (2026-07-20) From f7fff6c2ab78b30c4d851d2ab39afc362c8a2570 Mon Sep 17 00:00:00 2001 From: Fred Amaral Date: Tue, 21 Jul 2026 08:56:27 -0300 Subject: [PATCH 15/22] feat(middleware): add fail-closed AUTH_REQUIRED option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an opt-in AUTH_REQUIRED switch (AuthClient.Required, read from the AUTH_REQUIRED env var at construction) that inverts the default fail-open posture. When set and auth is disabled or misconfigured (!Enabled || Address == ""), every protected route refuses to serve — HTTP 503 on the Fiber path and gRPC codes.Unavailable on the unary and stream interceptors — instead of silently passing the request through unauthenticated. A loud error is also logged at construction so the misconfiguration is visible. Default (false) preserves the prior pass-through behavior exactly, so this is backward-compatible. Enforcement lives at the handler/interceptor rather than as a constructor error, to avoid a breaking change to NewAuthClient's existing (non-erroring) signature. The tenant-claim propagation duplicated across the two gRPC interceptors is extracted into a shared tenantContext helper, keeping cognitive complexity under the linter threshold after the added fail-closed branch. Closes #107 Claude-Session: https://claude.ai/code/session_01RcQLK4qK1kbpvXWjuAR3Xh --- README.md | 8 ++ auth/middleware/middleware.go | 48 ++++++++++- auth/middleware/middlewareGRPC.go | 80 +++++++++--------- auth/middleware/middlewareGRPC_test.go | 68 +++++++++++++++ auth/middleware/middleware_test.go | 111 +++++++++++++++++++++++++ 5 files changed, 274 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index 2349735..29c14ae 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,14 @@ PLUGIN_AUTH_ENABLED=true # permissions by product (matching product-prefixed resources). Defaults to # false, preserving the previous behavior of sending no product for M2M. AUTH_M2M_PRODUCT_FORWARD_ENABLED=false + +# Optional. When "true", the middleware fails closed: if auth is disabled +# (PLUGIN_AUTH_ENABLED=false) or misconfigured (empty PLUGIN_AUTH_ADDRESS), +# every protected route refuses to serve (HTTP 503 / gRPC Unavailable) instead +# of passing through unauthenticated. Defaults to false, preserving the prior +# fail-open behavior. Set it in security-sensitive deployments so a +# missing/typo'd address cannot silently downgrade a protected service to open. +AUTH_REQUIRED=false ``` ### 2. Create a new instance of the middleware: diff --git a/auth/middleware/middleware.go b/auth/middleware/middleware.go index ee55324..f738b01 100644 --- a/auth/middleware/middleware.go +++ b/auth/middleware/middleware.go @@ -39,6 +39,16 @@ type AuthClient struct { // Gating it by env keeps deploy != release: flipping the flag activates M2M // product isolation without a code change in consumers. ForwardM2MProduct bool + + // Required, when true, makes the middleware fail closed: if auth is disabled + // or misconfigured (!Enabled || Address == ""), every protected route refuses + // to serve (HTTP 503 / gRPC Unavailable) instead of passing through with + // c.Next()/handler(). It is read once from AUTH_REQUIRED at construction; the + // default (false) preserves the prior fail-open pass-through behavior exactly. + // This inverts the default posture only for deployments that opt in, so a + // missing/typo'd address can no longer silently downgrade a protected service + // to fully open. + Required bool } type AuthResponse struct { @@ -175,13 +185,20 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient } forwardM2MProduct := os.Getenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED") == "true" + required := os.Getenv("AUTH_REQUIRED") == "true" if !enabled || address == "" { + if required { + logErrorf(context.Background(), l, + "AUTH_REQUIRED is set but auth is disabled or address is empty: middleware will fail closed (refuse to serve)") + } + return &AuthClient{ Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, + Required: required, } } @@ -194,21 +211,21 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient if err != nil { logErrorf(context.Background(), l, failedToConnectMsg, err) - return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct} + return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required} } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { logErrorf(context.Background(), l, failedToConnectMsg, resp.Status) - return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct} + return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required} } body, err := io.ReadAll(resp.Body) if err != nil { logErrorf(context.Background(), l, "Failed to read response body: %v", err) - return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct} + return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required} } if string(body) == "healthy" { @@ -222,9 +239,26 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, + Required: required, } } +// canAuthorize reports whether the client is able to perform an authorization +// check: non-nil, Enabled, and holding an Address. When it returns false the +// caller passes the request through (default, fail open) unless Required is set. +// A nil receiver is safe and reports false, so a nil client is never usable. +func (auth *AuthClient) canAuthorize() bool { + return auth != nil && auth.Enabled && auth.Address != "" +} + +// mustRefuse reports whether a client that cannot authorize must fail closed +// (refuse to serve) instead of passing the request through. It is true only when +// the client is non-nil, opted in via Required (AUTH_REQUIRED), and cannot +// authorize (disabled or no address). This is the fail-closed switch from #107. +func (auth *AuthClient) mustRefuse() bool { + return auth != nil && auth.Required && !auth.canAuthorize() +} + // Authorize is a middleware function for the Fiber framework that checks if a user is authorized to perform a specific action on a resource. // product identifies the product/application owning the route (e.g. "midaz"); it is forwarded for normal-user flows, and for M2M (application) // flows when AUTH_M2M_PRODUCT_FORWARD_ENABLED is set, so the auth service can isolate permissions by product. M2M tokens are identified by their own subject claim. @@ -235,7 +269,13 @@ func (auth *AuthClient) Authorize(product, resource, action string) fiber.Handle _, tracer, reqID, _ := observability.NewTrackingFromContext(ctx) - if !auth.Enabled || auth.Address == "" { + if auth.mustRefuse() { + // AUTH_REQUIRED opted in but auth is disabled/misconfigured: refuse to + // serve (fail closed) instead of silently passing the request through. + return c.Status(http.StatusServiceUnavailable).SendString("Service Unavailable") + } + + if !auth.canAuthorize() { return c.Next() } diff --git a/auth/middleware/middlewareGRPC.go b/auth/middleware/middlewareGRPC.go index 41d6f54..dd69895 100644 --- a/auth/middleware/middlewareGRPC.go +++ b/auth/middleware/middlewareGRPC.go @@ -52,7 +52,13 @@ type PolicyConfig struct { // AUTH_M2M_PRODUCT_FORWARD_ENABLED is set), mirroring the request body. func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServerInterceptor { return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) { - if auth == nil || !auth.Enabled || auth.Address == "" { + if auth.mustRefuse() { + // AUTH_REQUIRED opted in but auth is disabled/misconfigured: refuse the + // RPC (fail closed) instead of silently passing it through. + return nil, status.Error(codes.Unavailable, "service unavailable") + } + + if !auth.canAuthorize() { return handler(ctx, req) } @@ -106,28 +112,39 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer } // Propagate tenant claims if multi-tenant mode is enabled - if os.Getenv("MULTI_TENANT_ENABLED") == "true" { - tenantID, tenantSlug, tOwner, _ := extractTenantClaims(token) - md, _ := metadata.FromIncomingContext(ctx) - md = md.Copy() + ctx, _ = tenantContext(ctx, token) - if tenantID != "" { - md.Set("md-tenant-id", tenantID) - } + return handler(ctx, req) + } +} - if tenantSlug != "" { - md.Set("md-tenant-slug", tenantSlug) - } +// tenantContext returns ctx augmented with tenant metadata (md-tenant-id/slug/owner) +// extracted from the token when MULTI_TENANT_ENABLED is set, along with whether it +// added anything. When multi-tenant mode is off it returns ctx unchanged and false. +// Shared by the unary and stream interceptors to avoid duplicating the propagation +// logic; the bool lets the stream interceptor decide whether to wrap its stream. +func tenantContext(ctx context.Context, token string) (context.Context, bool) { + if os.Getenv("MULTI_TENANT_ENABLED") != "true" { + return ctx, false + } - if tOwner != "" { - md.Set("md-tenant-owner", tOwner) - } + tenantID, tenantSlug, tOwner, _ := extractTenantClaims(token) + md, _ := metadata.FromIncomingContext(ctx) + md = md.Copy() - ctx = metadata.NewIncomingContext(ctx, md) - } + if tenantID != "" { + md.Set("md-tenant-id", tenantID) + } - return handler(ctx, req) + if tenantSlug != "" { + md.Set("md-tenant-slug", tenantSlug) } + + if tOwner != "" { + md.Set("md-tenant-owner", tOwner) + } + + return metadata.NewIncomingContext(ctx, md), true } // extractTokenFromMD returns the bearer token from incoming metadata "authorization". @@ -274,7 +291,13 @@ func extractTenantClaims(tokenString string) (tenantID, tenantSlug, owner string // - Propagates tenant claims when MULTI_TENANT_ENABLED=true. func NewGRPCAuthStreamPolicy(auth *AuthClient, cfg PolicyConfig) grpc.StreamServerInterceptor { return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error { - if auth == nil || !auth.Enabled || auth.Address == "" { + if auth.mustRefuse() { + // AUTH_REQUIRED opted in but auth is disabled/misconfigured: refuse the + // stream (fail closed) instead of silently passing it through. + return status.Error(codes.Unavailable, "service unavailable") + } + + if !auth.canAuthorize() { return handler(srv, ss) } @@ -314,25 +337,8 @@ func NewGRPCAuthStreamPolicy(auth *AuthClient, cfg PolicyConfig) grpc.StreamServ } // Propagate tenant claims if multi-tenant mode is enabled - if os.Getenv("MULTI_TENANT_ENABLED") == "true" { - tenantID, tenantSlug, tOwner, _ := extractTenantClaims(token) - md, _ := metadata.FromIncomingContext(ctx) - md = md.Copy() - - if tenantID != "" { - md.Set("md-tenant-id", tenantID) - } - - if tenantSlug != "" { - md.Set("md-tenant-slug", tenantSlug) - } - - if tOwner != "" { - md.Set("md-tenant-owner", tOwner) - } - - ctx = metadata.NewIncomingContext(ctx, md) - ss = &wrappedServerStream{ServerStream: ss, ctx: ctx} + if tenantCtx, wrapped := tenantContext(ctx, token); wrapped { + ss = &wrappedServerStream{ServerStream: ss, ctx: tenantCtx} } return handler(srv, ss) diff --git a/auth/middleware/middlewareGRPC_test.go b/auth/middleware/middlewareGRPC_test.go index a6f942f..22f282d 100644 --- a/auth/middleware/middlewareGRPC_test.go +++ b/auth/middleware/middlewareGRPC_test.go @@ -487,6 +487,50 @@ func TestNewGRPCAuthUnaryPolicy(t *testing.T) { assert.True(t, called) }) + t.Run("required_and_disabled_refuses_with_unavailable", func(t *testing.T) { + t.Parallel() + + called := false + handler := func(_ context.Context, _ any) (any, error) { + called = true + return "ok", nil + } + + auth := &AuthClient{Address: "http://localhost:9999", Enabled: false, Required: true} + interceptor := NewGRPCAuthUnaryPolicy(auth, PolicyConfig{}) + + resp, err := interceptor(context.Background(), "req", dummyInfo, handler) + require.Error(t, err) + assert.Nil(t, resp) + assert.False(t, called) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unavailable, st.Code()) + }) + + t.Run("required_and_empty_address_refuses_with_unavailable", func(t *testing.T) { + t.Parallel() + + called := false + handler := func(_ context.Context, _ any) (any, error) { + called = true + return "ok", nil + } + + auth := &AuthClient{Address: "", Enabled: true, Required: true} + interceptor := NewGRPCAuthUnaryPolicy(auth, PolicyConfig{}) + + resp, err := interceptor(context.Background(), "req", dummyInfo, handler) + require.Error(t, err) + assert.Nil(t, resp) + assert.False(t, called) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unavailable, st.Code()) + }) + t.Run("missing_token_returns_unauthenticated", func(t *testing.T) { t.Parallel() @@ -1047,6 +1091,30 @@ func TestNewGRPCAuthStreamPolicy(t *testing.T) { assert.True(t, called) }) + t.Run("required_and_disabled_refuses_with_unavailable", func(t *testing.T) { + t.Parallel() + + called := false + + handler := func(_ any, _ grpc.ServerStream) error { + called = true + return nil + } + + auth := &AuthClient{Address: "http://localhost:9999", Enabled: false, Required: true} + interceptor := NewGRPCAuthStreamPolicy(auth, PolicyConfig{}) + + ss := &fakeServerStream{ctx: context.Background()} + + err := interceptor(nil, ss, dummyInfo, handler) + require.Error(t, err) + assert.False(t, called) + + st, ok := status.FromError(err) + require.True(t, ok) + assert.Equal(t, codes.Unavailable, st.Code()) + }) + t.Run("missing_token_returns_unauthenticated", func(t *testing.T) { t.Parallel() diff --git a/auth/middleware/middleware_test.go b/auth/middleware/middleware_test.go index 948f3a1..8b112ca 100644 --- a/auth/middleware/middleware_test.go +++ b/auth/middleware/middleware_test.go @@ -3,6 +3,7 @@ package middleware import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "strings" @@ -10,6 +11,7 @@ import ( observability "github.com/LerianStudio/lib-observability/v2" "github.com/LerianStudio/lib-observability/v2/log" + "github.com/gofiber/fiber/v3" jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -695,6 +697,115 @@ func TestNewAuthClient_ReadsForwardM2MProductFlag(t *testing.T) { }) } +// --------------------------------------------------------------------------- +// NewAuthClient - Required (AUTH_REQUIRED) flag +// --------------------------------------------------------------------------- + +func TestNewAuthClient_ReadsRequiredFlag(t *testing.T) { + // Cannot use t.Parallel(): subtests use t.Setenv which modifies process env. + // enabled=false / empty address returns early without any network call, so the + // flag wiring is exercised in isolation. + logger := log.Logger(&testLogger{}) + + t.Run("flag_true_enables_required", func(t *testing.T) { + t.Setenv("AUTH_REQUIRED", "true") + + client := NewAuthClient("", false, &logger) + assert.True(t, client.Required) + }) + + t.Run("flag_absent_defaults_false", func(t *testing.T) { + t.Setenv("AUTH_REQUIRED", "") + + client := NewAuthClient("", false, &logger) + assert.False(t, client.Required) + }) + + t.Run("flag_non_true_value_is_false", func(t *testing.T) { + t.Setenv("AUTH_REQUIRED", "1") + + client := NewAuthClient("", false, &logger) + assert.False(t, client.Required) + }) +} + +// --------------------------------------------------------------------------- +// Authorize - fail-closed (AUTH_REQUIRED) posture +// --------------------------------------------------------------------------- + +func TestAuthorize_FailClosed(t *testing.T) { + t.Parallel() + + const reached = "reached handler" + + newApp := func(auth *AuthClient) *fiber.App { + app := fiber.New() + app.Get("/x", auth.Authorize("product", "resource", "get"), func(c fiber.Ctx) error { + return c.SendString(reached) + }) + + return app + } + + t.Run("required_and_disabled_refuses_with_503", func(t *testing.T) { + t.Parallel() + + auth := &AuthClient{Enabled: false, Required: true, Logger: &testLogger{}} + + resp, err := newApp(auth).Test(httptest.NewRequest(http.MethodGet, "/x", nil)) + require.NoError(t, err) + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + }) + + t.Run("required_and_empty_address_refuses_with_503", func(t *testing.T) { + t.Parallel() + + auth := &AuthClient{Address: "", Enabled: true, Required: true, Logger: &testLogger{}} + + resp, err := newApp(auth).Test(httptest.NewRequest(http.MethodGet, "/x", nil)) + require.NoError(t, err) + assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode) + }) + + t.Run("not_required_and_disabled_passes_through", func(t *testing.T) { + t.Parallel() + + auth := &AuthClient{Enabled: false, Required: false, Logger: &testLogger{}} + + resp, err := newApp(auth).Test(httptest.NewRequest(http.MethodGet, "/x", nil)) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, reached, string(body)) + }) + + t.Run("required_and_enabled_authorizes_normally", func(t *testing.T) { + t.Parallel() + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{Address: server.URL, Enabled: true, Required: true, Logger: &testLogger{}} + + req := httptest.NewRequest(http.MethodGet, "/x", nil) + req.Header.Set("Authorization", "Bearer "+createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "org1", + "sub": "user1", + })) + + resp, err := newApp(auth).Test(req) + require.NoError(t, err) + assert.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + assert.Equal(t, reached, string(body)) + }) +} + // --------------------------------------------------------------------------- // GetApplicationToken // --------------------------------------------------------------------------- From c0469e6741e7a20e5400b82d2912f7ea2630f016 Mon Sep 17 00:00:00 2001 From: Fred Amaral Date: Tue, 21 Jul 2026 09:43:38 -0300 Subject: [PATCH 16/22] feat(middleware): add authz cache, circuit breaker, and bounded retry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every authorized request made a synchronous POST /v1/authorize with no cache, breaker, or retry, so an authz-service blip or outage amplified straight into the request path. The call was also built with http.NewRequest (no context), so a caller's deadline/cancellation never reached it. Add three opt-in resilience layers (all default-off, so default behavior is byte-for-byte unchanged) plus the context fix, composed as breaker(retry(httpCall(ctx))) with the cache checked before the breaker: - Prereq fix: build the authz request with http.NewRequestWithContext, and wrap each call in context.WithTimeout(ctx, AUTH_TIMEOUT) (default 30s = behavior-neutral). The caller's cancellation now aborts the call, and the deadline caps the retry budget. - TTL decision cache (AUTH_CACHE_TTL > 0) keyed by (sub, resource, action, product) — never the raw token. Bounded, sharded, no-dependency map with lazy expiry (no background goroutine to leak). get never returns an expired entry. Documented staleness/revocation-lag window = TTL. - Circuit breaker (AUTH_BREAKER_ENABLED, sony/gobreaker promoted to direct). On open it serves ONLY a fresh positive cache hit (checked before the breaker), otherwise denies — never a stale grant. - Bounded retry (AUTH_RETRY_MAX, cenkalti/backoff/v5) on TRANSIENT failures only (network/timeout/5xx); authoritative 401/403 are never retried. Every fallback — breaker open, retry exhausted, timeout — denies (fail closed, 403 / PermissionDenied). This complements #107: misconfig at construction -> 503 (never mounts open); runtime outage -> deny. Two triggers, both fail-closed. Stacks on #107 (PR #127): built on its Required fail-closed flag. Must merge after #127; rebase onto develop and retarget base to develop once #127 lands. Closes #108 Claude-Session: https://claude.ai/code/session_01RcQLK4qK1kbpvXWjuAR3Xh --- README.md | 20 ++ auth/middleware/decisioncache.go | 136 +++++++++ auth/middleware/middleware.go | 138 ++++----- auth/middleware/resilience.go | 278 +++++++++++++++++ auth/middleware/resilience_test.go | 470 +++++++++++++++++++++++++++++ go.mod | 4 +- 6 files changed, 966 insertions(+), 80 deletions(-) create mode 100644 auth/middleware/decisioncache.go create mode 100644 auth/middleware/resilience.go create mode 100644 auth/middleware/resilience_test.go diff --git a/README.md b/README.md index 29c14ae..bbed4c0 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,26 @@ AUTH_M2M_PRODUCT_FORWARD_ENABLED=false # fail-open behavior. Set it in security-sensitive deployments so a # missing/typo'd address cannot silently downgrade a protected service to open. AUTH_REQUIRED=false + +# Optional authorization resilience (all opt-in; defaults preserve prior behavior). +# Every fallback denies (fail closed) — no path serves a request on an outage. +# +# AUTH_TIMEOUT bounds each authorization round-trip with a per-request deadline +# (Go duration). Defaults to 30s (behavior-neutral). It also caps the retry budget. +AUTH_TIMEOUT=30s +# AUTH_CACHE_TTL enables a short-lived decision cache when > 0, keyed by +# (subject, resource, action, product) — never the token. Empty/0 disables it +# (default). Security tradeoff: a permission revocation takes up to the TTL to +# propagate, so keep it small (5–15s). It sheds load and, with the breaker, +# survives brief authz outages by serving fresh positive decisions. +AUTH_CACHE_TTL= +# AUTH_BREAKER_ENABLED opens a circuit breaker after sustained authz failures. +# While open it serves ONLY a fresh positive cache hit and otherwise denies; +# it never serves a stale decision. Defaults to disabled. +AUTH_BREAKER_ENABLED=false +# AUTH_RETRY_MAX retries only TRANSIENT failures (network/timeout/5xx) up to N +# times within AUTH_TIMEOUT; authoritative 401/403 are never retried. 0 disables. +AUTH_RETRY_MAX=0 ``` ### 2. Create a new instance of the middleware: diff --git a/auth/middleware/decisioncache.go b/auth/middleware/decisioncache.go new file mode 100644 index 0000000..8ff8ba8 --- /dev/null +++ b/auth/middleware/decisioncache.go @@ -0,0 +1,136 @@ +package middleware + +import ( + "hash/fnv" + "sync" + "time" +) + +// decisionCacheShards is the fixed shard count. Sharding spreads lock contention +// across the authorization hot path so a single mutex is not serialized on every +// authorized request. Fixed (not configurable) — 16 is ample for the expected +// concurrency and keeps the type dependency-free. +const decisionCacheShards = 16 + +// decisionCacheMaxPerShard bounds memory under a burst of distinct subjects. +// ponytail: soft cap with sweep-on-write + single eviction, not an LRU; the short +// TTL makes entries self-expire quickly, so this only guards a pathological +// cardinality spike. Raise it (or swap for an LRU) only if eviction pressure shows +// up in practice. +const decisionCacheMaxPerShard = 1024 + +// cacheKey identifies an authorization decision by the inputs that determine it — +// the exact fields sent to the authz service. It NEVER contains the raw token: two +// tokens for the same subject must share a decision, and a token must never become +// a cache key (it would leak into memory keyed by a secret). +type cacheKey struct { + sub string + resource string + action string + product string +} + +// cacheEntry is a cached authorization decision with its expiry. +type cacheEntry struct { + authorized bool + expiresAt time.Time +} + +type cacheShard struct { + mu sync.Mutex + entries map[cacheKey]cacheEntry +} + +// decisionCache is a bounded, sharded, TTL authorization-decision cache. Expiry is +// lazy (checked on read, swept on write) so there is no background goroutine to +// leak. get never returns an expired entry, which is what keeps the breaker-open +// fallback fail-closed: a stale grant is never served. +type decisionCache struct { + ttl time.Duration + shards [decisionCacheShards]*cacheShard +} + +// newDecisionCache builds an enabled cache with the given TTL. ttl must be > 0 +// (callers gate on that before constructing). +func newDecisionCache(ttl time.Duration) *decisionCache { + c := &decisionCache{ttl: ttl} + for i := range c.shards { + c.shards[i] = &cacheShard{entries: make(map[cacheKey]cacheEntry)} + } + + return c +} + +// shardFor selects the shard for a key by hashing its fields with a separator, so +// distinct field boundaries cannot collide (e.g. {"a","b"} vs {"ab",""}). +func (c *decisionCache) shardFor(k cacheKey) *cacheShard { + h := fnv.New32a() + _, _ = h.Write([]byte(k.sub + "\x00" + k.resource + "\x00" + k.action + "\x00" + k.product)) + + return c.shards[h.Sum32()%decisionCacheShards] +} + +// get returns the cached decision for k and whether a FRESH entry exists. An +// expired entry is treated as absent (and evicted); callers therefore never see a +// stale decision — critical for the breaker-open path, which must not serve +// expired grants. +func (c *decisionCache) get(k cacheKey) (authorized, ok bool) { + shard := c.shardFor(k) + + shard.mu.Lock() + defer shard.mu.Unlock() + + entry, found := shard.entries[k] + if !found { + return false, false + } + + if time.Now().After(entry.expiresAt) { + delete(shard.entries, k) + + return false, false + } + + return entry.authorized, true +} + +// set stores a decision for k with the cache TTL. When the shard is at its soft +// cap it first sweeps expired entries and, if still full, evicts a single entry so +// the cache stays bounded. +func (c *decisionCache) set(k cacheKey, authorized bool) { + shard := c.shardFor(k) + + shard.mu.Lock() + defer shard.mu.Unlock() + + if len(shard.entries) >= decisionCacheMaxPerShard { + evictShard(shard) + } + + shard.entries[k] = cacheEntry{authorized: authorized, expiresAt: time.Now().Add(c.ttl)} +} + +// evictShard drops expired entries; if none were expired it removes one arbitrary +// entry so an insert can proceed without unbounded growth. Caller holds shard.mu. +func evictShard(shard *cacheShard) { + now := time.Now() + evicted := false + + for key, entry := range shard.entries { + if now.After(entry.expiresAt) { + delete(shard.entries, key) + + evicted = true + } + } + + if evicted { + return + } + + for key := range shard.entries { + delete(shard.entries, key) + + return + } +} diff --git a/auth/middleware/middleware.go b/auth/middleware/middleware.go index f738b01..498a16c 100644 --- a/auth/middleware/middleware.go +++ b/auth/middleware/middleware.go @@ -24,6 +24,7 @@ import ( libHTTP "github.com/LerianStudio/lib-commons/v6/commons/net/http" "github.com/gofiber/fiber/v3" jwt "github.com/golang-jwt/jwt/v5" + "github.com/sony/gobreaker" ) type AuthClient struct { @@ -49,6 +50,29 @@ type AuthClient struct { // missing/typo'd address can no longer silently downgrade a protected service // to fully open. Required bool + + // timeout bounds each authorization round-trip via a per-request context + // deadline. Read once from AUTH_TIMEOUT; defaults to 30s (behavior-neutral, + // matching the prior client-wide HTTP timeout) when unset/invalid. It also caps + // the retry budget. + timeout time.Duration + + // cache, when non-nil, memoizes authorization decisions for a short TTL keyed by + // (sub, resource, action, product) — NEVER the raw token. Enabled by + // AUTH_CACHE_TTL > 0 (opt-in; nil = no caching, prior behavior). It trades a + // bounded revocation-propagation lag (= TTL) for load shedding and outage + // resilience. + cache *decisionCache + + // breaker, when non-nil, short-circuits the authorization call after sustained + // failures (opt-in via AUTH_BREAKER_ENABLED). While open it serves only a fresh + // positive cache hit and otherwise denies — it never fails open. + breaker *gobreaker.CircuitBreaker + + // retryMax is the maximum number of retries applied to TRANSIENT authorization + // failures (network/timeout/5xx). Read once from AUTH_RETRY_MAX; 0 disables + // retry (opt-in). Authoritative decisions (401/403) are never retried. + retryMax uint } type AuthResponse struct { @@ -184,22 +208,27 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient } } - forwardM2MProduct := os.Getenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED") == "true" - required := os.Getenv("AUTH_REQUIRED") == "true" + // Build the client once with all env-derived config, then return it from every + // path below; the health check only logs, it never changes these fields. + c := &AuthClient{ + Address: address, + Enabled: enabled, + Logger: l, + ForwardM2MProduct: os.Getenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED") == "true", + Required: os.Getenv("AUTH_REQUIRED") == "true", + timeout: parseAuthTimeout(), + cache: newDecisionCacheFromEnv(), + breaker: newBreakerFromEnv(), + retryMax: parseRetryMax(), + } if !enabled || address == "" { - if required { + if c.Required { logErrorf(context.Background(), l, "AUTH_REQUIRED is set but auth is disabled or address is empty: middleware will fail closed (refuse to serve)") } - return &AuthClient{ - Address: address, - Enabled: enabled, - Logger: l, - ForwardM2MProduct: forwardM2MProduct, - Required: required, - } + return c } client := sharedHTTPClient @@ -211,21 +240,21 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient if err != nil { logErrorf(context.Background(), l, failedToConnectMsg, err) - return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required} + return c } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { logErrorf(context.Background(), l, failedToConnectMsg, resp.Status) - return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required} + return c } body, err := io.ReadAll(resp.Body) if err != nil { logErrorf(context.Background(), l, "Failed to read response body: %v", err) - return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required} + return c } if string(body) == "healthy" { @@ -234,13 +263,7 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient logErrorf(context.Background(), l, failedToConnectMsg, string(body)) } - return &AuthClient{ - Address: address, - Enabled: enabled, - Logger: l, - ForwardM2MProduct: forwardM2MProduct, - Required: required, - } + return c } // canAuthorize reports whether the client is able to perform an authorization @@ -398,7 +421,11 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resourc attribute.String("app.request.request_id", reqID), ) - client := sharedHTTPClient + // Per-request deadline: propagate the caller's cancellation/deadline to the + // authz call (the request was previously built without a context, so an upstream + // cancel could not abort it) and cap the retry budget. Defaults to 30s. + ctx, cancel := context.WithTimeout(ctx, auth.requestTimeout()) + defer cancel() token, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{}) if err != nil { @@ -453,66 +480,21 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resourc return false, http.StatusInternalServerError, err } - req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/v1/authorize", auth.Address), bytes.NewBuffer(requestBodyJSON)) - if err != nil { - logErrorf(ctx, auth.Logger, "Failed to create request: %v", err) - - tracing.HandleSpanError(span, "Failed to create request", err) + // Cache key = exactly the authz request inputs (never the raw token). product is + // the value actually forwarded ("" when not forwarded), so the key matches the + // decision the authz service made. + key := cacheKey{sub: sub, resource: resource, action: action, product: requestBody["product"]} - return false, http.StatusInternalServerError, fmt.Errorf("failed to create request: %w", err) - } - - tracing.InjectHTTPContext(ctx, req.Header) - - req.Header.Set("Content-Type", "application/json") - req.Header.Set("Authorization", accessToken) - - resp, err := client.Do(req) - if err != nil { - logErrorf(ctx, auth.Logger, "Failed to make request: %v", err) - - tracing.HandleSpanError(span, "Failed to make request", err) - - return false, http.StatusInternalServerError, fmt.Errorf("failed to make request: %w", err) - } - defer resp.Body.Close() - - body, err := io.ReadAll(resp.Body) - if err != nil { - logErrorf(ctx, auth.Logger, "Failed to read response body: %v", err) - - tracing.HandleSpanError(span, "Failed to read response body", err) - - return false, http.StatusInternalServerError, fmt.Errorf("failed to read response body: %w", err) - } - - respError, err := unmarshalErrorResponse(body) - if err != nil { - logErrorf(ctx, auth.Logger, "Failed to unmarshal auth error response: %v", err) - - tracing.HandleSpanError(span, "Failed to unmarshal auth error response", err) - - return false, http.StatusInternalServerError, fmt.Errorf("failed to unmarshal auth error response: %w", err) - } - - if respError.Code != "" && resp.StatusCode != http.StatusInternalServerError { - logErrorf(ctx, auth.Logger, "Authorization request failed: %s", respError.Message) - - tracing.HandleSpanError(span, "Authorization request failed", respError) - - return false, resp.StatusCode, respError - } - - var response AuthResponse - if err := json.Unmarshal(body, &response); err != nil { - logErrorf(ctx, auth.Logger, "Failed to unmarshal response: %v", err) - - tracing.HandleSpanError(span, "Failed to unmarshal response", err) - - return false, http.StatusInternalServerError, fmt.Errorf("failed to unmarshal response: %w", err) + // A fresh cache hit (positive OR negative) short-circuits before the breaker, so + // the breaker only ever runs on a miss — its open state then denies (never + // serving a stale grant). + if auth.cache != nil { + if authorized, hit := auth.cache.get(key); hit { + return authorized, http.StatusOK, nil + } } - return response.Authorized, resp.StatusCode, nil + return auth.resolveAuthz(ctx, span, accessToken, requestBodyJSON, key) } // GetApplicationToken sends a POST request to the authorization service to get a token for the application. diff --git a/auth/middleware/resilience.go b/auth/middleware/resilience.go new file mode 100644 index 0000000..d3cbc89 --- /dev/null +++ b/auth/middleware/resilience.go @@ -0,0 +1,278 @@ +package middleware + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "strconv" + "strings" + "time" + + "github.com/LerianStudio/lib-observability/v2/tracing" + "github.com/cenkalti/backoff/v5" + "github.com/sony/gobreaker" + "go.opentelemetry.io/otel/trace" +) + +const ( + // defaultAuthTimeout bounds each authorization round-trip when AUTH_TIMEOUT is + // unset/invalid. 30s matches the prior client-wide HTTP timeout, so the default + // is behavior-neutral. + defaultAuthTimeout = 30 * time.Second + + // defaultBreakerMaxFailures is the consecutive-failure count that trips the + // breaker when AUTH_BREAKER_ENABLED is set. + defaultBreakerMaxFailures uint32 = 5 + + // defaultBreakerOpenTimeout is how long the breaker stays open before probing. + defaultBreakerOpenTimeout = 30 * time.Second +) + +// authzOutcome is the result of one authorization round-trip, decoupled from +// checkAuthorization's (bool, int, error) contract so the retry/breaker layer can +// distinguish an authoritative decision from a transient failure: +// - authErr != nil -> surface this error with statusCode (an authoritative coded +// deny, or an internal parse/marshal failure) — exactly what the pre-resilience +// code returned. +// - authErr == nil -> a clean decision: authorized at statusCode. +type authzOutcome struct { + authorized bool + statusCode int + authErr error +} + +// requestTimeout is the per-request authorization deadline, falling back to the +// default when unset (e.g. a struct-literal client in tests). +func (auth *AuthClient) requestTimeout() time.Duration { + if auth.timeout > 0 { + return auth.timeout + } + + return defaultAuthTimeout +} + +// resolveAuthz performs the authorization call through the configured resilience +// layers and maps the result to checkAuthorization's contract. Every +// non-authoritative outcome — transient failure exhausted, breaker open, timeout — +// denies (fail closed): it returns (false, 403, nil), the same "not authorized" +// path a normal denial takes, never failing open. A clean decision is cached (when +// the cache is enabled) before being returned. +func (auth *AuthClient) resolveAuthz(ctx context.Context, span trace.Span, accessToken string, body []byte, key cacheKey) (bool, int, error) { + outcome, err := auth.invokeAuthz(ctx, span, accessToken, body) + if err != nil { + logErrorf(ctx, auth.Logger, "Authorization unavailable, denying (fail closed): %v", err) + tracing.HandleSpanError(span, "Authorization unavailable, denying", err) + + return false, http.StatusForbidden, nil + } + + if outcome.authErr != nil { + return false, outcome.statusCode, outcome.authErr + } + + if auth.cache != nil { + auth.cache.set(key, outcome.authorized) + } + + return outcome.authorized, outcome.statusCode, nil +} + +// invokeAuthz runs the authorization call under the resilience layers. Composition +// is breaker( retry( doAuthorizeCall ) ): retry absorbs transient blips; the +// breaker counts a sustained failure only once per fully-failed retry sequence +// and, once open, short-circuits. +// +// When neither retry nor breaker is enabled it makes a single call and returns the +// outcome as-is (identical to the pre-resilience behavior — transient +// classification is irrelevant without a layer to act on it). Otherwise a non-nil +// returned error means "transient failure exhausted" or "breaker open"; the caller +// denies. A nil error means the outcome is authoritative. +func (auth *AuthClient) invokeAuthz(ctx context.Context, span trace.Span, accessToken string, body []byte) (authzOutcome, error) { + call := func() (authzOutcome, error) { + return auth.doAuthorizeCall(ctx, span, accessToken, body) + } + + if auth.breaker == nil && auth.retryMax == 0 { + outcome, _ := call() + + return outcome, nil + } + + run := call + if auth.retryMax > 0 { + run = func() (authzOutcome, error) { + return backoff.Retry(ctx, call, + backoff.WithMaxTries(1+auth.retryMax), + backoff.WithMaxElapsedTime(auth.requestTimeout()), + ) + } + } + + if auth.breaker == nil { + return run() + } + + res, err := auth.breaker.Execute(func() (any, error) { + outcome, opErr := run() + + return outcome, opErr + }) + + outcome, _ := res.(authzOutcome) + + return outcome, err +} + +// doAuthorizeCall performs one POST /v1/authorize and classifies the result. The +// authzOutcome mirrors the legacy (bool, int, error) semantics exactly; the second +// return is non-nil ONLY for a TRANSIENT failure — a network error, a context +// timeout, or a 5xx — which is what the retry and breaker layers act on. +// Authoritative decisions (2xx, 401, 403) return a nil transient error, so they +// are never retried and never trip the breaker. +func (auth *AuthClient) doAuthorizeCall(ctx context.Context, span trace.Span, accessToken string, body []byte) (authzOutcome, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/v1/authorize", auth.Address), bytes.NewReader(body)) + if err != nil { + logErrorf(ctx, auth.Logger, "Failed to create request: %v", err) + tracing.HandleSpanError(span, "Failed to create request", err) + + return authzOutcome{statusCode: http.StatusInternalServerError, authErr: fmt.Errorf("failed to create request: %w", err)}, nil + } + + tracing.InjectHTTPContext(ctx, req.Header) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", accessToken) + + resp, err := sharedHTTPClient.Do(req) + if err != nil { + logErrorf(ctx, auth.Logger, "Failed to make request: %v", err) + tracing.HandleSpanError(span, "Failed to make request", err) + + wrapped := fmt.Errorf("failed to make request: %w", err) + + return authzOutcome{statusCode: http.StatusInternalServerError, authErr: wrapped}, wrapped + } + defer resp.Body.Close() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + logErrorf(ctx, auth.Logger, "Failed to read response body: %v", err) + tracing.HandleSpanError(span, "Failed to read response body", err) + + wrapped := fmt.Errorf("failed to read response body: %w", err) + + return authzOutcome{statusCode: http.StatusInternalServerError, authErr: wrapped}, wrapped + } + + outcome := auth.classifyResponse(ctx, span, resp.StatusCode, respBody) + + if resp.StatusCode >= http.StatusInternalServerError { + // 5xx is transient for the resilience layer regardless of how it is surfaced. + return outcome, fmt.Errorf("authz service returned status %d", resp.StatusCode) + } + + return outcome, nil +} + +// classifyResponse converts an authz HTTP response (status + body) into an +// authzOutcome, reproducing the pre-resilience decision logic exactly: a coded +// error body is an authoritative deny at its status; otherwise the AuthResponse +// decides; unparseable bodies are internal errors. +func (auth *AuthClient) classifyResponse(ctx context.Context, span trace.Span, statusCode int, body []byte) authzOutcome { + respError, err := unmarshalErrorResponse(body) + if err != nil { + logErrorf(ctx, auth.Logger, "Failed to unmarshal auth error response: %v", err) + tracing.HandleSpanError(span, "Failed to unmarshal auth error response", err) + + return authzOutcome{statusCode: http.StatusInternalServerError, authErr: fmt.Errorf("failed to unmarshal auth error response: %w", err)} + } + + if respError.Code != "" && statusCode != http.StatusInternalServerError { + logErrorf(ctx, auth.Logger, "Authorization request failed: %s", respError.Message) + tracing.HandleSpanError(span, "Authorization request failed", respError) + + return authzOutcome{statusCode: statusCode, authErr: respError} + } + + var response AuthResponse + if err := json.Unmarshal(body, &response); err != nil { + logErrorf(ctx, auth.Logger, "Failed to unmarshal response: %v", err) + tracing.HandleSpanError(span, "Failed to unmarshal response", err) + + return authzOutcome{statusCode: http.StatusInternalServerError, authErr: fmt.Errorf("failed to unmarshal response: %w", err)} + } + + return authzOutcome{authorized: response.Authorized, statusCode: statusCode} +} + +// newAuthBreaker builds a circuit breaker that opens after maxFailures consecutive +// failures and stays open for openTimeout before probing with a single request. +func newAuthBreaker(maxFailures uint32, openTimeout time.Duration) *gobreaker.CircuitBreaker { + return gobreaker.NewCircuitBreaker(gobreaker.Settings{ + Name: "lib-auth-authorize", + MaxRequests: 1, + Timeout: openTimeout, + ReadyToTrip: func(counts gobreaker.Counts) bool { + return counts.ConsecutiveFailures >= maxFailures + }, + }) +} + +// parseAuthTimeout reads AUTH_TIMEOUT (a Go duration, e.g. "5s"), falling back to +// the behavior-neutral default when unset or invalid. +func parseAuthTimeout() time.Duration { + if v := strings.TrimSpace(os.Getenv("AUTH_TIMEOUT")); v != "" { + if d, err := time.ParseDuration(v); err == nil && d > 0 { + return d + } + } + + return defaultAuthTimeout +} + +// newDecisionCacheFromEnv builds the decision cache when AUTH_CACHE_TTL is a +// positive Go duration, and nil (disabled) otherwise. The cache trades a bounded +// revocation-propagation lag (up to the TTL) for load shedding and outage +// resilience — a documented security tradeoff, kept tight by a small TTL. +func newDecisionCacheFromEnv() *decisionCache { + v := strings.TrimSpace(os.Getenv("AUTH_CACHE_TTL")) + if v == "" { + return nil + } + + d, err := time.ParseDuration(v) + if err != nil || d <= 0 { + return nil + } + + return newDecisionCache(d) +} + +// newBreakerFromEnv builds the circuit breaker when AUTH_BREAKER_ENABLED=="true", +// and nil (disabled) otherwise. +func newBreakerFromEnv() *gobreaker.CircuitBreaker { + if os.Getenv("AUTH_BREAKER_ENABLED") != "true" { + return nil + } + + return newAuthBreaker(defaultBreakerMaxFailures, defaultBreakerOpenTimeout) +} + +// parseRetryMax reads AUTH_RETRY_MAX (the maximum number of retries applied to +// transient failures), returning 0 (disabled) when unset or invalid. +func parseRetryMax() uint { + v := strings.TrimSpace(os.Getenv("AUTH_RETRY_MAX")) + if v == "" { + return 0 + } + + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + return 0 + } + + return uint(n) +} diff --git a/auth/middleware/resilience_test.go b/auth/middleware/resilience_test.go new file mode 100644 index 0000000..9edf14f --- /dev/null +++ b/auth/middleware/resilience_test.go @@ -0,0 +1,470 @@ +package middleware + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/LerianStudio/lib-observability/v2/log" + jwt "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// userToken builds a normal-user test token (HS256; this branch does not verify +// signatures, so any signing key is fine). +func userToken() string { + return createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "acme-org", + "sub": "user-1", + }) +} + +// countingAuthServer returns a server that records how many /v1/authorize requests +// it received and responds per the supplied handler. +func countingAuthServer(t *testing.T, handler func(w http.ResponseWriter, r *http.Request, n int64)) (*httptest.Server, *atomic.Int64) { + t.Helper() + + var hits atomic.Int64 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := hits.Add(1) + handler(w, r, n) + })) + + t.Cleanup(server.Close) + + return server, &hits +} + +func writeAuthorized(w http.ResponseWriter, authorized bool) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(AuthResponse{Authorized: authorized}) +} + +// --------------------------------------------------------------------------- +// decisionCache (unit) +// --------------------------------------------------------------------------- + +func TestDecisionCache_SetGetFresh(t *testing.T) { + t.Parallel() + + c := newDecisionCache(time.Minute) + key := cacheKey{sub: "s", resource: "r", action: "a", product: "p"} + + c.set(key, true) + + authorized, ok := c.get(key) + require.True(t, ok) + assert.True(t, authorized) +} + +func TestDecisionCache_NegativeDecisionCached(t *testing.T) { + t.Parallel() + + c := newDecisionCache(time.Minute) + key := cacheKey{sub: "s", resource: "r", action: "a"} + + c.set(key, false) + + authorized, ok := c.get(key) + require.True(t, ok) + assert.False(t, authorized) +} + +func TestDecisionCache_ExpiredEntryNotReturned(t *testing.T) { + t.Parallel() + + c := newDecisionCache(15 * time.Millisecond) + key := cacheKey{sub: "s", resource: "r", action: "a"} + + c.set(key, true) + + time.Sleep(40 * time.Millisecond) + + _, ok := c.get(key) + assert.False(t, ok, "an expired entry must never be served (would be fail-open under an outage)") +} + +func TestDecisionCache_KeyFieldsDoNotCollide(t *testing.T) { + t.Parallel() + + c := newDecisionCache(time.Minute) + + c.set(cacheKey{sub: "a", resource: "b"}, true) + c.set(cacheKey{sub: "ab", resource: ""}, false) + + got1, ok1 := c.get(cacheKey{sub: "a", resource: "b"}) + got2, ok2 := c.get(cacheKey{sub: "ab", resource: ""}) + + require.True(t, ok1) + require.True(t, ok2) + assert.True(t, got1) + assert.False(t, got2) +} + +func TestDecisionCache_BoundedUnderManyKeys(t *testing.T) { + t.Parallel() + + c := newDecisionCache(time.Minute) + + // Insert far more distinct keys than a single shard's cap to exercise eviction. + total := decisionCacheShards * decisionCacheMaxPerShard * 2 + for i := 0; i < total; i++ { + c.set(cacheKey{sub: "s", resource: "r", action: "a", product: string(rune(i)) + "-" + time.Now().String()}, true) + } + + size := 0 + for _, shard := range c.shards { + shard.mu.Lock() + size += len(shard.entries) + shard.mu.Unlock() + } + + assert.LessOrEqual(t, size, decisionCacheShards*decisionCacheMaxPerShard, "cache must stay bounded") +} + +// --------------------------------------------------------------------------- +// Cache integration via checkAuthorization +// --------------------------------------------------------------------------- + +func TestCheckAuthorization_CacheHit_AvoidsSecondPost(t *testing.T) { + t.Parallel() + + server, hits := countingAuthServer(t, func(w http.ResponseWriter, _ *http.Request, _ int64) { + writeAuthorized(w, true) + }) + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + cache: newDecisionCache(time.Minute), + } + + for i := 0; i < 3; i++ { + authorized, statusCode, err := auth.checkAuthorization(context.Background(), "", "res", "read", userToken()) + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) + } + + assert.Equal(t, int64(1), hits.Load(), "cache hits must avoid re-querying the authz service") +} + +func TestCheckAuthorization_CacheExpiry_RequeriesAuthz(t *testing.T) { + t.Parallel() + + server, hits := countingAuthServer(t, func(w http.ResponseWriter, _ *http.Request, _ int64) { + writeAuthorized(w, true) + }) + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + cache: newDecisionCache(15 * time.Millisecond), + } + + _, _, err := auth.checkAuthorization(context.Background(), "", "res", "read", userToken()) + require.NoError(t, err) + + time.Sleep(40 * time.Millisecond) + + _, _, err = auth.checkAuthorization(context.Background(), "", "res", "read", userToken()) + require.NoError(t, err) + + assert.Equal(t, int64(2), hits.Load(), "an expired cache entry must trigger a fresh authz query") +} + +func TestCheckAuthorization_NegativeCache_Served(t *testing.T) { + t.Parallel() + + server, hits := countingAuthServer(t, func(w http.ResponseWriter, _ *http.Request, _ int64) { + writeAuthorized(w, false) + }) + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + cache: newDecisionCache(time.Minute), + } + + for i := 0; i < 2; i++ { + authorized, _, err := auth.checkAuthorization(context.Background(), "", "res", "read", userToken()) + require.NoError(t, err) + assert.False(t, authorized) + } + + assert.Equal(t, int64(1), hits.Load(), "a cached denial is served without re-querying") +} + +// --------------------------------------------------------------------------- +// Retry +// --------------------------------------------------------------------------- + +func TestCheckAuthorization_Retry_On5xx_EventuallySucceeds(t *testing.T) { + t.Parallel() + + server, hits := countingAuthServer(t, func(w http.ResponseWriter, _ *http.Request, n int64) { + if n == 1 { + w.WriteHeader(http.StatusInternalServerError) + + return + } + + writeAuthorized(w, true) + }) + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + timeout: 2 * time.Second, + retryMax: 2, + } + + authorized, statusCode, err := auth.checkAuthorization(context.Background(), "", "res", "read", userToken()) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) + assert.Equal(t, int64(2), hits.Load(), "a transient 5xx must be retried") +} + +func TestCheckAuthorization_Retry_NotOn403(t *testing.T) { + t.Parallel() + + server, hits := countingAuthServer(t, func(w http.ResponseWriter, _ *http.Request, _ int64) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusForbidden) + _ = json.NewEncoder(w).Encode(map[string]string{"code": "FORBIDDEN", "message": "no"}) + }) + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + timeout: 2 * time.Second, + retryMax: 3, + } + + authorized, statusCode, err := auth.checkAuthorization(context.Background(), "", "res", "write", userToken()) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusForbidden, statusCode) + assert.Equal(t, int64(1), hits.Load(), "an authoritative 403 must never be retried") +} + +// --------------------------------------------------------------------------- +// Circuit breaker +// --------------------------------------------------------------------------- + +func TestCheckAuthorization_Breaker_OpensAfterN_AndDenies(t *testing.T) { + t.Parallel() + + server, hits := countingAuthServer(t, func(w http.ResponseWriter, _ *http.Request, _ int64) { + w.WriteHeader(http.StatusInternalServerError) + }) + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + breaker: newAuthBreaker(2, time.Minute), + } + + // Two consecutive transient (5xx) failures trip the breaker. + for i := 0; i < 2; i++ { + authorized, statusCode, err := auth.checkAuthorization(context.Background(), "", "res", "read", userToken()) + require.NoError(t, err, "runtime outage denies without surfacing an error") + assert.False(t, authorized) + assert.Equal(t, http.StatusForbidden, statusCode) + } + + // Breaker now open: the next call is denied WITHOUT touching the authz service. + authorized, statusCode, err := auth.checkAuthorization(context.Background(), "", "res", "read", userToken()) + require.NoError(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusForbidden, statusCode) + + assert.Equal(t, int64(2), hits.Load(), "an open breaker must short-circuit, not reach the authz service") +} + +func TestCheckAuthorization_BreakerOpen_ServesFreshPositiveCacheOnly(t *testing.T) { + t.Parallel() + + var failing atomic.Bool + + server, hits := countingAuthServer(t, func(w http.ResponseWriter, _ *http.Request, _ int64) { + if failing.Load() { + w.WriteHeader(http.StatusInternalServerError) + + return + } + + writeAuthorized(w, true) + }) + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + cache: newDecisionCache(time.Minute), + breaker: newAuthBreaker(2, time.Minute), + } + + // Phase 1: prime a positive decision for "resCached" while the service is healthy. + authorized, _, err := auth.checkAuthorization(context.Background(), "", "resCached", "read", userToken()) + require.NoError(t, err) + require.True(t, authorized) + + // Phase 2: service fails; trip the breaker via a different (uncached) key. + failing.Store(true) + + for i := 0; i < 2; i++ { + _, _, err = auth.checkAuthorization(context.Background(), "", "resTrip", "read", userToken()) + require.NoError(t, err) + } + + hitsAfterTrip := hits.Load() + + // Breaker open + fresh positive cache hit -> allow (served from cache, no network). + authorized, statusCode, err := auth.checkAuthorization(context.Background(), "", "resCached", "read", userToken()) + require.NoError(t, err) + assert.True(t, authorized, "a fresh positive cache hit is served even while the breaker is open") + assert.Equal(t, http.StatusOK, statusCode) + + // Breaker open + no cache -> deny. + authorized, statusCode, err = auth.checkAuthorization(context.Background(), "", "resUncached", "read", userToken()) + require.NoError(t, err) + assert.False(t, authorized, "with the breaker open and no fresh cache, the request is denied") + assert.Equal(t, http.StatusForbidden, statusCode) + + assert.Equal(t, hitsAfterTrip, hits.Load(), "neither the cache hit nor the open-breaker deny may reach the authz service") +} + +// --------------------------------------------------------------------------- +// Per-request context deadline (prerequisite bug fix) +// --------------------------------------------------------------------------- + +func TestCheckAuthorization_ContextTimeout_AbortsCall(t *testing.T) { + t.Parallel() + + // Server blocks well past the client's per-request deadline. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(2 * time.Second): + } + + writeAuthorized(w, true) + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + timeout: 50 * time.Millisecond, + } + + start := time.Now() + authorized, _, err := auth.checkAuthorization(context.Background(), "", "res", "read", userToken()) + elapsed := time.Since(start) + + require.Error(t, err, "the per-request deadline must abort the authz call") + assert.False(t, authorized) + assert.Less(t, elapsed, time.Second, "the call must abort at the deadline, not wait for the server") +} + +func TestCheckAuthorization_CallerCancellation_Propagates(t *testing.T) { + t.Parallel() + + // Proves NewRequestWithContext wiring: the CALLER's cancellation reaches the + // HTTP call (previously the request was built without a context). + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-r.Context().Done(): + case <-time.After(2 * time.Second): + } + + writeAuthorized(w, true) + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + timeout: 5 * time.Second, + } + + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(50 * time.Millisecond) + cancel() + }() + + start := time.Now() + _, _, err := auth.checkAuthorization(ctx, "", "res", "read", userToken()) + elapsed := time.Since(start) + + require.Error(t, err) + assert.Less(t, elapsed, time.Second, "caller cancellation must abort the authz call") +} + +// --------------------------------------------------------------------------- +// NewAuthClient - resilience config wiring +// --------------------------------------------------------------------------- + +func TestNewAuthClient_ResilienceConfig(t *testing.T) { + // Cannot use t.Parallel(): subtests use t.Setenv. enabled=false returns early + // without any network call, exercising the config wiring in isolation. + logger := log.Logger(&testLogger{}) + + t.Run("defaults_are_behavior_neutral", func(t *testing.T) { + t.Setenv("AUTH_TIMEOUT", "") + t.Setenv("AUTH_CACHE_TTL", "") + t.Setenv("AUTH_BREAKER_ENABLED", "") + t.Setenv("AUTH_RETRY_MAX", "") + + client := NewAuthClient("", false, &logger) + assert.Equal(t, defaultAuthTimeout, client.timeout) + assert.Nil(t, client.cache) + assert.Nil(t, client.breaker) + assert.Equal(t, uint(0), client.retryMax) + }) + + t.Run("all_knobs_enabled", func(t *testing.T) { + t.Setenv("AUTH_TIMEOUT", "5s") + t.Setenv("AUTH_CACHE_TTL", "5s") + t.Setenv("AUTH_BREAKER_ENABLED", "true") + t.Setenv("AUTH_RETRY_MAX", "2") + + client := NewAuthClient("", false, &logger) + assert.Equal(t, 5*time.Second, client.timeout) + assert.NotNil(t, client.cache) + assert.NotNil(t, client.breaker) + assert.Equal(t, uint(2), client.retryMax) + }) + + t.Run("invalid_values_fall_back_to_disabled", func(t *testing.T) { + t.Setenv("AUTH_TIMEOUT", "not-a-duration") + t.Setenv("AUTH_CACHE_TTL", "0s") + t.Setenv("AUTH_RETRY_MAX", "-1") + + client := NewAuthClient("", false, &logger) + assert.Equal(t, defaultAuthTimeout, client.timeout) + assert.Nil(t, client.cache) + assert.Equal(t, uint(0), client.retryMax) + }) +} diff --git a/go.mod b/go.mod index ee5c4f7..043fadc 100644 --- a/go.mod +++ b/go.mod @@ -23,7 +23,7 @@ require ( github.com/LerianStudio/lib-observability/v2 v2.0.0-beta.1 github.com/andybalholm/brotli v1.2.2 // indirect github.com/bxcodec/dbresolver/v2 v2.2.1 // indirect - github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect @@ -49,7 +49,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/shirou/gopsutil v3.21.11+incompatible // indirect github.com/shopspring/decimal v1.4.0 // indirect - github.com/sony/gobreaker v1.0.0 // indirect + github.com/sony/gobreaker v1.0.0 github.com/tklauser/go-sysconf v0.4.0 // indirect github.com/tklauser/numcpus v0.12.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect From d0b6350fa84abd015a7ee73f972e25e1a0ec91f4 Mon Sep 17 00:00:00 2001 From: lerian-studio Date: Tue, 21 Jul 2026 12:47:51 +0000 Subject: [PATCH 17/22] chore(release): 3.0.0-beta.6 ## [3.0.0-beta.6](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.5...v3.0.0-beta.6) (2026-07-21) ### Features * **middleware:** add fail-closed AUTH_REQUIRED option ([f7fff6c](https://github.com/LerianStudio/lib-auth/commit/f7fff6c2ab78b30c4d851d2ab39afc362c8a2570)), closes [#107](https://github.com/LerianStudio/lib-auth/issues/107) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8fcc9c..493b5f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [3.0.0-beta.6](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.5...v3.0.0-beta.6) (2026-07-21) + + +### Features + +* **middleware:** add fail-closed AUTH_REQUIRED option ([f7fff6c](https://github.com/LerianStudio/lib-auth/commit/f7fff6c2ab78b30c4d851d2ab39afc362c8a2570)), closes [#107](https://github.com/LerianStudio/lib-auth/issues/107) + ## [3.0.0-beta.5](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.4...v3.0.0-beta.5) (2026-07-20) From 64dd89c554bd0e2ee6952105b803c107d0d49600 Mon Sep 17 00:00:00 2001 From: lerian-studio Date: Tue, 21 Jul 2026 12:48:57 +0000 Subject: [PATCH 18/22] chore(release): 3.0.0-beta.7 ## [3.0.0-beta.7](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.6...v3.0.0-beta.7) (2026-07-21) ### Features * **middleware:** add authz cache, circuit breaker, and bounded retry ([c0469e6](https://github.com/LerianStudio/lib-auth/commit/c0469e6741e7a20e5400b82d2912f7ea2630f016)), closes [#107](https://github.com/LerianStudio/lib-auth/issues/107) [#127](https://github.com/LerianStudio/lib-auth/issues/127) [#127](https://github.com/LerianStudio/lib-auth/issues/127) [#108](https://github.com/LerianStudio/lib-auth/issues/108) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 493b5f4..b37433b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [3.0.0-beta.7](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.6...v3.0.0-beta.7) (2026-07-21) + + +### Features + +* **middleware:** add authz cache, circuit breaker, and bounded retry ([c0469e6](https://github.com/LerianStudio/lib-auth/commit/c0469e6741e7a20e5400b82d2912f7ea2630f016)), closes [#107](https://github.com/LerianStudio/lib-auth/issues/107) [#127](https://github.com/LerianStudio/lib-auth/issues/127) [#127](https://github.com/LerianStudio/lib-auth/issues/127) [#108](https://github.com/LerianStudio/lib-auth/issues/108) + ## [3.0.0-beta.6](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.5...v3.0.0-beta.6) (2026-07-21) From 48b81a355cabbc65fc2fe229b6b214c9fa62fc37 Mon Sep 17 00:00:00 2001 From: Fred Amaral Date: Tue, 21 Jul 2026 09:20:45 -0700 Subject: [PATCH 19/22] feat(middleware): add opt-in local JWT signature verification (#128) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(middleware): add opt-in local JWT signature verification The general authorization path parsed the bearer token with ParseUnverified and trusted its claims (type/owner/sub) to build the authz subject, relying solely on the network POST to /v1/authorize as the trust anchor. The M2M gate already verified tokens properly (RS256-pinned, expiry required, optional issuer) against a static injected cert. Converge on that hardened verifier instead of adding a second (JWKS-based) mechanism: extract m2m.go's crypto core into one package-level verifyToken used by BOTH RequireM2M and checkAuthorization. Hooking it at checkAuthorization covers Fiber and both gRPC interceptors from one audited place. Verification is opt-in and gated by presence of a cert, mirroring the M2M cert gate: - AUTH_JWT_VERIFY_CERT: newline-joined PEM bundle (or AUTH_JWT_VERIFY_CERT_PATH for a mounted file). Multiple certs enable zero-downtime key rotation — a token verified by ANY listed key is accepted (static-keyset-with-overlap). - AUTH_JWT_ISSUER: optional "iss" pin. When no cert is configured the historical ParseUnverified behavior is preserved exactly (backward compatible). When configured, the token is verified (signature/alg/exp/iss) before its claims are used; any failure denies with 401 (fail closed). A configured-but-unparseable cert is logged loud at ERROR and leaves verification disabled, never silently accepted, keeping NewAuthClient's no-error signature. Closes #106 Claude-Session: https://claude.ai/code/session_01RcQLK4qK1kbpvXWjuAR3Xh * refactor(middleware): source-neutral cert error + trim AUTH_JWT_VERIFY_CERT Reword the cert-parse failure log to be source-neutral since the PEM data can come from either AUTH_JWT_VERIFY_CERT or AUTH_JWT_VERIFY_CERT_PATH. Trim surrounding whitespace on the inline AUTH_JWT_VERIFY_CERT value before PEM parsing so a stray trailing newline no longer breaks cert parsing, mirroring the existing AUTH_JWT_VERIFY_CERT_PATH handling. Claude-Session: https://claude.ai/code/session_01RcQLK4qK1kbpvXWjuAR3Xh --- README.md | 17 + auth/middleware/m2m.go | 40 +-- auth/middleware/middleware.go | 36 +- auth/middleware/verification.go | 206 +++++++++++ auth/middleware/verification_test.go | 491 +++++++++++++++++++++++++++ 5 files changed, 741 insertions(+), 49 deletions(-) create mode 100644 auth/middleware/verification.go create mode 100644 auth/middleware/verification_test.go diff --git a/README.md b/README.md index bbed4c0..7359161 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,23 @@ PLUGIN_AUTH_ENABLED=true # false, preserving the previous behavior of sending no product for M2M. AUTH_M2M_PRODUCT_FORWARD_ENABLED=false +# Optional. Opt-in local JWT signature verification for the general authorization +# path. When unset, tokens are parsed without signature verification (the +# authorization service remains the trust anchor) — the previous behavior, +# unchanged. When set, the bearer token is cryptographically verified (RS256, +# expiry required, and issuer when AUTH_JWT_ISSUER is set) BEFORE its claims are +# trusted; any failure denies the request (401, fail closed). +# +# AUTH_JWT_VERIFY_CERT holds the issuer's PEM certificate(s) or RSA public key(s). +# Newline-join multiple PEMs to carry the old and new certs simultaneously across +# a key rotation (zero-downtime: a token verified by ANY listed key is accepted). +# AUTH_JWT_VERIFY_CERT_PATH points to a mounted PEM file instead (used only when +# AUTH_JWT_VERIFY_CERT is empty). A configured-but-unparseable cert is logged at +# ERROR and leaves verification disabled; it is never silently accepted. +AUTH_JWT_VERIFY_CERT= +AUTH_JWT_VERIFY_CERT_PATH= +AUTH_JWT_ISSUER= + # Optional. When "true", the middleware fails closed: if auth is disabled # (PLUGIN_AUTH_ENABLED=false) or misconfigured (empty PLUGIN_AUTH_ADDRESS), # every protected route refuses to serve (HTTP 503 / gRPC Unavailable) instead diff --git a/auth/middleware/m2m.go b/auth/middleware/m2m.go index 853fa9e..cbc257d 100644 --- a/auth/middleware/m2m.go +++ b/auth/middleware/m2m.go @@ -170,42 +170,18 @@ func (m *M2MAuthenticator) verify(ctx context.Context, span trace.Span, accessTo return nil, http.StatusUnauthorized, err } - claims := jwt.MapClaims{} - - // WithValidMethods pins the accepted signing algorithm to RS256, closing the - // alg-substitution hole (a token forged with "none" or with HS256 using the - // public key as the shared secret is rejected before the keyfunc runs). - // WithExpirationRequired rejects a validly-signed token that omits "exp", so a - // non-expiring credential cannot slip through (Casdoor always emits exp). - // WithIssuer, when an expected issuer is configured, rejects a token minted by - // a different issuer that shares the same signing key. - opts := []jwt.ParserOption{ - jwt.WithValidMethods([]string{"RS256"}), - jwt.WithExpirationRequired(), - } - if m.expectedIssuer != "" { - opts = append(opts, jwt.WithIssuer(m.expectedIssuer)) - } - - parser := jwt.NewParser(opts...) - - token, err := parser.ParseWithClaims(accessToken, claims, func(_ *jwt.Token) (any, error) { - return m.publicKey, nil - }) + // verifyToken is the shared hardened RS256 verifier: it pins RS256 (closing the + // alg-substitution hole — a token forged with "none" or with HS256 using the + // public key as the shared secret is rejected before the keyfunc runs), requires + // "exp" (Casdoor always emits it, so a non-expiring credential cannot slip + // through), and pins "iss" when expectedIssuer is set. A single-key set is passed + // here; the general path may pass several for rotation overlap. + claims, statusCode, err := verifyToken([]*rsa.PublicKey{m.publicKey}, m.expectedIssuer, accessToken) if err != nil { logErrorf(ctx, m.logger, "Failed to verify M2M token: %v", err) tracing.HandleSpanError(span, "Failed to verify M2M token", err) - return nil, http.StatusUnauthorized, errors.New("invalid token") - } - - if !token.Valid { - err := errors.New("invalid token") - - logErrorf(ctx, m.logger, "M2M token failed validation") - tracing.HandleSpanError(span, "M2M token failed validation", err) - - return nil, http.StatusUnauthorized, err + return nil, statusCode, err } userType, _ := claims["type"].(string) diff --git a/auth/middleware/middleware.go b/auth/middleware/middleware.go index 498a16c..80d68ee 100644 --- a/auth/middleware/middleware.go +++ b/auth/middleware/middleware.go @@ -3,6 +3,7 @@ package middleware import ( "bytes" "context" + "crypto/rsa" "encoding/json" "errors" "fmt" @@ -73,6 +74,18 @@ type AuthClient struct { // failures (network/timeout/5xx). Read once from AUTH_RETRY_MAX; 0 disables // retry (opt-in). Authoritative decisions (401/403) are never retried. retryMax uint + + // verifyKeys holds the RSA public keys used to cryptographically verify the + // bearer token locally before its claims are trusted. When empty (the default) + // the general path preserves the historical ParseUnverified behavior and the + // authz round-trip remains the trust anchor. It is populated from + // AUTH_JWT_VERIFY_CERT / AUTH_JWT_VERIFY_CERT_PATH at construction; carrying more + // than one key supports zero-downtime cert rotation (any key may verify). + verifyKeys []*rsa.PublicKey + + // verifyIssuer, when non-empty, pins the accepted token "iss" claim during local + // verification. Read once from AUTH_JWT_ISSUER; inert when verifyKeys is empty. + verifyIssuer string } type AuthResponse struct { @@ -208,6 +221,8 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient } } + verifyKeys, verifyIssuer := loadVerification(l) + // Build the client once with all env-derived config, then return it from every // path below; the health check only logs, it never changes these fields. c := &AuthClient{ @@ -220,6 +235,8 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient cache: newDecisionCacheFromEnv(), breaker: newBreakerFromEnv(), retryMax: parseRetryMax(), + verifyKeys: verifyKeys, + verifyIssuer: verifyIssuer, } if !enabled || address == "" { @@ -427,24 +444,9 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resourc ctx, cancel := context.WithTimeout(ctx, auth.requestTimeout()) defer cancel() - token, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{}) + claims, statusCode, err := auth.extractClaims(ctx, span, accessToken) if err != nil { - logErrorf(ctx, auth.Logger, "Failed to parse token: %v", err) - - tracing.HandleSpanError(span, "Failed to parse token", err) - - return false, http.StatusUnauthorized, err - } - - claims, ok := token.Claims.(jwt.MapClaims) - if !ok { - logErrorf(ctx, auth.Logger, "Failed to parse claims: token.Claims is not of type jwt.MapClaims") - - err := errors.New("token claims are not in the expected format") - - tracing.HandleSpanError(span, "Failed to parse claims", err) - - return false, http.StatusUnauthorized, err + return false, statusCode, err } userType, _ := claims["type"].(string) diff --git a/auth/middleware/verification.go b/auth/middleware/verification.go new file mode 100644 index 0000000..95870dd --- /dev/null +++ b/auth/middleware/verification.go @@ -0,0 +1,206 @@ +package middleware + +import ( + "context" + "crypto/rsa" + "encoding/pem" + "errors" + "fmt" + "net/http" + "os" + "strings" + + "github.com/LerianStudio/lib-observability/v2/log" + "github.com/LerianStudio/lib-observability/v2/tracing" + jwt "github.com/golang-jwt/jwt/v5" + "go.opentelemetry.io/otel/trace" +) + +// verifyToken cryptographically verifies a JWT and returns its claims. It pins +// RS256 (closing the alg-substitution hole: a token forged with "none" or with +// HS256 using the public key as the shared secret is rejected before the keyfunc +// runs), requires "exp", and — when expectedIssuer is non-empty — pins the "iss" +// claim. +// +// pubKeys is a rotation-overlap set: the token is accepted if it verifies against +// ANY key, so an operator can carry the old and new certs simultaneously across a +// key rotation without downtime (the static-keyset-with-overlap pattern). It +// performs NO application-level claim checks (token type, sub); the caller applies +// those. Every failure returns http.StatusUnauthorized so callers fail closed. It +// never logs the token. +// +// This is the shared hardened verifier extracted from the M2M gate: both +// RequireM2M (via M2MAuthenticator.verify) and the general checkAuthorization path +// route their signature verification through here so there is one audited RS256 +// verifier, not two. +func verifyToken(pubKeys []*rsa.PublicKey, expectedIssuer, accessToken string) (jwt.MapClaims, int, error) { + if len(pubKeys) == 0 { + return nil, http.StatusUnauthorized, errors.New("no verification key configured") + } + + opts := []jwt.ParserOption{ + jwt.WithValidMethods([]string{"RS256"}), + jwt.WithExpirationRequired(), + } + if expectedIssuer != "" { + opts = append(opts, jwt.WithIssuer(expectedIssuer)) + } + + keySet := jwt.VerificationKeySet{Keys: make([]jwt.VerificationKey, len(pubKeys))} + for i, key := range pubKeys { + keySet.Keys[i] = key + } + + claims := jwt.MapClaims{} + + token, err := jwt.NewParser(opts...).ParseWithClaims(accessToken, claims, func(_ *jwt.Token) (any, error) { + return keySet, nil + }) + if err != nil { + return nil, http.StatusUnauthorized, fmt.Errorf("token verification failed: %w", err) + } + + if !token.Valid { + return nil, http.StatusUnauthorized, errors.New("invalid token") + } + + return claims, http.StatusOK, nil +} + +// extractClaims returns the token claims used to build the authorization subject. +// +// When local verification is configured (verifyKeys present, from +// AUTH_JWT_VERIFY_CERT / AUTH_JWT_VERIFY_CERT_PATH) it cryptographically verifies +// the token first (signature/alg/exp/iss) and fails closed on any error before the +// claims are used. When it is NOT configured it preserves the historical +// ParseUnverified behavior exactly — the network authorization call remains the +// trust anchor — so the feature is opt-in and backward compatible. Verification is +// gated by presence of a cert, mirroring the M2M cert gate. +func (auth *AuthClient) extractClaims(ctx context.Context, span trace.Span, accessToken string) (jwt.MapClaims, int, error) { + if len(auth.verifyKeys) > 0 { + claims, statusCode, err := verifyToken(auth.verifyKeys, auth.verifyIssuer, accessToken) + if err != nil { + logErrorf(ctx, auth.Logger, "Failed to verify token: %v", err) + + tracing.HandleSpanError(span, "Failed to verify token", err) + + return nil, statusCode, err + } + + return claims, http.StatusOK, nil + } + + token, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{}) + if err != nil { + logErrorf(ctx, auth.Logger, "Failed to parse token: %v", err) + + tracing.HandleSpanError(span, "Failed to parse token", err) + + return nil, http.StatusUnauthorized, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok { + logErrorf(ctx, auth.Logger, "Failed to parse claims: token.Claims is not of type jwt.MapClaims") + + err := errors.New("token claims are not in the expected format") + + tracing.HandleSpanError(span, "Failed to parse claims", err) + + return nil, http.StatusUnauthorized, err + } + + return claims, http.StatusOK, nil +} + +// loadVerification reads the opt-in local JWT verification config from the +// environment: AUTH_JWT_VERIFY_CERT (inline, newline-joined PEMs for rotation +// overlap) or, when it is empty, AUTH_JWT_VERIFY_CERT_PATH (a mounted file), plus +// AUTH_JWT_ISSUER (optional "iss" pin). Absence of both cert sources leaves +// verification off (nil keys), preserving the historical ParseUnverified behavior. +// +// A configured-but-unparseable cert is logged loud at ERROR and verification is +// left disabled (the authz round-trip stays the trust anchor); it never silently +// accepts a bad cert and never panics, keeping NewAuthClient's no-error signature. +func loadVerification(logger log.Logger) (keys []*rsa.PublicKey, issuer string) { + issuer = strings.TrimSpace(os.Getenv("AUTH_JWT_ISSUER")) + + pemData, err := loadVerifyCertPEM() + if err != nil { + logErrorf(context.Background(), logger, + "AUTH_JWT_VERIFY_CERT_PATH unreadable, local JWT verification disabled: %v", err) + + return nil, issuer + } + + if len(pemData) == 0 { + return nil, issuer + } + + keys, err = parseRSAPublicKeys(pemData) + if err != nil { + logErrorf(context.Background(), logger, + "failed to parse verification certificate, local JWT verification disabled: %v", err) + + return nil, issuer + } + + return keys, issuer +} + +// loadVerifyCertPEM returns the PEM bytes for local JWT verification, preferring +// the inline AUTH_JWT_VERIFY_CERT over the file referenced by +// AUTH_JWT_VERIFY_CERT_PATH. It returns (nil, nil) when neither is set, which +// leaves verification off (opt-in by presence, like the M2M cert gate). +func loadVerifyCertPEM() ([]byte, error) { + if inline := strings.TrimSpace(os.Getenv("AUTH_JWT_VERIFY_CERT")); inline != "" { + return []byte(inline), nil + } + + path := strings.TrimSpace(os.Getenv("AUTH_JWT_VERIFY_CERT_PATH")) + if path == "" { + return nil, nil + } + + data, err := os.ReadFile(path) // #nosec G304 G703 -- path is operator-supplied deployment config (a mounted secret), not request/user input + if err != nil { + return nil, fmt.Errorf("failed to read AUTH_JWT_VERIFY_CERT_PATH %q: %w", path, err) + } + + return data, nil +} + +// parseRSAPublicKeys parses one or more PEM blocks (concatenated / newline-joined) +// into RSA public keys. Each block may be a PKIX public key or an X.509 +// certificate (e.g. Casdoor's token_jwt_key.pem). Supporting multiple blocks is +// what makes zero-downtime key rotation possible: the operator carries the old and +// new certs together for the overlap window. It errors if any block fails to parse +// or if no PEM block is found, so a misconfiguration is caught rather than silently +// yielding an empty (deny-everything) key set. +func parseRSAPublicKeys(pemData []byte) ([]*rsa.PublicKey, error) { + var keys []*rsa.PublicKey + + rest := pemData + + for { + block, remainder := pem.Decode(rest) + if block == nil { + break + } + + rest = remainder + + key, err := jwt.ParseRSAPublicKeyFromPEM(pem.EncodeToMemory(block)) + if err != nil { + return nil, fmt.Errorf("failed to parse verification key: %w", err) + } + + keys = append(keys, key) + } + + if len(keys) == 0 { + return nil, errors.New("no PEM blocks found in verification certificate") + } + + return keys, nil +} diff --git a/auth/middleware/verification_test.go b/auth/middleware/verification_test.go new file mode 100644 index 0000000..1fcfc8a --- /dev/null +++ b/auth/middleware/verification_test.go @@ -0,0 +1,491 @@ +package middleware + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/LerianStudio/lib-observability/v2/log" + jwt "github.com/golang-jwt/jwt/v5" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// normalUserClaims mirrors a real Casdoor normal-user token (the general path). +func normalUserClaims() jwt.MapClaims { + return jwt.MapClaims{ + "type": "normal-user", + "owner": "acme-org", + "sub": "user-123", + "exp": float64(time.Now().Add(time.Hour).Unix()), + "iat": float64(time.Now().Add(-time.Minute).Unix()), + } +} + +// --------------------------------------------------------------------------- +// verifyToken - shared hardened verifier +// --------------------------------------------------------------------------- + +func TestVerifyToken_ValidSignedToken_Passes(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + token := signRS256(t, key, normalUserClaims()) + + claims, statusCode, verr := verifyToken(pubKeys, "", token) + + require.NoError(t, verr) + assert.Equal(t, http.StatusOK, statusCode) + assert.Equal(t, "acme-org", claims["owner"]) + assert.Equal(t, "user-123", claims["sub"]) +} + +func TestVerifyToken_WrongKey_FailsClosed(t *testing.T) { + t.Parallel() + + attackerKey, _ := newTestRSAKeyPEM(t) + _, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + token := signRS256(t, attackerKey, normalUserClaims()) + + _, statusCode, verr := verifyToken(pubKeys, "", token) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_ExpiredToken_FailsClosed(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + claims := normalUserClaims() + claims["exp"] = float64(time.Now().Add(-time.Hour).Unix()) + + token := signRS256(t, key, claims) + + _, statusCode, verr := verifyToken(pubKeys, "", token) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_MissingExp_FailsClosed(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + claims := normalUserClaims() + delete(claims, "exp") + + token := signRS256(t, key, claims) + + _, statusCode, verr := verifyToken(pubKeys, "", token) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_AlgConfusionHS256_Rejected(t *testing.T) { + t.Parallel() + + // Attacker forges an HS256 token using the public PEM bytes as the shared + // secret. WithValidMethods([]string{"RS256"}) must reject it. + _, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + forged := jwt.NewWithClaims(jwt.SigningMethodHS256, normalUserClaims()) + + signed, err := forged.SignedString([]byte(pubPEM)) + require.NoError(t, err) + + _, statusCode, verr := verifyToken(pubKeys, "", signed) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_AlgNone_Rejected(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + unsigned := jwt.NewWithClaims(jwt.SigningMethodNone, normalUserClaims()) + + signed, err := unsigned.SignedString(jwt.UnsafeAllowNoneSignatureType) + require.NoError(t, err) + + _, statusCode, verr := verifyToken(pubKeys, "", signed) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_MatchingIssuer_Allows(t *testing.T) { + t.Parallel() + + const issuer = "http://plugin-access-manager-auth-backend:8000" + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + claims := normalUserClaims() + claims["iss"] = issuer + + token := signRS256(t, key, claims) + + _, statusCode, verr := verifyToken(pubKeys, issuer, token) + + require.NoError(t, verr) + assert.Equal(t, http.StatusOK, statusCode) +} + +func TestVerifyToken_WrongIssuer_Rejected(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + claims := normalUserClaims() + claims["iss"] = "http://attacker-issuer:8000" + + token := signRS256(t, key, claims) + + _, statusCode, verr := verifyToken(pubKeys, "http://expected-issuer:8000", token) + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_NoKeys_FailsClosed(t *testing.T) { + t.Parallel() + + key, _ := newTestRSAKeyPEM(t) + token := signRS256(t, key, normalUserClaims()) + + _, statusCode, err := verifyToken(nil, "", token) + + require.Error(t, err) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestVerifyToken_MalformedToken_Rejected(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + _, statusCode, verr := verifyToken(pubKeys, "", "not-a-jwt") + + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +// TestVerifyToken_RotationBundle_AcceptsEitherKey proves the zero-downtime +// rotation contract: with the old and new certs both present, a token signed by +// EITHER key verifies, while a token signed by a key outside the set is rejected. +func TestVerifyToken_RotationBundle_AcceptsEitherKey(t *testing.T) { + t.Parallel() + + oldKey, oldPEM := newTestRSAKeyPEM(t) + newKey, newPEM := newTestRSAKeyPEM(t) + strangerKey, _ := newTestRSAKeyPEM(t) + + bundle := oldPEM + "\n" + newPEM + pubKeys, err := parseRSAPublicKeys([]byte(bundle)) + require.NoError(t, err) + require.Len(t, pubKeys, 2) + + t.Run("old_key_accepted", func(t *testing.T) { + t.Parallel() + + _, statusCode, verr := verifyToken(pubKeys, "", signRS256(t, oldKey, normalUserClaims())) + require.NoError(t, verr) + assert.Equal(t, http.StatusOK, statusCode) + }) + + t.Run("new_key_accepted", func(t *testing.T) { + t.Parallel() + + _, statusCode, verr := verifyToken(pubKeys, "", signRS256(t, newKey, normalUserClaims())) + require.NoError(t, verr) + assert.Equal(t, http.StatusOK, statusCode) + }) + + t.Run("stranger_key_rejected", func(t *testing.T) { + t.Parallel() + + _, statusCode, verr := verifyToken(pubKeys, "", signRS256(t, strangerKey, normalUserClaims())) + require.Error(t, verr) + assert.Equal(t, http.StatusUnauthorized, statusCode) + }) +} + +// --------------------------------------------------------------------------- +// parseRSAPublicKeys +// --------------------------------------------------------------------------- + +func TestParseRSAPublicKeys_Single(t *testing.T) { + t.Parallel() + + _, pubPEM := newTestRSAKeyPEM(t) + + keys, err := parseRSAPublicKeys([]byte(pubPEM)) + + require.NoError(t, err) + assert.Len(t, keys, 1) +} + +func TestParseRSAPublicKeys_MultipleNewlineJoined(t *testing.T) { + t.Parallel() + + _, pem1 := newTestRSAKeyPEM(t) + _, pem2 := newTestRSAKeyPEM(t) + _, pem3 := newTestRSAKeyPEM(t) + + keys, err := parseRSAPublicKeys([]byte(pem1 + "\n" + pem2 + "\n" + pem3)) + + require.NoError(t, err) + assert.Len(t, keys, 3) +} + +func TestParseRSAPublicKeys_NoPEMBlock_Errors(t *testing.T) { + t.Parallel() + + keys, err := parseRSAPublicKeys([]byte("not a pem")) + + require.Error(t, err) + assert.Nil(t, keys) +} + +func TestParseRSAPublicKeys_BadBlock_Errors(t *testing.T) { + t.Parallel() + + badPEM := "-----BEGIN PUBLIC KEY-----\nZm9vYmFy\n-----END PUBLIC KEY-----\n" + + keys, err := parseRSAPublicKeys([]byte(badPEM)) + + require.Error(t, err) + assert.Nil(t, keys) +} + +// --------------------------------------------------------------------------- +// checkAuthorization - verification hooked in via extractClaims +// --------------------------------------------------------------------------- + +func TestCheckAuthorization_VerificationEnabled_ValidToken_ReachesAuthz(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + verifyKeys: pubKeys, + } + + token := signRS256(t, key, normalUserClaims()) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "read", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) +} + +func TestCheckAuthorization_VerificationEnabled_ForgedToken_DeniedBeforeAuthz(t *testing.T) { + t.Parallel() + + attackerKey, _ := newTestRSAKeyPEM(t) + _, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + // The authz backend must never be reached: verification fails closed first. + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Errorf("auth backend must not be called when local verification fails") + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + verifyKeys: pubKeys, + } + + token := signRS256(t, attackerKey, normalUserClaims()) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "read", token, + ) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +func TestCheckAuthorization_VerificationEnabled_ExpiredToken_Denied(t *testing.T) { + t.Parallel() + + key, pubPEM := newTestRSAKeyPEM(t) + pubKeys, err := parseRSAPublicKeys([]byte(pubPEM)) + require.NoError(t, err) + + server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + t.Errorf("auth backend must not be called when the token is expired") + })) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + verifyKeys: pubKeys, + } + + claims := normalUserClaims() + claims["exp"] = float64(time.Now().Add(-time.Hour).Unix()) + + token := signRS256(t, key, claims) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "read", token, + ) + + require.Error(t, err) + assert.False(t, authorized) + assert.Equal(t, http.StatusUnauthorized, statusCode) +} + +// TestCheckAuthorization_VerificationDisabled_UnsignedTokenStillWorks proves the +// opt-in contract: with no verifyKeys, the historical ParseUnverified path is +// preserved exactly — an HS256 test token (not RS256-signed by any trusted key) +// still authorizes through the authz round-trip. +func TestCheckAuthorization_VerificationDisabled_UnsignedTokenStillWorks(t *testing.T) { + t.Parallel() + + server := mockAuthServer(t, true, http.StatusOK) + defer server.Close() + + auth := &AuthClient{ + Address: server.URL, + Enabled: true, + Logger: &testLogger{}, + } + + token := createTestJWT(jwt.MapClaims{ + "type": "normal-user", + "owner": "acme-org", + "sub": "user-123", + }) + + authorized, statusCode, err := auth.checkAuthorization( + context.Background(), "midaz", "resource", "read", token, + ) + + require.NoError(t, err) + assert.True(t, authorized) + assert.Equal(t, http.StatusOK, statusCode) +} + +// --------------------------------------------------------------------------- +// NewAuthClient - verification config wiring (AUTH_JWT_VERIFY_CERT*) +// --------------------------------------------------------------------------- + +func TestNewAuthClient_VerificationConfig(t *testing.T) { + // Cannot use t.Parallel(): subtests use t.Setenv. enabled=false returns early + // without any network call, exercising the config wiring in isolation. + logger := log.Logger(&testLogger{}) + + t.Run("inline_cert_populates_keys", func(t *testing.T) { + _, pubPEM := newTestRSAKeyPEM(t) + t.Setenv("AUTH_JWT_VERIFY_CERT", pubPEM) + t.Setenv("AUTH_JWT_ISSUER", "http://issuer:8000") + + client := NewAuthClient("", false, &logger) + assert.Len(t, client.verifyKeys, 1) + assert.Equal(t, "http://issuer:8000", client.verifyIssuer) + }) + + t.Run("inline_bundle_populates_multiple_keys", func(t *testing.T) { + _, pem1 := newTestRSAKeyPEM(t) + _, pem2 := newTestRSAKeyPEM(t) + t.Setenv("AUTH_JWT_VERIFY_CERT", pem1+"\n"+pem2) + + client := NewAuthClient("", false, &logger) + assert.Len(t, client.verifyKeys, 2) + }) + + t.Run("absent_cert_leaves_verification_off", func(t *testing.T) { + t.Setenv("AUTH_JWT_VERIFY_CERT", "") + t.Setenv("AUTH_JWT_VERIFY_CERT_PATH", "") + + client := NewAuthClient("", false, &logger) + assert.Nil(t, client.verifyKeys) + }) + + t.Run("bad_cert_disables_verification_without_panic", func(t *testing.T) { + t.Setenv("AUTH_JWT_VERIFY_CERT", "-----BEGIN PUBLIC KEY-----\ngarbage\n-----END PUBLIC KEY-----") + + client := NewAuthClient("", false, &logger) + assert.Nil(t, client.verifyKeys) + }) + + t.Run("cert_path_reads_file", func(t *testing.T) { + _, pubPEM := newTestRSAKeyPEM(t) + dir := t.TempDir() + path := filepath.Join(dir, "cert.pem") + require.NoError(t, os.WriteFile(path, []byte(pubPEM), 0o600)) + + t.Setenv("AUTH_JWT_VERIFY_CERT", "") + t.Setenv("AUTH_JWT_VERIFY_CERT_PATH", path) + + client := NewAuthClient("", false, &logger) + assert.Len(t, client.verifyKeys, 1) + }) + + t.Run("cert_path_missing_file_disables_without_panic", func(t *testing.T) { + t.Setenv("AUTH_JWT_VERIFY_CERT", "") + t.Setenv("AUTH_JWT_VERIFY_CERT_PATH", filepath.Join(t.TempDir(), "does-not-exist.pem")) + + client := NewAuthClient("", false, &logger) + assert.Nil(t, client.verifyKeys) + }) + + t.Run("inline_cert_takes_precedence_over_path", func(t *testing.T) { + _, inlinePEM := newTestRSAKeyPEM(t) + t.Setenv("AUTH_JWT_VERIFY_CERT", inlinePEM) + t.Setenv("AUTH_JWT_VERIFY_CERT_PATH", filepath.Join(t.TempDir(), "unused.pem")) + + client := NewAuthClient("", false, &logger) + assert.Len(t, client.verifyKeys, 1) + }) +} From 467caacddf65310741a817ff180084dc32b86935 Mon Sep 17 00:00:00 2001 From: lerian-studio Date: Tue, 21 Jul 2026 16:21:17 +0000 Subject: [PATCH 20/22] chore(release): 3.0.0-beta.8 ## [3.0.0-beta.8](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.7...v3.0.0-beta.8) (2026-07-21) ### Features * **middleware:** add opt-in local JWT signature verification ([#128](https://github.com/LerianStudio/lib-auth/issues/128)) ([48b81a3](https://github.com/LerianStudio/lib-auth/commit/48b81a355cabbc65fc2fe229b6b214c9fa62fc37)), closes [#106](https://github.com/LerianStudio/lib-auth/issues/106) --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b37433b..aef6253 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,10 @@ +## [3.0.0-beta.8](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.7...v3.0.0-beta.8) (2026-07-21) + + +### Features + +* **middleware:** add opt-in local JWT signature verification ([#128](https://github.com/LerianStudio/lib-auth/issues/128)) ([48b81a3](https://github.com/LerianStudio/lib-auth/commit/48b81a355cabbc65fc2fe229b6b214c9fa62fc37)), closes [#106](https://github.com/LerianStudio/lib-auth/issues/106) + ## [3.0.0-beta.7](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.6...v3.0.0-beta.7) (2026-07-21) From 29c0d680c87c7a766e815ee6598b0c9701a00a91 Mon Sep 17 00:00:00 2001 From: rodrigodh Date: Tue, 21 Jul 2026 16:28:58 -0300 Subject: [PATCH 21/22] chore(deps): point to stable lib-observability v2.0.0 and lib-commons v6.0.0 Pin the observability and commons dependencies to their stable v2.0.0/v6.0.0 releases (were v2.0.0-beta.1 / v6.0.0-beta.2). No code changes; build passes. X-Lerian-Ref: 0x1 --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 043fadc..274364f 100644 --- a/go.mod +++ b/go.mod @@ -19,8 +19,8 @@ require ( ) require ( - github.com/LerianStudio/lib-commons/v6 v6.0.0-beta.2 - github.com/LerianStudio/lib-observability/v2 v2.0.0-beta.1 + github.com/LerianStudio/lib-commons/v6 v6.0.0 + github.com/LerianStudio/lib-observability/v2 v2.0.0 github.com/andybalholm/brotli v1.2.2 // indirect github.com/bxcodec/dbresolver/v2 v2.2.1 // indirect github.com/cenkalti/backoff/v5 v5.0.3 diff --git a/go.sum b/go.sum index a709a4a..d8b3a51 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,10 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEK github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/LerianStudio/lib-commons/v6 v6.0.0-beta.2 h1:UwSvTUe3jDclz2M2huMuYIqVzbJ+WccMKON9rPda+jA= -github.com/LerianStudio/lib-commons/v6 v6.0.0-beta.2/go.mod h1:wJzcspXD+S810PJUJn8SfFXIQgfy0OVFUeEA7QSREbg= -github.com/LerianStudio/lib-observability/v2 v2.0.0-beta.1 h1:jqeROYaFDqdT0d8Wqv0GlbdOkMwvWiMxFKI6H7gGsR8= -github.com/LerianStudio/lib-observability/v2 v2.0.0-beta.1/go.mod h1:gcpmb8FoTsuxUHVgW3sY8s2Pu558T4eDdboEnEowBrI= +github.com/LerianStudio/lib-commons/v6 v6.0.0 h1:XDKyCKdb2h3l0MBwQQNBRuCsiSTjafJfgp/QLMAEzd0= +github.com/LerianStudio/lib-commons/v6 v6.0.0/go.mod h1:bOPZG4oO2xoSE307JJyXKyhmBo7EyQ5g8DEw5fI/ZEg= +github.com/LerianStudio/lib-observability/v2 v2.0.0 h1:B13t2TlLvu9awqMsnJ6d0RWeftUlf1ROBxLB3okSn6c= +github.com/LerianStudio/lib-observability/v2 v2.0.0/go.mod h1:MuHQdrM8twiwewkwSO1TX6/931mI9oTM0NCIVjumPhQ= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/andybalholm/brotli v1.2.2 h1:HzTuoo2ErYQqf5qvcJInB8uvqSVxRttzkFexPWtnceM= From 469b81a71e246c4442e0ff6c70a65e58ba371b7e Mon Sep 17 00:00:00 2001 From: lerian-studio Date: Tue, 21 Jul 2026 19:49:19 +0000 Subject: [PATCH 22/22] chore(release): 3.0.0-beta.9 ## [3.0.0-beta.9](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.8...v3.0.0-beta.9) (2026-07-21) --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index aef6253..cb35877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,5 @@ +## [3.0.0-beta.9](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.8...v3.0.0-beta.9) (2026-07-21) + ## [3.0.0-beta.8](https://github.com/LerianStudio/lib-auth/compare/v3.0.0-beta.7...v3.0.0-beta.8) (2026-07-21)