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
7 changes: 2 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
93 changes: 0 additions & 93 deletions src/middleware/jti_cache.go

This file was deleted.

25 changes: 0 additions & 25 deletions src/middleware/jwt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
29 changes: 4 additions & 25 deletions src/middleware/jwt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)
}
}

Expand All @@ -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")
}
}
Loading