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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 890ef86..cb35877 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,74 @@ +## [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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### 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) + + +### ⚠ 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) diff --git a/README.md b/README.md index cafc32d..7359161 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 github.com/LerianStudio/lib-auth/v3@latest ``` ## 🚀 How to Use @@ -19,6 +19,57 @@ 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 + +# 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 +# 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 + +# 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: @@ -37,7 +88,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 +150,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/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/m2m.go b/auth/middleware/m2m.go new file mode 100644 index 0000000..cbc257d --- /dev/null +++ b/auth/middleware/m2m.go @@ -0,0 +1,221 @@ +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" +) + +// 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 +// 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 + // 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 +// (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. +// +// 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 { + 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, expectedIssuer: expectedIssuer, 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, expectedIssuer: expectedIssuer, 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 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 +// - 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) + + // 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), + 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 + } + + // 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, statusCode, 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 +} + +// 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) + if !ok || id.Subject == "" { + return M2MIdentity{}, false + } + + return id, true +} diff --git a/auth/middleware/m2m_test.go b/auth/middleware/m2m_test.go new file mode 100644 index 0000000..b0019df --- /dev/null +++ b/auth/middleware/m2m_test.go @@ -0,0 +1,476 @@ +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 +} + +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 +} + +// newTestM2MApp builds a Fiber app whose single route is gated by RequireM2M and +// 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 { + id, _ := M2MIdentityFromContext(c.Context()) + + c.Set("X-M2M-Subject", id.Subject) + c.Set("X-M2M-Client-Id", 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_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() + + // 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() + + // 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)) + + 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) +} + +// --------------------------------------------------------------------------- +// 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"}) + + id, ok := M2MIdentityFromContext(ctx) + + assert.False(t, ok) + assert.Equal(t, M2MIdentity{}, id, "an empty-subject identity must be reported as the zero value") +} diff --git a/auth/middleware/middleware.go b/auth/middleware/middleware.go index 5cc508a..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" @@ -13,23 +14,78 @@ 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" + "github.com/sony/gobreaker" ) 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 + + // 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 + + // 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 + + // 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 { @@ -47,8 +103,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 @@ -164,12 +221,31 @@ 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{ + 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(), + verifyKeys: verifyKeys, + verifyIssuer: verifyIssuer, + } + if !enabled || address == "" { - return &AuthClient{ - Address: address, - Enabled: enabled, - Logger: l, + 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 c } client := sharedHTTPClient @@ -181,21 +257,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 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} + 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} + return c } if string(body) == "healthy" { @@ -204,23 +280,42 @@ 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, - } + return c +} + +// 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 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 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 { - ctx := tracing.ExtractHTTPContext(c.UserContext(), c) + return func(c fiber.Ctx) error { + ctx := tracing.ExtractHTTPContext(c.Context(), c) _, 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() } @@ -261,42 +356,76 @@ 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 + } + + 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") + + tracing.HandleSpanError(span, "Missing sub claim in application token", err) - userID, _ := claims["sub"].(string) - if userID == "" { - logErrorf(ctx, auth.Logger, "Missing sub claim in token") + return "", http.StatusUnauthorized, err + } + + return sub, http.StatusOK, nil + default: + logErrorf(ctx, auth.Logger, "Unsupported token type: %q", userType) - err := errors.New("missing sub claim in token") + err := errors.New("unsupported token type") - tracing.HandleSpanError(span, "Missing sub claim in token", err) + tracing.HandleSpanError(span, "Unsupported token type", err) return "", http.StatusUnauthorized, err } +} + +// 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 fmt.Sprintf("%s/%s", owner, userID), http.StatusOK, nil + return userType == normalUser || (userType == application && forwardM2MProduct) } func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resource, action, accessToken string) (bool, int, error) { @@ -309,31 +438,20 @@ 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{}) + 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) - 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 } @@ -344,7 +462,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 } @@ -364,66 +482,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) - - 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) + // 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"]} - 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/middlewareGRPC.go b/auth/middleware/middlewareGRPC.go index 4b1fb79..dd69895 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" @@ -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 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 DefaultPolicy *Policy @@ -45,11 +46,19 @@ 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, 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 == "" { + 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) } @@ -73,7 +82,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 for normal-user flows, and for M2M + // (application) flows when AUTH_M2M_PRODUCT_FORWARD_ENABLED is set. var product string if cfg.SubResolver != nil { @@ -87,11 +97,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, auth.ForwardM2MProduct) if err := tracing.SetSpanAttributesFromValue(span, "app.request.payload", payload, nil); err != nil { tracing.HandleSpanError(span, "failed to set span payload", err) } @@ -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". @@ -209,6 +226,43 @@ 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, 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 shouldForwardProduct(tokenTypeClaim(token), product, forwardM2MProduct) { + 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. @@ -237,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) } @@ -254,7 +314,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 for normal-user flows, and for M2M + // (application) flows when AUTH_M2M_PRODUCT_FORWARD_ENABLED is set. var product string if cfg.SubResolver != nil { @@ -276,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 e092375..22f282d 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" @@ -485,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() @@ -749,6 +795,242 @@ 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", false) + + // 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_flag_off_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", false) + + assert.Equal(t, "resource", payload["resource"]) + assert.Equal(t, "read", payload["action"]) + // 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) + }) + + 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", false) + + 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 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 // --------------------------------------------------------------------------- @@ -809,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 bcf05d6..8b112ca 100644 --- a/auth/middleware/middleware_test.go +++ b/auth/middleware/middleware_test.go @@ -3,13 +3,15 @@ package middleware import ( "context" "encoding/json" + "io" "net/http" "net/http/httptest" "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" + "github.com/gofiber/fiber/v3" jwt "github.com/golang-jwt/jwt/v5" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -118,7 +120,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,9 +162,112 @@ 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"]) - // Product is NOT forwarded for non-normal-user tokens. + // 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 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) } @@ -401,28 +507,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 +555,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) { @@ -509,6 +665,147 @@ 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) + }) +} + +// --------------------------------------------------------------------------- +// 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 // --------------------------------------------------------------------------- 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/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) + }) +} diff --git a/go.mod b/go.mod index dc5b6ac..274364f 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 + 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 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 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,20 +41,19 @@ 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 - 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 - 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..d8b3a51 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 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.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=