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
10 changes: 7 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
FMSG_DATA_DIR=/var/lib/fmsgd/

# Production RS256 JWT verification (uncomment all four to use JWKS mode).
# Production EdDSA JWT verification (uncomment to use JWKS mode).
# FMSG_JWT_AUDIENCE is optional; only set it if your identity provider
# issues an aud claim you want enforced.
#FMSG_JWT_JWKS_URL=https://idp.example.com/.well-known/jwks.json
#FMSG_JWT_ISSUER=https://idp.example.com/
#FMSG_JWT_ADDRESS_CLAIM=sub
#FMSG_JWT_AUDIENCE=fmsg-web-client
#FMSG_JWT_ADDRESS_CLAIM=fmsg_address

FMSG_API_JWT_SECRET=fmsg-dev-secret-do-not-use-in-production
# Enable API keys / sub-accounts (uncomment to enable):
#FMSG_API_TOKEN_ED25519_PRIVATE_KEY=

PGHOST=localhost
PGUSER=fmsg
PGPASSWORD=secret
Expand Down
9 changes: 9 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
# Claude Instructions

Read and follow `AGENTS.md` before making any changes to this repository.

## Don't name a specific IdP

This is a public open-source repo used with many different identity providers.
Never name any specific IdP (product, company, or domain) in code, comments,
commit messages, docs, or README content in this repo. Always refer to it
generically as "the IdP" or "an identity provider", so users integrating their
own IdP aren't confused into thinking a particular one is required or
referenced by this codebase.
91 changes: 59 additions & 32 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +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)* | 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_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 EdDSA (Ed25519) JWTs. 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_JWT_AUDIENCE` | *(optional)* | When set, tokens must include this value in their `aud` claim. Leave unset if your identity provider does not issue an `aud` claim. |
| `FMSG_JWT_ADDRESS_CLAIM` | *(prod, required with JWKS)* | JWT claim name containing the fmsg address in `@user@domain` form, e.g. `sub` or a namespaced custom claim. |
| `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` | *(optional)* | Base64-encoded Ed25519 private key or seed used to mint first-party JWTs from API keys. Required to enable `/fmsg/token` and sub-account routes. |
| `FMSG_API_TOKEN_ISSUER` | `fmsg-webapi` | Issuer for first-party API-key JWTs. |
| `FMSG_API_TOKEN_AUDIENCE` | `fmsg-webapi` | Audience for first-party API-key JWTs. |
Expand Down Expand Up @@ -48,27 +48,27 @@ A `.env` file placed in the working directory is loaded automatically at startup
Most `/fmsg/*` routes require an `Authorization: Bearer <token>` header. The
API can enable either or both authentication methods at startup:

- RS256/JWKS tokens from an external identity provider.
- EdDSA/JWKS tokens from an external identity provider.
- First-party Ed25519 JWTs minted by `POST /fmsg/token` from opaque API keys.

Startup fails unless at least one method is configured.

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

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`.
identity provider and signed with EdDSA (Ed25519). 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: RS256`, `kid: <known to JWKS>`, `typ: JWT`.
Required token header: `alg: EdDSA`, `kid: <known to JWKS>`, `typ: JWT`.

Relevant claims:

| Claim | Description |
| ----- | ----------- |
| `iss` | Must equal `FMSG_JWT_ISSUER`. |
| `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. |
| `aud` | Must contain `FMSG_JWT_AUDIENCE`, only checked when that variable is set. |
| configured address claim | The claim named by `FMSG_JWT_ADDRESS_CLAIM` (commonly `sub`); must contain the user address in `@user@domain` form. Tokens without this claim are rejected with `403 no fmsg account for this identity`. |
| `exp` | Expiry timestamp (must be in the future, ±10 s leeway). |
| `iat` | Optional issued-at timestamp; validated when present. |
| `nbf` | Optional not-before timestamp; validated when present. |
Expand All @@ -77,10 +77,10 @@ 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.
Clients must send a JWT that matches the configured issuer (and audience, if
configured) 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.

### API Keys And First-Party JWTs

Expand All @@ -101,7 +101,7 @@ The returned JWT contains `sub` (the granted address), `owner`, `api_key_id`,
each request, so deleting a grant or expiring its key invalidates existing
tokens before their normal expiry.

An RS256-authenticated owner can perform normal message routes as one of their
An EdDSA-authenticated owner can perform normal message routes as one of their
granted identities without changing request bodies:

```http
Expand All @@ -123,7 +123,7 @@ ON CONFLICT (owner_addr, agent)
DO UPDATE SET max_sub_accounts = EXCLUDED.max_sub_accounts;
```

Operators can bootstrap or rotate keys without RS256 by using the built-in CLI
Operators can bootstrap or rotate keys without EdDSA by using the built-in CLI
command. It uses the standard `PG*` connection environment variables and prints
the plaintext API key once.

Expand All @@ -146,14 +146,14 @@ Delegated identity:

```bash
go run ./cmd/fmsg-webapi api-key create-delegation \
-owner @[email protected] \
-owner @[email protected] \
-agent sales \
-addr @sales@fmsg.io \
-addr @sales@example.com \
-cidr 203.0.113.0/24 \
-expires 2026-12-31T00:00:00Z

go run ./cmd/fmsg-webapi api-key rotate-delegation \
-owner @[email protected] \
-owner @[email protected] \
-agent sales \
-expires 2027-03-31T00:00:00Z
```
Expand Down Expand Up @@ -183,8 +183,9 @@ by default; override with `FMSG_API_PORT`.
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_AUDIENCE=fmsg-web-client
export FMSG_JWT_ADDRESS_CLAIM=fmsg_address
export FMSG_JWT_ADDRESS_CLAIM=sub
# Optional: only set this if your identity provider issues an aud claim.
# export FMSG_JWT_AUDIENCE=fmsg-web-client
# Optional: also enable programmatic API keys.
# export FMSG_API_TOKEN_ED25519_PRIVATE_KEY=$(openssl rand -base64 32)
export FMSG_TLS_CERT=/etc/letsencrypt/live/example.com/fullchain.pem
Expand Down Expand Up @@ -237,12 +238,14 @@ the application.

| Method | Path | Description |
| -------- | ------------------------------------------- | ------------------------ |
| `GET` | `/fmsg` | List messages for user |
| `GET` | `/fmsg` | List messages for the authenticated identity |
| `GET` | `/fmsg/sent` | List authored messages (sent + drafts) |
| `GET` | `/fmsg/ws` | WebSocket for pushed event notifications |
| `POST` | `/fmsg/token` | Exchange an API key for a JWT |
| `GET` | `/fmsg/sub-accounts` | List owned API-access grants |
| `POST` | `/fmsg/sub-accounts` | Create a derived sub-account API key |
| `GET` | `/fmsg/sub-accounts/:agent` | Retrieve a grant |
| `PATCH` | `/fmsg/sub-accounts/:agent` | Replace a grant's allowed CIDRs |
| `POST` | `/fmsg/sub-accounts/:agent/rotate-key` | Rotate a grant API key |
| `DELETE` | `/fmsg/sub-accounts/:agent` | Delete a grant |
| `POST` | `/fmsg` | Create a draft message |
Expand Down Expand Up @@ -287,7 +290,7 @@ and belong to a granted address that exists in fmsgid.

### GET `/fmsg/sub-accounts`

Lists API-access grants owned by the RS256-authenticated user. Grants with
Lists API-access grants owned by the EdDSA-authenticated user. Grants with
`grant_type: "derived_sub_account"` use the `@user_agent@domain` convention.
Grants with `grant_type: "delegated_identity"` are explicit operator-created
delegations to arbitrary fmsg addresses.
Expand Down Expand Up @@ -322,7 +325,7 @@ delegations to arbitrary fmsg addresses.
### POST `/fmsg/sub-accounts`

Creates a derived sub-account and returns its plaintext API key once. Requires
RS256 owner authentication.
EdDSA owner authentication.

```json
{
Expand All @@ -340,11 +343,28 @@ self-service route. They are operator-created with `api-key create-delegation`
after the operator has confirmed the owner is allowed to manage the delegated
address.

### GET `/fmsg/sub-accounts/:agent`

Retrieves a single grant owned by the EdDSA-authenticated user. Same response
shape as one entry from `GET /fmsg/sub-accounts`.

### PATCH `/fmsg/sub-accounts/:agent`

Replaces a grant's allowed CIDRs without rotating its API key.

```json
{
"allowed_cidrs": ["203.0.113.0/24"]
}
```

**Response:** the updated grant, same shape as `GET /fmsg/sub-accounts/:agent`.

### POST `/fmsg/sub-accounts/:agent/rotate-key`

Rotates any grant API key owned by the RS256-authenticated user and returns the
new plaintext key once. Requires `key_expires_at`; `allowed_cidrs` may be
supplied to replace the existing ranges.
Rotates any grant API key owned by the EdDSA-authenticated user and returns the
new plaintext key once. Requires `key_expires_at`. Does not touch
`allowed_cidrs` — use `PATCH /fmsg/sub-accounts/:agent` for that.

### DELETE `/fmsg/sub-accounts/:agent`

Expand Down Expand Up @@ -391,7 +411,14 @@ ws.onmessage = (e) => {

### GET `/fmsg`

Returns messages where the authenticated user is a recipient (listed in `msg_to` or `msg_add_to`), ordered by message ID descending.
Returns messages where the authenticated identity is a recipient (listed in `msg_to` or `msg_add_to`), ordered by message ID descending.

Visibility is always scoped to exactly the one identity the caller
authenticated as — the owner, or (via a sub-account's own API-key token or
the owner's `X-FMSG-Act-As` header) a single sub-account. A request never
sees another identity's messages, including an owner's own sub-accounts', in
the same call. The `read`/`time_read` field reflects whether that identity
has read the message.

**Query parameters:**

Expand All @@ -404,7 +431,7 @@ Returns messages where the authenticated user is a recipient (listed in `msg_to`

### GET `/fmsg/sent`

Returns messages authored by the authenticated user (`msg.from_addr = <identity>`), ordered by message ID descending.
Returns messages authored by the authenticated identity (`msg.from_addr`), ordered by message ID descending.

This includes both sent messages and drafts (`time_sent` may be `NULL`).

Expand Down Expand Up @@ -449,7 +476,7 @@ Add-to recipients are not part of this body — they are added later via `POST /

### GET `/fmsg/:id`

Retrieves a single message by ID. The authenticated user must be a participant — the sender (`from`) or a recipient (listed in `to` or `add_to`).
Retrieves a single message by ID. The authenticated identity must be a participant — the sender (`from`) or a recipient (listed in `to` or `add_to`).

**Response:** JSON message object:

Expand Down Expand Up @@ -585,7 +612,7 @@ New addresses must be distinct among themselves (case-insensitive).

### GET `/fmsg/:id/data`

Downloads the binary body of a message. The authenticated user must be a participant — the sender (`from`) or a recipient (listed in `to` or `add_to`).
Downloads the binary body of a message. The authenticated identity must be a participant — the sender (`from`) or a recipient (listed in `to` or `add_to`).

**Response:** The raw message body file with `Content-Disposition: attachment` header. The `Content-Type` is inferred from the stored file extension.

Expand Down
16 changes: 12 additions & 4 deletions cmd/fmsg-webapi/apikey_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,18 @@ func runAPIKeyRotate(ctx context.Context, args []string) error {
defer database.Close()

store := apiauth.NewStore(database)
replaceCIDRs := strings.TrimSpace(*cidrs) != ""
gotSubAddr, err := store.RotateKey(ctx, *owner, *agent, key.ID, hash, expires, allowed, replaceCIDRs)
gotSubAddr, err := store.RotateKey(ctx, *owner, *agent, key.ID, hash, expires)
if err != nil {
return err
}
if !strings.EqualFold(gotSubAddr, subAddr) {
return fmt.Errorf("stored sub-account address %s does not match derived address %s", gotSubAddr, subAddr)
}
if strings.TrimSpace(*cidrs) != "" {
if _, err := store.UpdateCIDRs(ctx, *owner, *agent, allowed); err != nil {
return err
}
}
printCLIKey(*owner, *agent, subAddr, key)
return nil
}
Expand Down Expand Up @@ -156,11 +160,15 @@ func runAPIKeyRotateDelegation(ctx context.Context, args []string) error {
defer database.Close()

store := apiauth.NewStore(database)
replaceCIDRs := strings.TrimSpace(*cidrs) != ""
subAddr, err := store.RotateKey(ctx, *owner, *agent, key.ID, hash, expires, allowed, replaceCIDRs)
subAddr, err := store.RotateKey(ctx, *owner, *agent, key.ID, hash, expires)
if err != nil {
return err
}
if strings.TrimSpace(*cidrs) != "" {
if _, err := store.UpdateCIDRs(ctx, *owner, *agent, allowed); err != nil {
return err
}
}
printCLIKey(*owner, *agent, subAddr, key)
return nil
}
Expand Down
8 changes: 4 additions & 4 deletions cmd/fmsg-webapi/apikey_cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import (

func TestPrepareCLIGrantInputsAllowsArbitraryDelegatedAddressFlow(t *testing.T) {
expires := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
allowed, gotExpires, key, hash, err := prepareCLIGrantInputs("@mark@fmsg.io", "sales", "203.0.113.0/24", expires)
allowed, gotExpires, key, hash, err := prepareCLIGrantInputs("@mark@example.com", "sales", "203.0.113.0/24", expires)
if err != nil {
t.Fatal(err)
}
Expand All @@ -25,18 +25,18 @@ func TestPrepareCLIGrantInputsAllowsArbitraryDelegatedAddressFlow(t *testing.T)

func TestPrepareCLIKeyInputsStillDerivesSubAccountAddress(t *testing.T) {
expires := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
subAddr, _, _, _, _, err := prepareCLIKeyInputs("@mark@fmsg.io", "bot", "203.0.113.0/24", expires)
subAddr, _, _, _, _, err := prepareCLIKeyInputs("@mark@example.com", "bot", "203.0.113.0/24", expires)
if err != nil {
t.Fatal(err)
}
if subAddr != "@mark_bot@fmsg.io" {
if subAddr != "@mark_bot@example.com" {
t.Fatalf("subAddr = %q", subAddr)
}
}

func TestPrepareCLIGrantInputsRejectsInvalidAgent(t *testing.T) {
expires := time.Now().Add(time.Hour).UTC().Format(time.RFC3339)
_, _, _, _, err := prepareCLIGrantInputs("@mark@fmsg.io", "sales_team", "203.0.113.0/24", expires)
_, _, _, _, err := prepareCLIGrantInputs("@mark@example.com", "sales_team", "203.0.113.0/24", expires)
if err == nil || !strings.Contains(err.Error(), "invalid agent") {
t.Fatalf("err = %v", err)
}
Expand Down
14 changes: 8 additions & 6 deletions cmd/fmsg-webapi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ func main() {
// Required configuration.
dataDir := mustEnv("FMSG_DATA_DIR")

// JWT configuration. RS256 provider JWTs and first-party Ed25519 API
// JWT configuration. EdDSA provider JWTs and first-party Ed25519 API
// tokens can be enabled independently.
jwksURL := os.Getenv("FMSG_JWT_JWKS_URL")
jwtIssuer := os.Getenv("FMSG_JWT_ISSUER")
Expand Down Expand Up @@ -137,7 +137,7 @@ func main() {
// Global rate limiting is handled by nftables at the host level.

// Instantiate handlers.
msgHandler := handlers.NewMessageHandler(database, dataDir, maxDataSize, maxMsgSize, shortTextSize)
msgHandler := handlers.NewMessageHandler(database, dataDir, maxDataSize, maxMsgSize, shortTextSize, apiStore)
attHandler := handlers.NewAttachmentHandler(database, dataDir, maxAttachSize, maxMsgSize)

// Web Push handler: stores subscriptions and delivers VAPID pushes for
Expand Down Expand Up @@ -174,6 +174,8 @@ func main() {
subAccountHandler := handlers.NewSubAccountHandler(apiStore, idURL)
fmsg.GET("/sub-accounts", subAccountHandler.List)
fmsg.POST("/sub-accounts", subAccountHandler.Create)
fmsg.GET("/sub-accounts/:agent", subAccountHandler.Get)
fmsg.PATCH("/sub-accounts/:agent", subAccountHandler.UpdateCIDRs)
fmsg.POST("/sub-accounts/:agent/rotate-key", subAccountHandler.RotateKey)
fmsg.DELETE("/sub-accounts/:agent", subAccountHandler.Delete)
}
Expand Down Expand Up @@ -297,17 +299,17 @@ func buildJWTConfig(ctx context.Context, jwksURL, issuer, audience, addressClaim
}

if jwksURL != "" {
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")
if issuer == "" || addressClaim == "" {
return cfg, errors.New("FMSG_JWT_ISSUER 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.JWKS = k.Keyfunc
log.Printf("RS256 auth enabled (issuer=%s, jwks=%s, audience=%s, address_claim=%s)", issuer, jwksURL, audience, addressClaim)
log.Printf("EdDSA auth enabled (issuer=%s, jwks=%s, audience=%q, address_claim=%s)", issuer, jwksURL, audience, addressClaim)
} else {
log.Println("RS256 auth disabled (FMSG_JWT_JWKS_URL not set)")
log.Println("EdDSA auth disabled (FMSG_JWT_JWKS_URL not set)")
}

if tokenIssuer != nil {
Expand Down
Loading
Loading