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
44 changes: 42 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ HTTP API providing user/client message handling for an fmsg host. Exposes CRUD o
| Variable | Default | Description |
| ------------------- | ------------------------ | ------------------------------------------------------- |
| `FMSG_DATA_DIR` | *(required)* | Path where message data files are stored, e.g. `/var/lib/fmsgd/` |
| `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. |
| `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. |
| `FMSG_JWT_ISSUER` | *(prod, required with JWKS)* | Expected `iss` claim value (e.g. `https://idp.fmsg.io`). Tokens with a different issuer are rejected. |
| `FMSG_JWT_AUDIENCE` | *(optional)* | When set, tokens must include this value in their `aud` claim. |
| `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. |
| `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. |
| `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`. |
| `FMSG_API_PORT` | `8000` | TCP port for plain HTTP mode (ignored when TLS is enabled) |
Expand All @@ -26,6 +29,42 @@ Standard PostgreSQL environment variables (`PGHOST`, `PGPORT`, `PGUSER`,
A `.env` file placed in the working directory is loaded automatically at startup
(values in the environment take precedence).

## Authentication

All `/fmsg/*` routes require an `Authorization: Bearer <token>` header. The API
operates in one of two verification modes, selected automatically at startup:

### EdDSA (production)

Active when `FMSG_JWT_JWKS_URL` is set. Tokens are expected to be issued by the
fmsg IdP and signed with Ed25519. The JWKS endpoint is polled on a schedule;
the IdP can rotate keys by adding a new JWK with a fresh `kid`.

Required token header: `alg: EdDSA`, `kid: <known to JWKS>`, `typ: JWT`.

Required claims:

| Claim | Description |
| ----- | ----------- |
| `iss` | Must equal `FMSG_JWT_ISSUER`. |
| `sub` | User address in `@user@domain` form. |
| `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`. |
| `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.

## Building

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

```bash
export FMSG_DATA_DIR=/opt/fmsg/data
export FMSG_API_JWT_SECRET=changeme
export FMSG_JWT_JWKS_URL=https://idp.fmsg.io/.well-known/jwks.json
export FMSG_JWT_ISSUER=https://idp.fmsg.io
export FMSG_TLS_CERT=/etc/letsencrypt/live/example.com/fullchain.pem
export FMSG_TLS_KEY=/etc/letsencrypt/live/example.com/privkey.pem
export PGHOST=localhost
Expand Down
6 changes: 3 additions & 3 deletions src/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,16 @@ module github.com/markmnl/fmsg-webapi
go 1.25.0

require (
github.com/appleboy/gin-jwt/v2 v2.10.3
github.com/MicahParks/keyfunc/v3 v3.8.0
github.com/gin-gonic/gin v1.12.0
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/jackc/pgx/v5 v5.8.0
github.com/joho/godotenv v1.5.1
golang.org/x/time v0.15.0
)

require (
github.com/MicahParks/jwkset v0.11.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
Expand All @@ -37,7 +38,6 @@ require (
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
Expand Down
20 changes: 6 additions & 14 deletions src/go.sum
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
github.com/appleboy/gin-jwt/v2 v2.10.3 h1:KNcPC+XPRNpuoBh+j+rgs5bQxN+SwG/0tHbIqpRoBGc=
github.com/appleboy/gin-jwt/v2 v2.10.3/go.mod h1:LDUaQ8mF2W6LyXIbd5wqlV2SFebuyYs4RDwqMNgpsp8=
github.com/appleboy/gofight/v2 v2.1.2 h1:VOy3jow4vIK8BRQJoC/I9muxyYlJ2yb9ht2hZoS3rf4=
github.com/appleboy/gofight/v2 v2.1.2/go.mod h1:frW+U1QZEdDgixycTj4CygQ48yLTUhplt43+Wczp3rw=
github.com/MicahParks/jwkset v0.11.0 h1:yc0zG+jCvZpWgFDFmvs8/8jqqVBG9oyIbmBtmjOhoyQ=
github.com/MicahParks/jwkset v0.11.0/go.mod h1:U2oRhRaLgDCLjtpGL2GseNKGmZtLs/3O7p+OZaL5vo0=
github.com/MicahParks/keyfunc/v3 v3.8.0 h1:Hx2dgIjAXGk9slakM6rV9BOeaWDPEXXZ4Us8guNBfds=
github.com/MicahParks/keyfunc/v3 v3.8.0/go.mod h1:z66bkCviwqfg2YUp+Jcc/xRE9IXLcMq6DrgV/+Htru0=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
Expand Down Expand Up @@ -31,8 +31,8 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
Expand Down Expand Up @@ -79,18 +79,10 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tidwall/gjson v1.17.1 h1:wlYEnwqAHgzmhNUFfw7Xalt2JzQvsMx2Se4PcoFCT/U=
github.com/tidwall/gjson v1.17.1/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
github.com/tidwall/pretty v1.2.0 h1:RWIZEg2iJ8/g6fDDYzMpobmaoGh5OLl4AXtGUGPcqCs=
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
Expand Down
52 changes: 48 additions & 4 deletions src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strings"
"time"

"github.com/MicahParks/keyfunc/v3"
"github.com/gin-gonic/gin"
"github.com/joho/godotenv"

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

// Required configuration.
dataDir := mustEnv("FMSG_DATA_DIR")
jwtSecret := mustEnv("FMSG_API_JWT_SECRET")
jwtKey := parseSecret(jwtSecret)

// JWT configuration. Mode is selected automatically:
// * EdDSA (prod) when FMSG_JWT_JWKS_URL is set.
// * HMAC (dev) otherwise, using FMSG_API_JWT_SECRET.
jwksURL := os.Getenv("FMSG_JWT_JWKS_URL")
jwtIssuer := os.Getenv("FMSG_JWT_ISSUER")
jwtAudience := os.Getenv("FMSG_JWT_AUDIENCE")

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

// Initialise JWT middleware.
jwtMiddleware, err := middleware.SetupJWT(jwtKey, idURL)
jwtCfg, err := buildJWTConfig(ctx, jwksURL, jwtIssuer, jwtAudience, idURL)
if err != nil {
log.Fatalf("failed to configure JWT: %v", err)
}
jwtMiddleware, err := middleware.New(jwtCfg)
if err != nil {
log.Fatalf("failed to initialise JWT middleware: %v", err)
}
Expand All @@ -72,7 +82,7 @@ func main() {

// Register routes under /fmsg, all protected by JWT.
fmsg := router.Group("/fmsg")
fmsg.Use(jwtMiddleware.MiddlewareFunc())
fmsg.Use(jwtMiddleware)
{
fmsg.GET("/wait", msgHandler.Wait)
fmsg.GET("", msgHandler.List)
Expand Down Expand Up @@ -145,6 +155,40 @@ func envOrDefaultInt(key string, defaultValue int) int {
return defaultValue
}

// buildJWTConfig assembles a middleware.Config from environment-derived
// inputs, picking EdDSA (prod) when a JWKS URL is supplied and falling back
// to HMAC (dev) otherwise.
func buildJWTConfig(ctx context.Context, jwksURL, issuer, audience, idURL string) (middleware.Config, error) {
cfg := middleware.Config{
Issuer: issuer,
Audience: audience,
IDURL: idURL,
}

if jwksURL != "" {
if issuer == "" {
return cfg, errors.New("FMSG_JWT_ISSUER is required when FMSG_JWT_JWKS_URL is set")
}
k, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
if err != nil {
return cfg, err
}
cfg.Mode = middleware.ModeEdDSA
cfg.JWKS = k.Keyfunc
log.Printf("JWT mode: EdDSA (issuer=%s, jwks=%s)", issuer, jwksURL)
return cfg, nil
}

secret := os.Getenv("FMSG_API_JWT_SECRET")
if secret == "" {
return cfg, errors.New("either FMSG_JWT_JWKS_URL (prod) or FMSG_API_JWT_SECRET (dev) must be set")
}
cfg.Mode = middleware.ModeHMAC
cfg.HMACKey = parseSecret(secret)
log.Println("JWT mode: HMAC (development)")
return cfg, nil
}

// parseSecret returns the HMAC key bytes for the given secret string.
// If s begins with "base64:" the remainder is base64-decoded; otherwise the
// raw string bytes are used.
Expand Down
93 changes: 93 additions & 0 deletions src/middleware/jti_cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
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
}
Comment on lines +51 to +57
}
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)
}
}
}
Loading
Loading