Skip to content

Commit ac2eefb

Browse files
markmnlCopilot
andauthored
Implement EdDSA JWTs (#12)
* implement EdDSA JWTs * Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent d0b42be commit ac2eefb

7 files changed

Lines changed: 702 additions & 164 deletions

File tree

README.md

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ HTTP API providing user/client message handling for an fmsg host. Exposes CRUD o
99
| Variable | Default | Description |
1010
| ------------------- | ------------------------ | ------------------------------------------------------- |
1111
| `FMSG_DATA_DIR` | *(required)* | Path where message data files are stored, e.g. `/var/lib/fmsgd/` |
12-
| `FMSG_API_JWT_SECRET` | *(required)* | HMAC secret used to validate JWT tokens. Prefix with `base64:` to supply a base64-encoded key (e.g. `base64:c2VjcmV0`); otherwise the raw string is used. |
12+
| `FMSG_JWT_JWKS_URL` | *(prod)* | URL of the IdP's JWKS endpoint (e.g. `https://idp.fmsg.io/.well-known/jwks.json`). When set, the API verifies EdDSA tokens issued by the IdP. Public keys are fetched and cached, refreshed and looked up by the token's `kid` header. |
13+
| `FMSG_JWT_ISSUER` | *(prod, required with JWKS)* | Expected `iss` claim value (e.g. `https://idp.fmsg.io`). Tokens with a different issuer are rejected. |
14+
| `FMSG_JWT_AUDIENCE` | *(optional)* | When set, tokens must include this value in their `aud` claim. |
15+
| `FMSG_API_JWT_SECRET` | *(dev)* | HMAC secret for HS256 token verification. Used only in dev mode (when `FMSG_JWT_JWKS_URL` is unset). Prefix with `base64:` to supply a base64-encoded key. Either this or `FMSG_JWT_JWKS_URL` must be set. |
1316
| `FMSG_TLS_CERT` | *(optional)* | Path to the TLS certificate file (e.g. `/etc/letsencrypt/live/example.com/fullchain.pem`). When set with `FMSG_TLS_KEY`, enables HTTPS on port 443. |
1417
| `FMSG_TLS_KEY` | *(optional)* | Path to the TLS private key file (e.g. `/etc/letsencrypt/live/example.com/privkey.pem`). Must be set together with `FMSG_TLS_CERT`. |
1518
| `FMSG_API_PORT` | `8000` | TCP port for plain HTTP mode (ignored when TLS is enabled) |
@@ -26,6 +29,42 @@ Standard PostgreSQL environment variables (`PGHOST`, `PGPORT`, `PGUSER`,
2629
A `.env` file placed in the working directory is loaded automatically at startup
2730
(values in the environment take precedence).
2831

32+
## Authentication
33+
34+
All `/fmsg/*` routes require an `Authorization: Bearer <token>` header. The API
35+
operates in one of two verification modes, selected automatically at startup:
36+
37+
### EdDSA (production)
38+
39+
Active when `FMSG_JWT_JWKS_URL` is set. Tokens are expected to be issued by the
40+
fmsg IdP and signed with Ed25519. The JWKS endpoint is polled on a schedule;
41+
the IdP can rotate keys by adding a new JWK with a fresh `kid`.
42+
43+
Required token header: `alg: EdDSA`, `kid: <known to JWKS>`, `typ: JWT`.
44+
45+
Required claims:
46+
47+
| Claim | Description |
48+
| ----- | ----------- |
49+
| `iss` | Must equal `FMSG_JWT_ISSUER`. |
50+
| `sub` | User address in `@user@domain` form. |
51+
| `iat` | Issued-at timestamp (Unix seconds). |
52+
| `nbf` | Not-before timestamp. |
53+
| `exp` | Expiry timestamp (must be in the future, ±10 s leeway). |
54+
| `jti` | Unique token ID. Used for in-process replay prevention until `exp`. |
55+
| `aud` | Optional; required only when `FMSG_JWT_AUDIENCE` is set. |
56+
57+
A 10-second clock-skew leeway is applied to `iat`/`nbf`/`exp` validation.
58+
Replay prevention is in-process and does not coordinate across multiple API
59+
instances; deploy as a single instance or replace the cache before scaling
60+
horizontally.
61+
62+
### HMAC (development)
63+
64+
Active when `FMSG_JWT_JWKS_URL` is unset. Tokens must be HS256-signed with the
65+
shared secret in `FMSG_API_JWT_SECRET`. Required claims are `sub` and `exp`;
66+
`iat`/`nbf` are honoured when present. No replay prevention is applied.
67+
2968
## Building
3069

3170
Requires **Go 1.25** or newer.
@@ -50,7 +89,8 @@ Set `FMSG_TLS_CERT` and `FMSG_TLS_KEY` to enable HTTPS on port `443`.
5089

5190
```bash
5291
export FMSG_DATA_DIR=/opt/fmsg/data
53-
export FMSG_API_JWT_SECRET=changeme
92+
export FMSG_JWT_JWKS_URL=https://idp.fmsg.io/.well-known/jwks.json
93+
export FMSG_JWT_ISSUER=https://idp.fmsg.io
5494
export FMSG_TLS_CERT=/etc/letsencrypt/live/example.com/fullchain.pem
5595
export FMSG_TLS_KEY=/etc/letsencrypt/live/example.com/privkey.pem
5696
export PGHOST=localhost

src/go.mod

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,16 @@ module github.com/markmnl/fmsg-webapi
33
go 1.25.0
44

55
require (
6-
github.com/appleboy/gin-jwt/v2 v2.10.3
6+
github.com/MicahParks/keyfunc/v3 v3.8.0
77
github.com/gin-gonic/gin v1.12.0
8-
github.com/golang-jwt/jwt/v4 v4.5.2
8+
github.com/golang-jwt/jwt/v5 v5.3.1
99
github.com/jackc/pgx/v5 v5.8.0
1010
github.com/joho/godotenv v1.5.1
1111
golang.org/x/time v0.15.0
1212
)
1313

1414
require (
15+
github.com/MicahParks/jwkset v0.11.0 // indirect
1516
github.com/bytedance/gopkg v0.1.3 // indirect
1617
github.com/bytedance/sonic v1.15.0 // indirect
1718
github.com/bytedance/sonic/loader v0.5.0 // indirect
@@ -37,7 +38,6 @@ require (
3738
github.com/quic-go/quic-go v0.59.0 // indirect
3839
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
3940
github.com/ugorji/go/codec v1.3.1 // indirect
40-
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
4141
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
4242
golang.org/x/arch v0.22.0 // indirect
4343
golang.org/x/crypto v0.48.0 // indirect

src/go.sum

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
github.com/appleboy/gin-jwt/v2 v2.10.3 h1:KNcPC+XPRNpuoBh+j+rgs5bQxN+SwG/0tHbIqpRoBGc=
2-
github.com/appleboy/gin-jwt/v2 v2.10.3/go.mod h1:LDUaQ8mF2W6LyXIbd5wqlV2SFebuyYs4RDwqMNgpsp8=
3-
github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4=
4-
github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw=
1+
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
2+
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
3+
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
4+
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
55
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
66
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
77
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
@@ -31,8 +31,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
3131
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
3232
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
3333
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
34-
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
35-
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
34+
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
35+
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
3636
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
3737
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
3838
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -79,18 +79,10 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
7979
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
8080
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
8181
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
82-
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
83-
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
84-
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
85-
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
86-
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
87-
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
8882
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
8983
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
9084
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
9185
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
92-
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
93-
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
9486
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
9587
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
9688
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=

src/main.go

Lines changed: 48 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"strings"
1313
"time"
1414

15+
"github.com/MicahParks/keyfunc/v3"
1516
"github.com/gin-gonic/gin"
1617
"github.com/joho/godotenv"
1718

@@ -26,8 +27,13 @@ func main() {
2627

2728
// Required configuration.
2829
dataDir := mustEnv("FMSG_DATA_DIR")
29-
jwtSecret := mustEnv("FMSG_API_JWT_SECRET")
30-
jwtKey := parseSecret(jwtSecret)
30+
31+
// JWT configuration. Mode is selected automatically:
32+
// * EdDSA (prod) when FMSG_JWT_JWKS_URL is set.
33+
// * HMAC (dev) otherwise, using FMSG_API_JWT_SECRET.
34+
jwksURL := os.Getenv("FMSG_JWT_JWKS_URL")
35+
jwtIssuer := os.Getenv("FMSG_JWT_ISSUER")
36+
jwtAudience := os.Getenv("FMSG_JWT_AUDIENCE")
3137

3238
// TLS configuration (optional — omit both to run plain HTTP).
3339
tlsCert := os.Getenv("FMSG_TLS_CERT")
@@ -55,7 +61,11 @@ func main() {
5561
log.Println("connected to PostgreSQL")
5662

5763
// Initialise JWT middleware.
58-
jwtMiddleware, err := middleware.SetupJWT(jwtKey, idURL)
64+
jwtCfg, err := buildJWTConfig(ctx, jwksURL, jwtIssuer, jwtAudience, idURL)
65+
if err != nil {
66+
log.Fatalf("failed to configure JWT: %v", err)
67+
}
68+
jwtMiddleware, err := middleware.New(jwtCfg)
5969
if err != nil {
6070
log.Fatalf("failed to initialise JWT middleware: %v", err)
6171
}
@@ -72,7 +82,7 @@ func main() {
7282

7383
// Register routes under /fmsg, all protected by JWT.
7484
fmsg := router.Group("/fmsg")
75-
fmsg.Use(jwtMiddleware.MiddlewareFunc())
85+
fmsg.Use(jwtMiddleware)
7686
{
7787
fmsg.GET("/wait", msgHandler.Wait)
7888
fmsg.GET("", msgHandler.List)
@@ -145,6 +155,40 @@ func envOrDefaultInt(key string, defaultValue int) int {
145155
return defaultValue
146156
}
147157

158+
// buildJWTConfig assembles a middleware.Config from environment-derived
159+
// inputs, picking EdDSA (prod) when a JWKS URL is supplied and falling back
160+
// to HMAC (dev) otherwise.
161+
func buildJWTConfig(ctx context.Context, jwksURL, issuer, audience, idURL string) (middleware.Config, error) {
162+
cfg := middleware.Config{
163+
Issuer: issuer,
164+
Audience: audience,
165+
IDURL: idURL,
166+
}
167+
168+
if jwksURL != "" {
169+
if issuer == "" {
170+
return cfg, errors.New("FMSG_JWT_ISSUER is required when FMSG_JWT_JWKS_URL is set")
171+
}
172+
k, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
173+
if err != nil {
174+
return cfg, err
175+
}
176+
cfg.Mode = middleware.ModeEdDSA
177+
cfg.JWKS = k.Keyfunc
178+
log.Printf("JWT mode: EdDSA (issuer=%s, jwks=%s)", issuer, jwksURL)
179+
return cfg, nil
180+
}
181+
182+
secret := os.Getenv("FMSG_API_JWT_SECRET")
183+
if secret == "" {
184+
return cfg, errors.New("either FMSG_JWT_JWKS_URL (prod) or FMSG_API_JWT_SECRET (dev) must be set")
185+
}
186+
cfg.Mode = middleware.ModeHMAC
187+
cfg.HMACKey = parseSecret(secret)
188+
log.Println("JWT mode: HMAC (development)")
189+
return cfg, nil
190+
}
191+
148192
// parseSecret returns the HMAC key bytes for the given secret string.
149193
// If s begins with "base64:" the remainder is base64-decoded; otherwise the
150194
// raw string bytes are used.

src/middleware/jti_cache.go

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package middleware
2+
3+
import (
4+
"sync"
5+
"time"
6+
)
7+
8+
// jtiCacheMaxEntries bounds memory usage of the in-process replay cache.
9+
// When exceeded, expired entries are swept first; if still over the limit,
10+
// new entries are dropped (the request is still rejected only on a true
11+
// duplicate, never on overflow).
12+
const jtiCacheMaxEntries = 100_000
13+
14+
// jtiCache tracks JWT IDs that have already been seen, until their
15+
// corresponding token expiry, to prevent replay attacks.
16+
//
17+
// The cache lives in-process; it does not coordinate across multiple API
18+
// instances. For a horizontally-scaled deployment, replace with a shared
19+
// store (e.g. Postgres or Redis).
20+
type jtiCache struct {
21+
mu sync.Mutex
22+
entries map[string]time.Time
23+
stop chan struct{}
24+
}
25+
26+
// newJTICache returns a cache with a background sweeper running until Close.
27+
func newJTICache() *jtiCache {
28+
c := &jtiCache{
29+
entries: make(map[string]time.Time),
30+
stop: make(chan struct{}),
31+
}
32+
go c.sweepLoop(time.Minute)
33+
return c
34+
}
35+
36+
// Seen atomically checks whether jti has been recorded with an unexpired
37+
// entry; if not, records it with the given expiry. Returns true if the
38+
// jti was already present (i.e. this is a replay).
39+
//
40+
// Empty jti strings are never considered seen (caller decides policy).
41+
func (c *jtiCache) Seen(jti string, exp time.Time) bool {
42+
if jti == "" {
43+
return false
44+
}
45+
now := time.Now()
46+
c.mu.Lock()
47+
defer c.mu.Unlock()
48+
if existing, ok := c.entries[jti]; ok && existing.After(now) {
49+
return true
50+
}
51+
if len(c.entries) >= jtiCacheMaxEntries {
52+
c.sweepLocked(now)
53+
if len(c.entries) >= jtiCacheMaxEntries {
54+
// Cache full of unexpired entries; refuse to grow but do not
55+
// falsely flag the token as a replay.
56+
return false
57+
}
58+
}
59+
c.entries[jti] = exp
60+
return false
61+
}
62+
63+
// Close stops the background sweeper.
64+
func (c *jtiCache) Close() {
65+
select {
66+
case <-c.stop:
67+
default:
68+
close(c.stop)
69+
}
70+
}
71+
72+
func (c *jtiCache) sweepLoop(interval time.Duration) {
73+
t := time.NewTicker(interval)
74+
defer t.Stop()
75+
for {
76+
select {
77+
case <-c.stop:
78+
return
79+
case now := <-t.C:
80+
c.mu.Lock()
81+
c.sweepLocked(now)
82+
c.mu.Unlock()
83+
}
84+
}
85+
}
86+
87+
func (c *jtiCache) sweepLocked(now time.Time) {
88+
for k, exp := range c.entries {
89+
if !exp.After(now) {
90+
delete(c.entries, k)
91+
}
92+
}
93+
}

0 commit comments

Comments
 (0)