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
9 changes: 8 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
FMSG_DATA_DIR=/var/lib/fmsgd/

# Production RS256 JWT verification (uncomment all four to use JWKS mode).
#FMSG_JWT_JWKS_URL=https://idp.example.com/.well-known/jwks.json
#FMSG_JWT_ISSUER=https://idp.example.com/
#FMSG_JWT_AUDIENCE=fmsg-web-client
#FMSG_JWT_ADDRESS_CLAIM=fmsg_address

FMSG_API_JWT_SECRET=fmsg-dev-secret-do-not-use-in-production
PGHOST=localhost
PGUSER=fmsg
Expand All @@ -10,4 +17,4 @@ PGSSLMODE=disable
# with: npx web-push generate-vapid-keys
#FMSG_VAPID_PUBLIC_KEY=
#FMSG_VAPID_PRIVATE_KEY=
#FMSG_VAPID_SUBJECT=mailto:admin@example.com
#FMSG_VAPID_SUBJECT=mailto:admin@example.com
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
- Build: `cd src && go build ./...`
- Test: `cd src && go test ./...`
- Always run tests after making code changes to verify nothing is broken.
- This repository is an open-source implementation and not tied to one specific deployment - config should come from the environment

## README

**Keep `README.md` up to date whenever you:**
**Keep `README.md` up to date and concise whenever you:**

- Add, remove, or rename a route.
- Change a route's query parameters, request body, or response shape.
Expand All @@ -21,7 +22,7 @@ The API routes table and each route's section must reflect the live code in

## Database

- Schema source of truth: `https://github.com/markmnl/fmsgd/blob/add-to-batches/dd.sql`
- Schema source of truth: `https://github.com/markmnl/fmsgd/blob/main/dd.sql`
- Ensure all SQL in Go source files aligns with that schema.
- When adding recipients via the `add-to` route, insert one `msg_add_to_batch`
row (`add_to_from` = authenticated identity, plus `time_added`) and insert the
Expand Down
42 changes: 26 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +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_JWT_JWKS_URL` | *(prod)* | URL of the IdP's JWKS endpoint (e.g. `https://idp.example.com/.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.example.com`). Tokens with a different issuer are rejected. |
| `FMSG_JWT_AUDIENCE` | *(optional)* | When set, tokens must include this value in their `aud` claim. |
| `FMSG_JWT_JWKS_URL` | *(prod)* | JWKS endpoint for the configured identity provider (e.g. `https://idp.example.com/.well-known/jwks.json`). When set, the API verifies RS256 JWTs. RSA 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.example.com/`). Tokens with a different issuer are rejected. This must exactly match the token issuer. |
| `FMSG_JWT_AUDIENCE` | *(prod, required with JWKS)* | Expected `aud` claim value for this application or API. |
| `FMSG_JWT_ADDRESS_CLAIM` | *(prod, required with JWKS)* | JWT claim name containing the fmsg address in `@user@domain` form, e.g. `fmsg_address` or a namespaced custom 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. |
| `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`. |
Expand All @@ -38,27 +39,34 @@ A `.env` file placed in the working directory is loaded automatically at startup
All `/fmsg/*` routes require an `Authorization: Bearer <token>` header. The API
operates in one of two verification modes, selected automatically at startup:

### EdDSA (production)
### RS256 (production, JWKS-backed JWTs)

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`.
Active when `FMSG_JWT_JWKS_URL` is set. Tokens must be issued by the configured
identity provider and signed with RS256. The JWKS endpoint is polled on a
schedule; the provider can rotate keys by adding a new JWK with a fresh `kid`.

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

Required claims:
Relevant 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. |
| `aud` | Must contain `FMSG_JWT_AUDIENCE`. |
| configured address claim | The claim named by `FMSG_JWT_ADDRESS_CLAIM`; must contain the user address in `@user@domain` form. Tokens without this claim are rejected with `403 no fmsg account for this identity`. |
| `sub` | Provider-specific identity. It is validated as part of the signed token but is not used as the fmsg address in RS256 mode. |
| `exp` | Expiry timestamp (must be in the future, ±10 s leeway). |
| `jti` | Optional unique token ID. |
| `aud` | Optional; required only when `FMSG_JWT_AUDIENCE` is set. |
| `iat` | Optional issued-at timestamp; validated when present. |
| `nbf` | Optional not-before timestamp; validated when present. |

A 10-second clock-skew leeway is applied to `iat`/`nbf`/`exp` validation.
A 10-second clock-skew leeway is applied to `iat`/`nbf`/`exp` validation. After
the address claim is validated, the API checks fmsgid to confirm the address is
known and accepting new messages.

Clients must send a JWT that matches the configured issuer and audience and
includes the configured address claim. Whether that token is an ID token or
access token is determined by the identity provider configuration for the
deployment.

### HMAC (development)

Expand Down Expand Up @@ -92,7 +100,9 @@ by default; override with `FMSG_API_PORT`.
```bash
export FMSG_DATA_DIR=/opt/fmsg/data
export FMSG_JWT_JWKS_URL=https://idp.example.com/.well-known/jwks.json
export FMSG_JWT_ISSUER=https://idp.example.com
export FMSG_JWT_ISSUER=https://idp.example.com/
export FMSG_JWT_AUDIENCE=fmsg-web-client
export FMSG_JWT_ADDRESS_CLAIM=fmsg_address
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
2 changes: 1 addition & 1 deletion src/handlers/push.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ type PushHandler struct {
}

// NewPushHandler creates a PushHandler. vapidPublic/vapidPrivate are URL-safe
// base64 VAPID keys; vapidSubject is the VAPID "sub" (e.g. "mailto:admin@fmsg.io").
// base64 VAPID keys; vapidSubject is the VAPID "sub" (e.g. "mailto:admin@example.com").
func NewPushHandler(database *db.DB, msgs *MessageHandler, vapidPublic, vapidPrivate, vapidSubject, iconURL string) *PushHandler {
h := &PushHandler{
DB: database,
Expand Down
28 changes: 15 additions & 13 deletions src/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,12 @@ func main() {
dataDir := mustEnv("FMSG_DATA_DIR")

// JWT configuration. Mode is selected automatically:
// * EdDSA (prod) when FMSG_JWT_JWKS_URL is set.
// * RS256 (prod, JWKS-backed JWTs) 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")
jwtAddressClaim := os.Getenv("FMSG_JWT_ADDRESS_CLAIM")

// TLS configuration (optional — omit both to run plain HTTP).
tlsCert := os.Getenv("FMSG_TLS_CERT")
Expand All @@ -51,7 +52,7 @@ func main() {
shortTextSize := envOrDefaultInt("FMSG_API_SHORT_TEXT_SIZE", 768)

// CORS: comma-separated list of allowed browser origins, e.g.
// "https://fmsg.io,https://www.fmsg.io". Empty disables CORS.
// "https://app.example.com,https://www.example.com". Empty disables CORS.
corsOrigins := parseCSV(os.Getenv("FMSG_CORS_ORIGINS"))

// Web Push (VAPID) configuration. Push is enabled only when all three
Expand All @@ -74,7 +75,7 @@ func main() {
log.Println("connected to PostgreSQL")

// Initialise JWT middleware.
jwtCfg, err := buildJWTConfig(ctx, jwksURL, jwtIssuer, jwtAudience, idURL)
jwtCfg, err := buildJWTConfig(ctx, jwksURL, jwtIssuer, jwtAudience, jwtAddressClaim, idURL)
if err != nil {
log.Fatalf("failed to configure JWT: %v", err)
}
Expand Down Expand Up @@ -233,26 +234,27 @@ func envOrDefaultInt(key string, defaultValue int) int {
}

// 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) {
// inputs, picking RS256 (prod, JWKS-backed JWTs) when a JWKS URL is supplied
// and falling back to HMAC (dev) otherwise.
func buildJWTConfig(ctx context.Context, jwksURL, issuer, audience, addressClaim, idURL string) (middleware.Config, error) {
cfg := middleware.Config{
Issuer: issuer,
Audience: audience,
IDURL: idURL,
Issuer: issuer,
Audience: audience,
AddressClaim: addressClaim,
IDURL: idURL,
}

if jwksURL != "" {
if issuer == "" {
return cfg, errors.New("FMSG_JWT_ISSUER is required when FMSG_JWT_JWKS_URL is set")
if issuer == "" || audience == "" || addressClaim == "" {
return cfg, errors.New("FMSG_JWT_ISSUER, FMSG_JWT_AUDIENCE and FMSG_JWT_ADDRESS_CLAIM are 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.Mode = middleware.ModeRS256
cfg.JWKS = k.Keyfunc
log.Printf("JWT mode: EdDSA (issuer=%s, jwks=%s)", issuer, jwksURL)
log.Printf("JWT mode: RS256 (issuer=%s, jwks=%s, audience=%s, address_claim=%s)", issuer, jwksURL, audience, addressClaim)
return cfg, nil
}

Expand Down
4 changes: 2 additions & 2 deletions src/middleware/cors.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@ import (
// CORSConfig configures the CORS middleware.
type CORSConfig struct {
// AllowedOrigins is the list of exact origins permitted to access the API
// from a browser, e.g. "https://fmsg.io". A single entry of "*" allows any
// origin (only valid when credentials are not used). An empty list
// from a browser, e.g. "https://app.example.com". A single entry of "*"
// allows any origin (only valid when credentials are not used). An empty list
// disables CORS entirely.
AllowedOrigins []string
// AllowedMethods are the HTTP methods returned in the preflight response.
Expand Down
20 changes: 10 additions & 10 deletions src/middleware/cors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ func newCORSTestRouter(origins []string) *gin.Engine {
}

func TestCORS_NoOriginPassesThrough(t *testing.T) {
r := newCORSTestRouter([]string{"https://fmsg.io"})
r := newCORSTestRouter([]string{"https://app.example.com"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
r.ServeHTTP(w, req)
Expand All @@ -37,25 +37,25 @@ func TestCORS_NoOriginPassesThrough(t *testing.T) {
}

func TestCORS_AllowedOriginGetsHeaders(t *testing.T) {
r := newCORSTestRouter([]string{"https://fmsg.io"})
r := newCORSTestRouter([]string{"https://app.example.com"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Origin", "https://fmsg.io")
req.Header.Set("Origin", "https://app.example.com")
r.ServeHTTP(w, req)

if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://fmsg.io" {
t.Errorf("Access-Control-Allow-Origin = %q, want https://fmsg.io", got)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
t.Errorf("Access-Control-Allow-Origin = %q, want https://app.example.com", got)
}
if got := w.Header().Get("Vary"); got == "" {
t.Errorf("Vary header missing")
}
}

func TestCORS_DisallowedOriginGetsNoHeaders(t *testing.T) {
r := newCORSTestRouter([]string{"https://fmsg.io"})
r := newCORSTestRouter([]string{"https://app.example.com"})
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Origin", "https://evil.example")
Expand All @@ -72,7 +72,7 @@ func TestCORS_DisallowedOriginGetsNoHeaders(t *testing.T) {
func TestCORS_PreflightShortCircuits(t *testing.T) {
r := gin.New()
cfg := DefaultCORSConfig()
cfg.AllowedOrigins = []string{"https://fmsg.io"}
cfg.AllowedOrigins = []string{"https://app.example.com"}
r.Use(NewCORS(cfg))
// Downstream middleware that would reject if reached.
r.Use(func(c *gin.Context) {
Expand All @@ -82,15 +82,15 @@ func TestCORS_PreflightShortCircuits(t *testing.T) {

w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodOptions, "/x", nil)
req.Header.Set("Origin", "https://fmsg.io")
req.Header.Set("Origin", "https://app.example.com")
req.Header.Set("Access-Control-Request-Method", "POST")
req.Header.Set("Access-Control-Request-Headers", "Authorization, Content-Type")
r.ServeHTTP(w, req)

if w.Code != http.StatusNoContent {
t.Fatalf("status = %d, want 204", w.Code)
}
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://fmsg.io" {
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
t.Errorf("Access-Control-Allow-Origin = %q", got)
}
if got := w.Header().Get("Access-Control-Allow-Methods"); got == "" {
Expand Down Expand Up @@ -120,7 +120,7 @@ func TestCORS_DisabledWhenNoOrigins(t *testing.T) {
r := newCORSTestRouter(nil)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/x", nil)
req.Header.Set("Origin", "https://fmsg.io")
req.Header.Set("Origin", "https://app.example.com")
r.ServeHTTP(w, req)

if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
Expand Down
Loading
Loading