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
83 changes: 62 additions & 21 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,33 +80,37 @@ deployment.
### API Keys And First-Party JWTs

Active when `FMSG_API_TOKEN_ED25519_PRIVATE_KEY` is set. Programmatic clients
authenticate with opaque API keys bound to sub-account addresses. The server
stores only API-key hashes and exchanges valid keys for short-lived Ed25519 JWTs.
authenticate with opaque API keys bound to API-access grants. A grant may be a
derived sub-account such as `@alice_bot@example.com`, or an explicit delegated
identity such as `@sales@example.com`. The server stores only API-key hashes and
exchanges valid keys for short-lived Ed25519 JWTs.

API keys are sent only to `POST /fmsg/token`:

```http
Authorization: Bearer fmsgk_<key_id>_<secret>
```

The returned JWT contains `sub` (the sub-account address), `owner`, `api_key_id`,
The returned JWT contains `sub` (the granted address), `owner`, `api_key_id`,
`iss`, `aud`, `iat`, and `exp`. Protected routes re-check the backing key row on
each request, so deleting a sub-account or expiring its key invalidates existing
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
sub-accounts without changing request bodies:
granted identities without changing request bodies:

```http
X-FMSG-Act-As: @user_bot@example.com
```

The requested sub-account must be owned by the authenticated user and must exist
The requested address must be granted to the authenticated user and must exist
in fmsgid.

Apply [api_keys.sql](api_keys.sql) before enabling API-key auth.
Apply [api_keys.sql](api_keys.sql) before enabling API-key auth. Existing
deployments that already applied the earlier API-key table should apply
[api_keys_delegation.sql](api_keys_delegation.sql).

To set a custom per-owner sub-account limit, insert an owner config row:
To set a custom per-owner grant limit, insert an owner config row:

```sql
INSERT INTO fmsg_api_sub_account (owner_addr, agent, max_sub_accounts)
Expand All @@ -117,7 +121,9 @@ DO UPDATE SET max_sub_accounts = EXCLUDED.max_sub_accounts;

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

Derived sub-account:

```bash
go run ./cmd/fmsg-webapi api-key create \
Expand All @@ -132,6 +138,22 @@ go run ./cmd/fmsg-webapi api-key rotate \
-expires 2027-03-31T00:00:00Z
```

Delegated identity:

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

go run ./cmd/fmsg-webapi api-key rotate-delegation \
-owner @mark@fmsg.io \
-agent sales \
-expires 2027-03-31T00:00:00Z
```

## Building

Requires **Go 1.25** or newer.
Expand Down Expand Up @@ -215,10 +237,10 @@ the application.
| `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 sub-accounts |
| `POST` | `/fmsg/sub-accounts` | Create a sub-account API key |
| `POST` | `/fmsg/sub-accounts/:agent/rotate-key` | Rotate a sub-account API key |
| `DELETE` | `/fmsg/sub-accounts/:agent` | Delete a sub-account |
| `GET` | `/fmsg/sub-accounts` | List owned API-access grants |
| `POST` | `/fmsg/sub-accounts` | Create a derived sub-account API key |
| `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 |
| `GET` | `/fmsg/:id` | Retrieve a message |
| `PUT` | `/fmsg/:id` | Update a draft message |
Expand Down Expand Up @@ -246,7 +268,7 @@ Exchanges an opaque API key for a short-lived JWT.
**Authentication:** `Authorization: Bearer fmsgk_<key_id>_<secret>`.

The key must be unexpired, match the stored hash, be used from an allowed CIDR,
and belong to a sub-account that exists in fmsgid.
and belong to a granted address that exists in fmsgid.

**Response:**

Expand All @@ -261,7 +283,10 @@ and belong to a sub-account that exists in fmsgid.

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

Lists sub-accounts owned by the RS256-authenticated user.
Lists API-access grants owned by the RS256-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.

**Response:**

Expand All @@ -272,18 +297,28 @@ Lists sub-accounts owned by the RS256-authenticated user.
{
"agent": "bot",
"addr": "@alice_bot@example.com",
"grant_type": "derived_sub_account",
"key_id": "abc",
"allowed_cidrs": ["203.0.113.0/24"],
"key_expires_at": "2026-12-31T00:00:00Z"
},
{
"agent": "sales",
"addr": "@sales@example.com",
"grant_type": "delegated_identity",
"display_name": "Sales mailbox",
"key_id": "def",
"allowed_cidrs": ["203.0.113.0/24"],
"key_expires_at": "2026-12-31T00:00:00Z"
}
]
}
```

### POST `/fmsg/sub-accounts`

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

```json
{
Expand All @@ -296,15 +331,21 @@ owner authentication.
The derived address is `@user_bot@domain`. `agent` may contain letters, digits,
dots, and hyphens, but not underscores.

Delegated identities such as `@sales@example.com` are not created by this
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.

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

Rotates a sub-account API key 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 RS256-authenticated user and returns the
new plaintext key once. Requires `key_expires_at`; `allowed_cidrs` may be
supplied to replace the existing ranges.

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

Deletes a sub-account row and revokes future token exchange. Existing JWTs for
that key are rejected on their next protected-route request.
Deletes a grant row and revokes future token exchange. Existing JWTs for that
key are rejected on their next protected-route request.

### GET `/fmsg/ws`

Expand Down
10 changes: 8 additions & 2 deletions api_keys.sql
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ CREATE TABLE IF NOT EXISTS fmsg_api_sub_account (
owner_addr varchar(255) NOT NULL,
agent varchar(64) NOT NULL,
sub_addr varchar(255),
grant_type text NOT NULL DEFAULT 'derived_sub_account',
display_name text,
key_id varchar(64),
key_hash bytea,
allowed_cidrs cidr[],
Expand All @@ -10,11 +12,11 @@ CREATE TABLE IF NOT EXISTS fmsg_api_sub_account (
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (owner_addr, agent),
UNIQUE (sub_addr),
UNIQUE (key_id),
CHECK (max_sub_accounts > 0),
CHECK (grant_type IN ('derived_sub_account', 'delegated_identity')),
CHECK (
(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)
(agent = '' AND sub_addr IS NULL AND display_name IS NULL AND key_id IS NULL AND key_hash IS NULL AND allowed_cidrs IS NULL AND key_expires_at IS NULL)
OR
(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)
),
Expand All @@ -26,3 +28,7 @@ CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_idx

CREATE INDEX IF NOT EXISTS fmsg_api_sub_account_sub_idx
ON fmsg_api_sub_account ((lower(sub_addr)));

CREATE UNIQUE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_sub_unique
ON fmsg_api_sub_account ((lower(owner_addr)), (lower(sub_addr)))
WHERE agent <> '';
25 changes: 25 additions & 0 deletions api_keys_delegation.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
ALTER TABLE fmsg_api_sub_account
ADD COLUMN IF NOT EXISTS grant_type text NOT NULL DEFAULT 'derived_sub_account';

ALTER TABLE fmsg_api_sub_account
ADD COLUMN IF NOT EXISTS display_name text;

ALTER TABLE fmsg_api_sub_account
DROP CONSTRAINT IF EXISTS fmsg_api_sub_account_sub_addr_key;

DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'fmsg_api_sub_account_grant_type_check'
) THEN
ALTER TABLE fmsg_api_sub_account
ADD CONSTRAINT fmsg_api_sub_account_grant_type_check
CHECK (grant_type IN ('derived_sub_account', 'delegated_identity'));
END IF;
END $$;

CREATE UNIQUE INDEX IF NOT EXISTS fmsg_api_sub_account_owner_sub_unique
ON fmsg_api_sub_account ((lower(owner_addr)), (lower(sub_addr)))
WHERE agent <> '';
97 changes: 90 additions & 7 deletions cmd/fmsg-webapi/apikey_cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,17 @@ import (

func runAPIKeyCLI(ctx context.Context, args []string) error {
if len(args) == 0 {
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")
return fmt.Errorf("usage: api-key create|rotate|create-delegation|rotate-delegation -owner @user@domain -agent name -cidr 203.0.113.0/24 -expires 2026-12-31T00:00:00Z")
}
switch args[0] {
case "create":
return runAPIKeyCreate(ctx, args[1:])
case "rotate":
return runAPIKeyRotate(ctx, args[1:])
case "create-delegation":
return runAPIKeyCreateDelegation(ctx, args[1:])
case "rotate-delegation":
return runAPIKeyRotateDelegation(ctx, args[1:])
default:
return fmt.Errorf("unknown api-key command %q", args[0])
}
Expand Down Expand Up @@ -93,17 +97,96 @@ func runAPIKeyRotate(ctx context.Context, args []string) error {
return nil
}

func runAPIKeyCreateDelegation(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("api-key create-delegation", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
owner := fs.String("owner", "", "owner fmsg address")
agent := fs.String("agent", "", "delegation label")
addr := fs.String("addr", "", "delegated fmsg address")
displayName := fs.String("display-name", "", "optional display name")
cidrs := fs.String("cidr", "", "comma-separated allowed CIDR ranges")
expiresRaw := fs.String("expires", "", "API key expiry as RFC3339 timestamp")
if err := fs.Parse(args); err != nil {
return err
}

allowed, expires, key, hash, err := prepareCLIGrantInputs(*owner, *agent, *cidrs, *expiresRaw)
if err != nil {
return err
}
if len(allowed) == 0 {
return fmt.Errorf("cidr is required for create-delegation")
}
if !middleware.IsValidAddr(*addr) {
return fmt.Errorf("addr must be an fmsg address")
}
database, err := db.New(ctx, "")
if err != nil {
return err
}
defer database.Close()

store := apiauth.NewStore(database)
if err := store.CreateDelegated(ctx, *owner, *agent, *addr, *displayName, key.ID, hash, allowed, expires); err != nil {
return err
}
printCLIKey(*owner, *agent, *addr, key)
return nil
}

func runAPIKeyRotateDelegation(ctx context.Context, args []string) error {
fs := flag.NewFlagSet("api-key rotate-delegation", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
owner := fs.String("owner", "", "owner fmsg address")
agent := fs.String("agent", "", "delegation label")
cidrs := fs.String("cidr", "", "comma-separated allowed CIDR ranges; omit to keep existing")
expiresRaw := fs.String("expires", "", "API key expiry as RFC3339 timestamp")
if err := fs.Parse(args); err != nil {
return err
}

allowed, expires, key, hash, err := prepareCLIGrantInputs(*owner, *agent, *cidrs, *expiresRaw)
if err != nil {
return err
}
database, err := db.New(ctx, "")
if err != nil {
return err
}
defer database.Close()

store := apiauth.NewStore(database)
replaceCIDRs := strings.TrimSpace(*cidrs) != ""
subAddr, err := store.RotateKey(ctx, *owner, *agent, key.ID, hash, expires, allowed, replaceCIDRs)
if err != nil {
return err
}
printCLIKey(*owner, *agent, subAddr, key)
return nil
}

func prepareCLIKeyInputs(owner, agent, cidrsRaw, expiresRaw string) (string, []string, time.Time, apiauth.APIKey, []byte, error) {
if !middleware.IsValidAddr(owner) {
return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("owner must be an fmsg address")
allowed, expires, key, hash, err := prepareCLIGrantInputs(owner, agent, cidrsRaw, expiresRaw)
if err != nil {
return "", nil, time.Time{}, apiauth.APIKey{}, nil, err
}
subAddr, err := apiauth.DeriveSubAccountAddr(owner, agent)
if err != nil {
return "", nil, time.Time{}, apiauth.APIKey{}, nil, err
}
return subAddr, allowed, expires, key, hash, nil
}

func prepareCLIGrantInputs(owner, agent, cidrsRaw, expiresRaw string) ([]string, time.Time, apiauth.APIKey, []byte, error) {
if !middleware.IsValidAddr(owner) {
return nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("owner must be an fmsg address")
}
if err := apiauth.ValidateAgent(agent); err != nil {
return nil, time.Time{}, apiauth.APIKey{}, nil, err
}
expires, err := time.Parse(time.RFC3339, expiresRaw)
if err != nil || !expires.After(time.Now()) {
return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("expires must be a future RFC3339 timestamp")
return nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("expires must be a future RFC3339 timestamp")
}
var allowed []string
if strings.TrimSpace(cidrsRaw) != "" {
Expand All @@ -113,14 +196,14 @@ func prepareCLIKeyInputs(owner, agent, cidrsRaw, expiresRaw string) (string, []s
}
if len(allowed) > 0 {
if err := apiauth.ValidateCIDRs(allowed); err != nil {
return "", nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("invalid CIDR: %w", err)
return nil, time.Time{}, apiauth.APIKey{}, nil, fmt.Errorf("invalid CIDR: %w", err)
}
}
key, err := apiauth.GenerateAPIKey()
if err != nil {
return "", nil, time.Time{}, apiauth.APIKey{}, nil, err
return nil, time.Time{}, apiauth.APIKey{}, nil, err
}
return subAddr, allowed, expires, key, apiauth.HashAPIKey(key.Value), nil
return allowed, expires, key, apiauth.HashAPIKey(key.Value), nil
}

func printCLIKey(owner, agent, subAddr string, key apiauth.APIKey) {
Expand Down
Loading
Loading