Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,26 @@ AUTH_M2M_PRODUCT_FORWARD_ENABLED=false
# fail-open behavior. Set it in security-sensitive deployments so a
# missing/typo'd address cannot silently downgrade a protected service to open.
AUTH_REQUIRED=false

# Optional authorization resilience (all opt-in; defaults preserve prior behavior).
# Every fallback denies (fail closed) — no path serves a request on an outage.
#
# AUTH_TIMEOUT bounds each authorization round-trip with a per-request deadline
# (Go duration). Defaults to 30s (behavior-neutral). It also caps the retry budget.
AUTH_TIMEOUT=30s
# AUTH_CACHE_TTL enables a short-lived decision cache when > 0, keyed by
# (subject, resource, action, product) — never the token. Empty/0 disables it
# (default). Security tradeoff: a permission revocation takes up to the TTL to
# propagate, so keep it small (5–15s). It sheds load and, with the breaker,
# survives brief authz outages by serving fresh positive decisions.
AUTH_CACHE_TTL=
# AUTH_BREAKER_ENABLED opens a circuit breaker after sustained authz failures.
# While open it serves ONLY a fresh positive cache hit and otherwise denies;
# it never serves a stale decision. Defaults to disabled.
AUTH_BREAKER_ENABLED=false
# AUTH_RETRY_MAX retries only TRANSIENT failures (network/timeout/5xx) up to N
# times within AUTH_TIMEOUT; authoritative 401/403 are never retried. 0 disables.
AUTH_RETRY_MAX=0
```

### 2. Create a new instance of the middleware:
Expand Down
136 changes: 136 additions & 0 deletions auth/middleware/decisioncache.go
Original file line number Diff line number Diff line change
@@ -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
}
}
138 changes: 60 additions & 78 deletions auth/middleware/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
libHTTP "github.com/LerianStudio/lib-commons/v6/commons/net/http"
"github.com/gofiber/fiber/v3"
jwt "github.com/golang-jwt/jwt/v5"
"github.com/sony/gobreaker"
)

type AuthClient struct {
Expand All @@ -49,6 +50,29 @@ type AuthClient struct {
// missing/typo'd address can no longer silently downgrade a protected service
// to fully open.
Required bool

// timeout bounds each authorization round-trip via a per-request context
// deadline. Read once from AUTH_TIMEOUT; defaults to 30s (behavior-neutral,
// matching the prior client-wide HTTP timeout) when unset/invalid. It also caps
// the retry budget.
timeout time.Duration

// cache, when non-nil, memoizes authorization decisions for a short TTL keyed by
// (sub, resource, action, product) — NEVER the raw token. Enabled by
// AUTH_CACHE_TTL > 0 (opt-in; nil = no caching, prior behavior). It trades a
// bounded revocation-propagation lag (= TTL) for load shedding and outage
// resilience.
cache *decisionCache

// breaker, when non-nil, short-circuits the authorization call after sustained
// failures (opt-in via AUTH_BREAKER_ENABLED). While open it serves only a fresh
// positive cache hit and otherwise denies — it never fails open.
breaker *gobreaker.CircuitBreaker

// retryMax is the maximum number of retries applied to TRANSIENT authorization
// failures (network/timeout/5xx). Read once from AUTH_RETRY_MAX; 0 disables
// retry (opt-in). Authoritative decisions (401/403) are never retried.
retryMax uint
}

type AuthResponse struct {
Expand Down Expand Up @@ -184,22 +208,27 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient
}
}

forwardM2MProduct := os.Getenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED") == "true"
required := os.Getenv("AUTH_REQUIRED") == "true"
// Build the client once with all env-derived config, then return it from every
// path below; the health check only logs, it never changes these fields.
c := &AuthClient{
Address: address,
Enabled: enabled,
Logger: l,
ForwardM2MProduct: os.Getenv("AUTH_M2M_PRODUCT_FORWARD_ENABLED") == "true",
Required: os.Getenv("AUTH_REQUIRED") == "true",
timeout: parseAuthTimeout(),
cache: newDecisionCacheFromEnv(),
breaker: newBreakerFromEnv(),
retryMax: parseRetryMax(),
}

if !enabled || address == "" {
if required {
if c.Required {
logErrorf(context.Background(), l,
"AUTH_REQUIRED is set but auth is disabled or address is empty: middleware will fail closed (refuse to serve)")
}

return &AuthClient{
Address: address,
Enabled: enabled,
Logger: l,
ForwardM2MProduct: forwardM2MProduct,
Required: required,
}
return c
}

client := sharedHTTPClient
Expand All @@ -211,21 +240,21 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient
if err != nil {
logErrorf(context.Background(), l, failedToConnectMsg, err)

return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required}
return c
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
logErrorf(context.Background(), l, failedToConnectMsg, resp.Status)

return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required}
return c
}

body, err := io.ReadAll(resp.Body)
if err != nil {
logErrorf(context.Background(), l, "Failed to read response body: %v", err)

return &AuthClient{Address: address, Enabled: enabled, Logger: l, ForwardM2MProduct: forwardM2MProduct, Required: required}
return c
}

if string(body) == "healthy" {
Expand All @@ -234,13 +263,7 @@ func NewAuthClient(address string, enabled bool, logger *log.Logger) *AuthClient
logErrorf(context.Background(), l, failedToConnectMsg, string(body))
}

return &AuthClient{
Address: address,
Enabled: enabled,
Logger: l,
ForwardM2MProduct: forwardM2MProduct,
Required: required,
}
return c
}

// canAuthorize reports whether the client is able to perform an authorization
Expand Down Expand Up @@ -398,7 +421,11 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resourc
attribute.String("app.request.request_id", reqID),
)

client := sharedHTTPClient
// Per-request deadline: propagate the caller's cancellation/deadline to the
// authz call (the request was previously built without a context, so an upstream
// cancel could not abort it) and cap the retry budget. Defaults to 30s.
ctx, cancel := context.WithTimeout(ctx, auth.requestTimeout())
defer cancel()

token, _, err := new(jwt.Parser).ParseUnverified(accessToken, jwt.MapClaims{})
if err != nil {
Expand Down Expand Up @@ -453,66 +480,21 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resourc
return false, http.StatusInternalServerError, err
}

req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/v1/authorize", auth.Address), bytes.NewBuffer(requestBodyJSON))
if err != nil {
logErrorf(ctx, auth.Logger, "Failed to create request: %v", err)

tracing.HandleSpanError(span, "Failed to create request", err)
// Cache key = exactly the authz request inputs (never the raw token). product is
// the value actually forwarded ("" when not forwarded), so the key matches the
// decision the authz service made.
key := cacheKey{sub: sub, resource: resource, action: action, product: requestBody["product"]}

return false, http.StatusInternalServerError, fmt.Errorf("failed to create request: %w", err)
}

tracing.InjectHTTPContext(ctx, req.Header)

req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", accessToken)

resp, err := client.Do(req)
if err != nil {
logErrorf(ctx, auth.Logger, "Failed to make request: %v", err)

tracing.HandleSpanError(span, "Failed to make request", err)

return false, http.StatusInternalServerError, fmt.Errorf("failed to make request: %w", err)
}
defer resp.Body.Close()

body, err := io.ReadAll(resp.Body)
if err != nil {
logErrorf(ctx, auth.Logger, "Failed to read response body: %v", err)

tracing.HandleSpanError(span, "Failed to read response body", err)

return false, http.StatusInternalServerError, fmt.Errorf("failed to read response body: %w", err)
}

respError, err := unmarshalErrorResponse(body)
if err != nil {
logErrorf(ctx, auth.Logger, "Failed to unmarshal auth error response: %v", err)

tracing.HandleSpanError(span, "Failed to unmarshal auth error response", err)

return false, http.StatusInternalServerError, fmt.Errorf("failed to unmarshal auth error response: %w", err)
}

if respError.Code != "" && resp.StatusCode != http.StatusInternalServerError {
logErrorf(ctx, auth.Logger, "Authorization request failed: %s", respError.Message)

tracing.HandleSpanError(span, "Authorization request failed", respError)

return false, resp.StatusCode, respError
}

var response AuthResponse
if err := json.Unmarshal(body, &response); err != nil {
logErrorf(ctx, auth.Logger, "Failed to unmarshal response: %v", err)

tracing.HandleSpanError(span, "Failed to unmarshal response", err)

return false, http.StatusInternalServerError, fmt.Errorf("failed to unmarshal response: %w", err)
// A fresh cache hit (positive OR negative) short-circuits before the breaker, so
// the breaker only ever runs on a miss — its open state then denies (never
// serving a stale grant).
if auth.cache != nil {
if authorized, hit := auth.cache.get(key); hit {
return authorized, http.StatusOK, nil
}
}

return response.Authorized, resp.StatusCode, nil
return auth.resolveAuthz(ctx, span, accessToken, requestBodyJSON, key)
}

// GetApplicationToken sends a POST request to the authorization service to get a token for the application.
Expand Down
Loading
Loading