Skip to content

Commit 3db4452

Browse files
authored
Added API keys to acquire JWTs for programmatic access (#26)
* api-keys * no underscore
1 parent 7ca8535 commit 3db4452

15 files changed

Lines changed: 1698 additions & 453 deletions

File tree

README.md

Lines changed: 147 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ HTTP API providing user/client message handling for an fmsg host. Exposes CRUD o
1313
| `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. |
1414
| `FMSG_JWT_AUDIENCE` | *(prod, required with JWKS)* | Expected `aud` claim value for this application or API. |
1515
| `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. |
16-
| `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. |
16+
| `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. |
17+
| `FMSG_API_TOKEN_ISSUER` | `fmsg-webapi` | Issuer for first-party API-key JWTs. |
18+
| `FMSG_API_TOKEN_AUDIENCE` | `fmsg-webapi` | Audience for first-party API-key JWTs. |
19+
| `FMSG_API_TOKEN_TTL` | `12h` | Lifetime of JWTs minted by `POST /fmsg/token`. |
20+
| `FMSG_TRUSTED_PROXIES` | *(optional)* | Comma-separated trusted proxy CIDRs/IPs for Gin client IP resolution. Leave unset to use direct client addresses for API-key CIDR checks. |
1721
| `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. |
1822
| `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`. |
1923
| `FMSG_API_PORT` | `443` (TLS) / `8000` (plain) | TCP port to listen on. |
@@ -36,8 +40,13 @@ A `.env` file placed in the working directory is loaded automatically at startup
3640

3741
## Authentication
3842

39-
All `/fmsg/*` routes require an `Authorization: Bearer <token>` header. The API
40-
operates in one of two verification modes, selected automatically at startup:
43+
Most `/fmsg/*` routes require an `Authorization: Bearer <token>` header. The
44+
API can enable either or both authentication methods at startup:
45+
46+
- RS256/JWKS tokens from an external identity provider.
47+
- First-party Ed25519 JWTs minted by `POST /fmsg/token` from opaque API keys.
48+
49+
Startup fails unless at least one method is configured.
4150

4251
### RS256 (production, JWKS-backed JWTs)
4352

@@ -68,11 +77,60 @@ includes the configured address claim. Whether that token is an ID token or
6877
access token is determined by the identity provider configuration for the
6978
deployment.
7079

71-
### HMAC (development)
80+
### API Keys And First-Party JWTs
81+
82+
Active when `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` is set. Programmatic clients
83+
authenticate with opaque API keys bound to sub-account addresses. The server
84+
stores only API-key hashes and exchanges valid keys for short-lived Ed25519 JWTs.
85+
86+
API keys are sent only to `POST /fmsg/token`:
87+
88+
```http
89+
Authorization: Bearer fmsgk_<key_id>_<secret>
90+
```
91+
92+
The returned JWT contains `sub` (the sub-account address), `owner`, `api_key_id`,
93+
`iss`, `aud`, `iat`, and `exp`. Protected routes re-check the backing key row on
94+
each request, so deleting a sub-account or expiring its key invalidates existing
95+
tokens before their normal expiry.
96+
97+
An RS256-authenticated owner can perform normal message routes as one of their
98+
sub-accounts without changing request bodies:
99+
100+
```http
101+
X-FMSG-Act-As: @user_bot@example.com
102+
```
103+
104+
The requested sub-account must be owned by the authenticated user and must exist
105+
in fmsgid.
106+
107+
Apply [api_keys.sql](api_keys.sql) before enabling API-key auth.
108+
109+
To set a custom per-owner sub-account limit, insert an owner config row:
72110

73-
Active when `FMSG_JWT_JWKS_URL` is unset. Tokens must be HS256-signed with the
74-
shared secret in `FMSG_API_JWT_SECRET`. Required claims are `sub` and `exp`;
75-
`iat`/`nbf` are honoured when present.
111+
```sql
112+
INSERT INTO fmsg_api_sub_account (owner_addr, agent, max_sub_accounts)
113+
VALUES ('@alice@example.com', '', 10)
114+
ON CONFLICT (owner_addr, agent)
115+
DO UPDATE SET max_sub_accounts = EXCLUDED.max_sub_accounts;
116+
```
117+
118+
Operators can bootstrap or rotate keys without RS256 by using the built-in CLI
119+
command. It uses the standard `PG*` connection environment variables and prints
120+
the plaintext API key once:
121+
122+
```bash
123+
go run ./cmd/fmsg-webapi api-key create \
124+
-owner @alice@example.com \
125+
-agent bot \
126+
-cidr 203.0.113.0/24 \
127+
-expires 2026-12-31T00:00:00Z
128+
129+
go run ./cmd/fmsg-webapi api-key rotate \
130+
-owner @alice@example.com \
131+
-agent bot \
132+
-expires 2027-03-31T00:00:00Z
133+
```
76134

77135
## Building
78136

@@ -101,6 +159,8 @@ export FMSG_JWT_JWKS_URL=https://idp.example.com/.well-known/jwks.json
101159
export FMSG_JWT_ISSUER=https://idp.example.com/
102160
export FMSG_JWT_AUDIENCE=fmsg-web-client
103161
export FMSG_JWT_ADDRESS_CLAIM=fmsg_address
162+
# Optional: also enable programmatic API keys.
163+
# export FMSG_API_TOKEN_ED25519_PRIVATE_KEY=$(openssl rand -base64 32)
104164
export FMSG_TLS_CERT=/etc/letsencrypt/live/example.com/fullchain.pem
105165
export FMSG_TLS_KEY=/etc/letsencrypt/live/example.com/privkey.pem
106166
export PGHOST=localhost
@@ -122,7 +182,7 @@ proxying `https://fmsgapi.example.com/` to `http://127.0.0.1:8000/`).
122182

123183
```bash
124184
export FMSG_DATA_DIR=/var/lib/fmsgd/
125-
export FMSG_API_JWT_SECRET=changeme
185+
export FMSG_API_TOKEN_ED25519_PRIVATE_KEY=$(openssl rand -base64 32)
126186
export PGHOST=localhost
127187
export PGUSER=fmsg
128188
export PGPASSWORD=secret
@@ -141,7 +201,10 @@ the HTTP server and kept alive by its own ping/pong heartbeat.
141201

142202
## API Routes
143203

144-
All routes are prefixed with `/fmsg` and require a valid `Authorization: Bearer <token>` header. The one exception is the WebSocket route `/fmsg/ws`, which additionally accepts the token via an `access_token` query parameter (browsers cannot set headers on a WebSocket).
204+
All routes are prefixed with `/fmsg`. `POST /fmsg/token` accepts an API key and
205+
returns a JWT. Other routes require a valid `Authorization: Bearer <token>`
206+
header. The WebSocket route `/fmsg/ws` additionally accepts the token via an
207+
`access_token` query parameter (browsers cannot set headers on a WebSocket).
145208

146209
Rate limiting is enforced at the host level (e.g. `nftables`) rather than in
147210
the application.
@@ -151,6 +214,11 @@ the application.
151214
| `GET` | `/fmsg` | List messages for user |
152215
| `GET` | `/fmsg/sent` | List authored messages (sent + drafts) |
153216
| `GET` | `/fmsg/ws` | WebSocket for pushed event notifications |
217+
| `POST` | `/fmsg/token` | Exchange an API key for a JWT |
218+
| `GET` | `/fmsg/sub-accounts` | List owned sub-accounts |
219+
| `POST` | `/fmsg/sub-accounts` | Create a sub-account API key |
220+
| `POST` | `/fmsg/sub-accounts/:agent/rotate-key` | Rotate a sub-account API key |
221+
| `DELETE` | `/fmsg/sub-accounts/:agent` | Delete a sub-account |
154222
| `POST` | `/fmsg` | Create a draft message |
155223
| `GET` | `/fmsg/:id` | Retrieve a message |
156224
| `PUT` | `/fmsg/:id` | Update a draft message |
@@ -168,6 +236,76 @@ the application.
168236
The `/fmsg/push/subscribe` routes are registered only when Web Push is
169237
configured (see [Web Push](#web-push)).
170238

239+
The `/fmsg/token` and `/fmsg/sub-accounts*` routes are registered only when
240+
API-key auth is configured with `FMSG_API_TOKEN_ED25519_PRIVATE_KEY`.
241+
242+
### POST `/fmsg/token`
243+
244+
Exchanges an opaque API key for a short-lived JWT.
245+
246+
**Authentication:** `Authorization: Bearer fmsgk_<key_id>_<secret>`.
247+
248+
The key must be unexpired, match the stored hash, be used from an allowed CIDR,
249+
and belong to a sub-account that exists in fmsgid.
250+
251+
**Response:**
252+
253+
```json
254+
{
255+
"access_token": "eyJ...",
256+
"token_type": "Bearer",
257+
"expires_in": 43200,
258+
"expires_at": "2026-12-31T12:00:00Z"
259+
}
260+
```
261+
262+
### GET `/fmsg/sub-accounts`
263+
264+
Lists sub-accounts owned by the RS256-authenticated user.
265+
266+
**Response:**
267+
268+
```json
269+
{
270+
"max_sub_accounts": 5,
271+
"sub_accounts": [
272+
{
273+
"agent": "bot",
274+
"addr": "@alice_bot@example.com",
275+
"key_id": "abc",
276+
"allowed_cidrs": ["203.0.113.0/24"],
277+
"key_expires_at": "2026-12-31T00:00:00Z"
278+
}
279+
]
280+
}
281+
```
282+
283+
### POST `/fmsg/sub-accounts`
284+
285+
Creates a sub-account and returns its plaintext API key once. Requires RS256
286+
owner authentication.
287+
288+
```json
289+
{
290+
"agent": "bot",
291+
"allowed_cidrs": ["203.0.113.0/24"],
292+
"key_expires_at": "2026-12-31T00:00:00Z"
293+
}
294+
```
295+
296+
The derived address is `@user_bot@domain`. `agent` may contain letters, digits,
297+
dots, and hyphens, but not underscores.
298+
299+
### POST `/fmsg/sub-accounts/:agent/rotate-key`
300+
301+
Rotates a sub-account API key and returns the new plaintext key once. Requires
302+
`key_expires_at`; `allowed_cidrs` may be supplied to replace the existing ranges.
303+
304+
### DELETE `/fmsg/sub-accounts/:agent`
305+
306+
Deletes a sub-account row and revokes future token exchange. Existing JWTs for
307+
that key are rejected on their next protected-route request.
308+
171309
### GET `/fmsg/ws`
172310

173311
Upgrades the connection to a WebSocket over which the server pushes events that

api_keys.sql

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
CREATE TABLE IF NOT EXISTS fmsg_api_sub_account (
2+
owner_addr varchar(255) NOT NULL,
3+
agent varchar(64) NOT NULL,
4+
sub_addr varchar(255),
5+
key_id varchar(64),
6+
key_hash bytea,
7+
allowed_cidrs cidr[],
8+
key_expires_at timestamptz,
9+
max_sub_accounts int NOT NULL DEFAULT 5,
10+
created_at timestamptz NOT NULL DEFAULT now(),
11+
updated_at timestamptz NOT NULL DEFAULT now(),
12+
PRIMARY KEY (owner_addr, agent),
13+
UNIQUE (sub_addr),
14+
UNIQUE (key_id),
15+
CHECK (max_sub_accounts > 0),
16+
CHECK (
17+
(agent = '' AND sub_addr IS NULL AND key_id IS NULL AND key_hash IS NULL AND allowed_cidrs IS NULL AND key_expires_at IS NULL)
18+
OR
19+
(agent <> '' AND sub_addr IS NOT NULL AND key_id IS NOT NULL AND key_hash IS NOT NULL AND allowed_cidrs IS NOT NULL AND cardinality(allowed_cidrs) > 0 AND key_expires_at IS NOT NULL)
20+
),
21+
CHECK (agent = '' OR agent NOT LIKE '%\_%' ESCAPE '\')
22+
);
23+
24+
CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_idx
25+
ON fmsg_api_sub_account ((lower(owner_addr)));
26+
27+
CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_sub_idx
28+
ON fmsg_api_sub_account ((lower(sub_addr)));

cmd/fmsg-webapi/apikey_cli.go

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"flag"
6+
"fmt"
7+
"os"
8+
"strings"
9+
"time"
10+
11+
"github.com/markmnl/fmsg-webapi/internal/apiauth"
12+
"github.com/markmnl/fmsg-webapi/internal/db"
13+
"github.com/markmnl/fmsg-webapi/internal/middleware"
14+
)
15+
16+
func runAPIKeyCLI(ctx context.Context, args []string) error {
17+
if len(args) == 0 {
18+
return fmt.Errorf("usage: api-key create|rotate -owner @user@domain -agent name -cidr 203.0.113.0/24 -expires 2026-12-31T00:00:00Z")
19+
}
20+
switch args[0] {
21+
case "create":
22+
return runAPIKeyCreate(ctx, args[1:])
23+
case "rotate":
24+
return runAPIKeyRotate(ctx, args[1:])
25+
default:
26+
return fmt.Errorf("unknown api-key command %q", args[0])
27+
}
28+
}
29+
30+
func runAPIKeyCreate(ctx context.Context, args []string) error {
31+
fs := flag.NewFlagSet("api-key create", flag.ContinueOnError)
32+
fs.SetOutput(os.Stderr)
33+
owner := fs.String("owner", "", "owner fmsg address")
34+
agent := fs.String("agent", "", "sub-account agent name")
35+
cidrs := fs.String("cidr", "", "comma-separated allowed CIDR ranges")
36+
expiresRaw := fs.String("expires", "", "API key expiry as RFC3339 timestamp")
37+
if err := fs.Parse(args); err != nil {
38+
return err
39+
}
40+
41+
subAddr, allowed, expires, key, hash, err := prepareCLIKeyInputs(*owner, *agent, *cidrs, *expiresRaw)
42+
if err != nil {
43+
return err
44+
}
45+
if len(allowed) == 0 {
46+
return fmt.Errorf("cidr is required for create")
47+
}
48+
database, err := db.New(ctx, "")
49+
if err != nil {
50+
return err
51+
}
52+
defer database.Close()
53+
54+
store := apiauth.NewStore(database)
55+
if err := store.Create(ctx, *owner, *agent, subAddr, key.ID, hash, allowed, expires); err != nil {
56+
return err
57+
}
58+
printCLIKey(*owner, *agent, subAddr, key)
59+
return nil
60+
}
61+
62+
func runAPIKeyRotate(ctx context.Context, args []string) error {
63+
fs := flag.NewFlagSet("api-key rotate", flag.ContinueOnError)
64+
fs.SetOutput(os.Stderr)
65+
owner := fs.String("owner", "", "owner fmsg address")
66+
agent := fs.String("agent", "", "sub-account agent name")
67+
cidrs := fs.String("cidr", "", "comma-separated allowed CIDR ranges; omit to keep existing")
68+
expiresRaw := fs.String("expires", "", "API key expiry as RFC3339 timestamp")
69+
if err := fs.Parse(args); err != nil {
70+
return err
71+
}
72+
73+
subAddr, allowed, expires, key, hash, err := prepareCLIKeyInputs(*owner, *agent, *cidrs, *expiresRaw)
74+
if err != nil {
75+
return err
76+
}
77+
database, err := db.New(ctx, "")
78+
if err != nil {
79+
return err
80+
}
81+
defer database.Close()
82+
83+
store := apiauth.NewStore(database)
84+
replaceCIDRs := strings.TrimSpace(*cidrs) != ""
85+
gotSubAddr, err := store.RotateKey(ctx, *owner, *agent, key.ID, hash, expires, allowed, replaceCIDRs)
86+
if err != nil {
87+
return err
88+
}
89+
if !strings.EqualFold(gotSubAddr, subAddr) {
90+
return fmt.Errorf("stored sub-account address %s does not match derived address %s", gotSubAddr, subAddr)
91+
}
92+
printCLIKey(*owner, *agent, subAddr, key)
93+
return nil
94+
}
95+
96+
func prepareCLIKeyInputs(owner, agent, cidrsRaw, expiresRaw string) (string, []string, time.Time, apiauth.APIKey, []byte, error) {
97+
if !middleware.IsValidAddr(owner) {
98+
return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("owner must be an fmsg address")
99+
}
100+
subAddr, err := apiauth.DeriveSubAccountAddr(owner, agent)
101+
if err != nil {
102+
return "", nil, time.Time{}, apiauth.APIKey{}, nil, err
103+
}
104+
expires, err := time.Parse(time.RFC3339, expiresRaw)
105+
if err != nil || !expires.After(time.Now()) {
106+
return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("expires must be a future RFC3339 timestamp")
107+
}
108+
var allowed []string
109+
if strings.TrimSpace(cidrsRaw) != "" {
110+
for _, cidr := range strings.Split(cidrsRaw, ",") {
111+
allowed = append(allowed, strings.TrimSpace(cidr))
112+
}
113+
}
114+
if len(allowed) > 0 {
115+
if err := apiauth.ValidateCIDRs(allowed); err != nil {
116+
return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("invalid CIDR: %w", err)
117+
}
118+
}
119+
key, err := apiauth.GenerateAPIKey()
120+
if err != nil {
121+
return "", nil, time.Time{}, apiauth.APIKey{}, nil, err
122+
}
123+
return subAddr, allowed, expires, key, apiauth.HashAPIKey(key.Value), nil
124+
}
125+
126+
func printCLIKey(owner, agent, subAddr string, key apiauth.APIKey) {
127+
fmt.Printf("owner=%s\n", owner)
128+
fmt.Printf("agent=%s\n", agent)
129+
fmt.Printf("sub_addr=%s\n", subAddr)
130+
fmt.Printf("key_id=%s\n", key.ID)
131+
fmt.Printf("api_key=%s\n", key.Value)
132+
}

0 commit comments

Comments
 (0)