Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
024a909
feat(middleware)!: migrate to Fiber v3, cut /v3 major
rodrigodh Jul 15, 2026
4597cc1
chore(ci): accept GO-2026-5932 (unmaintained x/crypto/openpgp)
rodrigodh Jul 15, 2026
c78123e
docs(middleware): drop go get -u from install example
rodrigodh Jul 15, 2026
d8b7285
Merge pull request #121 from LerianStudio/feat/fiber-v3
rodrigodh Jul 15, 2026
564e71c
chore(release): 3.0.0-beta.1
lerian-studio Jul 15, 2026
e31ac53
feat: real M2M subject and token type whitelist
qnen Jul 16, 2026
e79ef7f
Merge pull request #122 from LerianStudio/feat/item-17-dynamic-m2m-role
qnen Jul 16, 2026
e7dfa91
chore(release): 3.0.0-beta.2
lerian-studio Jul 16, 2026
93c44db
feat(middleware): forward product on M2M auth (flag-gated)
qnen Jul 17, 2026
2fda16e
Merge pull request #123 from LerianStudio/feat/item-17-m2m-product-fo…
qnen Jul 17, 2026
4cebe96
chore(release): 3.0.0-beta.3
lerian-studio Jul 17, 2026
2034198
feat(middleware): add M2M authentication gate
qnen Jul 20, 2026
5afd305
feat(middleware): add optional issuer pinning to M2M gate
qnen Jul 20, 2026
b29e0e1
Merge pull request #125 from LerianStudio/feat/item-17-m2m-auth-middl…
qnen Jul 20, 2026
dc3ca0d
chore(release): 3.0.0-beta.4
lerian-studio Jul 20, 2026
c48393a
feat(middleware): expose M2M identity via request context
qnen Jul 20, 2026
25fc4ec
fix(middleware): return zero M2MIdentity when reporting absent
qnen Jul 20, 2026
8c25485
Merge pull request #126 from LerianStudio/feat/m2m-identity-context
qnen Jul 20, 2026
9feddbb
chore(release): 3.0.0-beta.5
lerian-studio Jul 20, 2026
f7fff6c
feat(middleware): add fail-closed AUTH_REQUIRED option
fredcamaral Jul 21, 2026
c0469e6
feat(middleware): add authz cache, circuit breaker, and bounded retry
fredcamaral Jul 21, 2026
7474d61
Merge pull request #127 from LerianStudio/feat/auth-required-fail-clo…
fredcamaral Jul 21, 2026
d0b6350
chore(release): 3.0.0-beta.6
lerian-studio Jul 21, 2026
7cbd20b
Merge pull request #129 from LerianStudio/feat/authz-resilience-108
fredcamaral Jul 21, 2026
64dd89c
chore(release): 3.0.0-beta.7
lerian-studio Jul 21, 2026
48b81a3
feat(middleware): add opt-in local JWT signature verification (#128)
fredcamaral Jul 21, 2026
467caac
chore(release): 3.0.0-beta.8
lerian-studio Jul 21, 2026
29c0d68
chore(deps): point to stable lib-observability v2.0.0 and lib-commons…
rodrigodh Jul 21, 2026
279918e
Merge pull request #130 from LerianStudio/chore/bump-stable-deps
rodrigodh Jul 21, 2026
469b81a
chore(release): 3.0.0-beta.9
lerian-studio Jul 21, 2026
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
5 changes: 5 additions & 0 deletions .trivyignore
Original file line number Diff line number Diff line change
@@ -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
69 changes: 69 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,72 @@
## [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)
Comment thread
rodrigodh marked this conversation as resolved.


### 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)


Expand Down
57 changes: 54 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Comment thread
rodrigodh marked this conversation as resolved.
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=
Comment thread
rodrigodh marked this conversation as resolved.
# 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 All @@ -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)
```
Expand Down Expand Up @@ -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)
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
}
}
Loading
Loading