diff --git a/README.md b/README.md index 730ce16..6679825 100644 --- a/README.md +++ b/README.md @@ -53,19 +53,16 @@ Required claims: | `iat` | Issued-at timestamp (Unix seconds). | | `nbf` | Not-before timestamp. | | `exp` | Expiry timestamp (must be in the future, ±10 s leeway). | -| `jti` | Unique token ID. Used for in-process replay prevention until `exp`. | +| `jti` | Optional unique token ID. | | `aud` | Optional; required only when `FMSG_JWT_AUDIENCE` is set. | A 10-second clock-skew leeway is applied to `iat`/`nbf`/`exp` validation. -Replay prevention is in-process and does not coordinate across multiple API -instances; deploy as a single instance or replace the cache before scaling -horizontally. ### HMAC (development) Active when `FMSG_JWT_JWKS_URL` is unset. Tokens must be HS256-signed with the shared secret in `FMSG_API_JWT_SECRET`. Required claims are `sub` and `exp`; -`iat`/`nbf` are honoured when present. No replay prevention is applied. +`iat`/`nbf` are honoured when present. ## Building diff --git a/src/middleware/jti_cache.go b/src/middleware/jti_cache.go deleted file mode 100644 index cbe2480..0000000 --- a/src/middleware/jti_cache.go +++ /dev/null @@ -1,93 +0,0 @@ -package middleware - -import ( - "sync" - "time" -) - -// jtiCacheMaxEntries bounds memory usage of the in-process replay cache. -// When exceeded, expired entries are swept first; if still over the limit, -// new entries are dropped (the request is still rejected only on a true -// duplicate, never on overflow). -const jtiCacheMaxEntries = 100_000 - -// jtiCache tracks JWT IDs that have already been seen, until their -// corresponding token expiry, to prevent replay attacks. -// -// The cache lives in-process; it does not coordinate across multiple API -// instances. For a horizontally-scaled deployment, replace with a shared -// store (e.g. Postgres or Redis). -type jtiCache struct { - mu sync.Mutex - entries map[string]time.Time - stop chan struct{} -} - -// newJTICache returns a cache with a background sweeper running until Close. -func newJTICache() *jtiCache { - c := &jtiCache{ - entries: make(map[string]time.Time), - stop: make(chan struct{}), - } - go c.sweepLoop(time.Minute) - return c -} - -// Seen atomically checks whether jti has been recorded with an unexpired -// entry; if not, records it with the given expiry. Returns true if the -// jti was already present (i.e. this is a replay). -// -// Empty jti strings are never considered seen (caller decides policy). -func (c *jtiCache) Seen(jti string, exp time.Time) bool { - if jti == "" { - return false - } - now := time.Now() - c.mu.Lock() - defer c.mu.Unlock() - if existing, ok := c.entries[jti]; ok && existing.After(now) { - return true - } - if len(c.entries) >= jtiCacheMaxEntries { - c.sweepLocked(now) - if len(c.entries) >= jtiCacheMaxEntries { - // Cache full of unexpired entries; refuse to grow but do not - // falsely flag the token as a replay. - return false - } - } - c.entries[jti] = exp - return false -} - -// Close stops the background sweeper. -func (c *jtiCache) Close() { - select { - case <-c.stop: - default: - close(c.stop) - } -} - -func (c *jtiCache) sweepLoop(interval time.Duration) { - t := time.NewTicker(interval) - defer t.Stop() - for { - select { - case <-c.stop: - return - case now := <-t.C: - c.mu.Lock() - c.sweepLocked(now) - c.mu.Unlock() - } - } -} - -func (c *jtiCache) sweepLocked(now time.Time) { - for k, exp := range c.entries { - if !exp.After(now) { - delete(c.entries, k) - } - } -} diff --git a/src/middleware/jwt.go b/src/middleware/jwt.go index e8f530e..8a3c353 100644 --- a/src/middleware/jwt.go +++ b/src/middleware/jwt.go @@ -67,7 +67,6 @@ type Config struct { // - extracts a Bearer token from the Authorization header, // - parses & verifies the signature according to cfg.Mode, // - validates iss/aud/exp/nbf claims, -// - rejects replays (EdDSA mode only) by tracking jti in-process, // - extracts sub as the user address and validates its shape, // - calls fmsgid to confirm the user is known and accepting messages, // - on success stores the address in the Gin context under IdentityKey. @@ -129,11 +128,6 @@ func New(cfg Config) (gin.HandlerFunc, error) { } parser := jwt.NewParser(parserOpts...) - var replay *jtiCache - if cfg.Mode == ModeEdDSA { - replay = newJTICache() - } - idURL := cfg.IDURL return func(c *gin.Context) { @@ -157,25 +151,6 @@ func New(cfg Config) (gin.HandlerFunc, error) { return } - if replay != nil { - jti, _ := claims["jti"].(string) - if jti == "" { - log.Printf("auth rejected: ip=%s addr=%s reason=missing_jti", c.ClientIP(), addr) - respondAuth(c, http.StatusUnauthorized, "invalid token") - return - } - expTime, err := claims.GetExpirationTime() - if err != nil || expTime == nil { - respondAuth(c, http.StatusUnauthorized, "invalid token") - return - } - if replay.Seen(jti, expTime.Time) { - log.Printf("auth rejected: ip=%s addr=%s reason=jti_replay jti=%s", c.ClientIP(), addr, jti) - respondAuth(c, http.StatusUnauthorized, "token already used") - return - } - } - code, accepting, err := checkFmsgID(idURL, addr) if err != nil { log.Printf("fmsgid check error for %s: %v", addr, err) diff --git a/src/middleware/jwt_test.go b/src/middleware/jwt_test.go index 1cbd94b..b98f653 100644 --- a/src/middleware/jwt_test.go +++ b/src/middleware/jwt_test.go @@ -282,7 +282,7 @@ func TestEdDSAMode_Expired(t *testing.T) { } } -func TestEdDSAMode_Replay(t *testing.T) { +func TestEdDSAMode_Reuse(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() priv, jwks := newEdDSAFixture(t) @@ -295,15 +295,15 @@ func TestEdDSAMode_Replay(t *testing.T) { "sub": "@alice@example.com", "iat": time.Now().Unix(), "exp": time.Now().Add(time.Hour).Unix(), - "jti": "replay-me", + "jti": "reuse-me", } tok := signEdDSA(t, priv, "prod-1", claims) if w := runMiddleware(t, mw, tok); w.Code != http.StatusOK { t.Fatalf("first call expected 200, got %d", w.Code) } - if w := runMiddleware(t, mw, tok); w.Code != http.StatusUnauthorized { - t.Fatalf("replay expected 401, got %d", w.Code) + if w := runMiddleware(t, mw, tok); w.Code != http.StatusOK { + t.Fatalf("reuse expected 200, got %d", w.Code) } } @@ -325,24 +325,3 @@ func TestEdDSAMode_FmsgIDUnavailable(t *testing.T) { t.Fatalf("expected 503, got %d", w.Code) } } - -func TestJTICache_SeenAndExpiry(t *testing.T) { - c := newJTICache() - defer c.Close() - exp := time.Now().Add(time.Hour) - if c.Seen("a", exp) { - t.Fatal("first Seen should be false") - } - if !c.Seen("a", exp) { - t.Fatal("second Seen should be true") - } - // Expired entry should not count as seen. - if c.Seen("b", time.Now().Add(-time.Second)) { - t.Fatal("expired entry: first Seen should be false") - } - // And subsequently the cached entry, having expired in the past, should - // be replaceable. - if c.Seen("b", time.Now().Add(-time.Second)) { - t.Fatal("expired entry: should not flag as replay") - } -}