diff --git a/.env.example b/.env.example index a3a694f..4a261a1 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index b80f07c..62125b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index c0af3cb..b90c5c8 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -48,27 +48,27 @@ A `.env` file placed in the working directory is loaded automatically at startup Most `/fmsg/*` routes require an `Authorization: Bearer ` 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: `, `typ: JWT`. +Required token header: `alg: EdDSA`, `kid: `, `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. | @@ -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 @@ -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 @@ -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. @@ -146,14 +146,14 @@ Delegated identity: ```bash go run ./cmd/fmsg-webapi api-key create-delegation \ - -owner @mark@fmsg.io \ + -owner @alice@example.com \ -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 @mark@fmsg.io \ + -owner @alice@example.com \ -agent sales \ -expires 2027-03-31T00:00:00Z ``` @@ -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 @@ -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 | @@ -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. @@ -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 { @@ -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` @@ -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:** @@ -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 = `), 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`). @@ -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: @@ -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. diff --git a/cmd/fmsg-webapi/apikey_cli.go b/cmd/fmsg-webapi/apikey_cli.go index 096f1b7..f675592 100644 --- a/cmd/fmsg-webapi/apikey_cli.go +++ b/cmd/fmsg-webapi/apikey_cli.go @@ -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 } @@ -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 } diff --git a/cmd/fmsg-webapi/apikey_cli_test.go b/cmd/fmsg-webapi/apikey_cli_test.go index 46d0ce4..a702846 100644 --- a/cmd/fmsg-webapi/apikey_cli_test.go +++ b/cmd/fmsg-webapi/apikey_cli_test.go @@ -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) } @@ -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) } diff --git a/cmd/fmsg-webapi/main.go b/cmd/fmsg-webapi/main.go index a3a2b77..730ec16 100644 --- a/cmd/fmsg-webapi/main.go +++ b/cmd/fmsg-webapi/main.go @@ -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") @@ -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 @@ -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) } @@ -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 { diff --git a/internal/apiauth/store.go b/internal/apiauth/store.go index 1308341..bdfa9c3 100644 --- a/internal/apiauth/store.go +++ b/internal/apiauth/store.go @@ -167,24 +167,14 @@ func (s *Store) createGrant(ctx context.Context, ownerAddr, agent, subAddr, gran return tx.Commit(ctx) } -func (s *Store) RotateKey(ctx context.Context, ownerAddr, agent, keyID string, keyHash []byte, keyExpiresAt time.Time, allowedCIDRs []string, replaceCIDRs bool) (string, error) { +func (s *Store) RotateKey(ctx context.Context, ownerAddr, agent, keyID string, keyHash []byte, keyExpiresAt time.Time) (string, error) { var subAddr string - var err error - if replaceCIDRs { - err = s.DB.Pool.QueryRow(ctx, - `UPDATE fmsg_api_sub_account - SET key_id = $3, key_hash = $4, key_expires_at = $5, allowed_cidrs = $6::cidr[], updated_at = now() - WHERE lower(owner_addr) = lower($1) AND agent = $2 AND agent <> '' - RETURNING sub_addr`, - ownerAddr, agent, keyID, keyHash, keyExpiresAt, allowedCIDRs).Scan(&subAddr) - } else { - err = s.DB.Pool.QueryRow(ctx, - `UPDATE fmsg_api_sub_account - SET key_id = $3, key_hash = $4, key_expires_at = $5, updated_at = now() - WHERE lower(owner_addr) = lower($1) AND agent = $2 AND agent <> '' - RETURNING sub_addr`, - ownerAddr, agent, keyID, keyHash, keyExpiresAt).Scan(&subAddr) - } + err := s.DB.Pool.QueryRow(ctx, + `UPDATE fmsg_api_sub_account + SET key_id = $3, key_hash = $4, key_expires_at = $5, updated_at = now() + WHERE lower(owner_addr) = lower($1) AND agent = $2 AND agent <> '' + RETURNING sub_addr`, + ownerAddr, agent, keyID, keyHash, keyExpiresAt).Scan(&subAddr) if isUniqueViolation(err) { return "", ErrAlreadyExists } @@ -197,6 +187,28 @@ func (s *Store) RotateKey(ctx context.Context, ownerAddr, agent, keyID string, k return subAddr, nil } +// UpdateCIDRs replaces the allowed source CIDRs for a sub-account without +// touching its API key, returning the updated account. +func (s *Store) UpdateCIDRs(ctx context.Context, ownerAddr, agent string, allowedCIDRs []string) (SubAccount, error) { + var a SubAccount + err := s.DB.Pool.QueryRow(ctx, + `UPDATE fmsg_api_sub_account + SET allowed_cidrs = $3::cidr[], updated_at = now() + WHERE lower(owner_addr) = lower($1) AND agent = $2 AND agent <> '' + RETURNING owner_addr, agent, sub_addr, grant_type, COALESCE(display_name, ''), key_id, + ARRAY(SELECT cidr_value::text FROM unnest(allowed_cidrs) AS x(cidr_value)), + key_expires_at, max_sub_accounts`, + ownerAddr, agent, allowedCIDRs). + Scan(&a.OwnerAddr, &a.Agent, &a.Addr, &a.GrantType, &a.DisplayName, &a.KeyID, &a.AllowedCIDRs, &a.KeyExpiresAt, &a.MaxSubAccounts) + if errors.Is(err, pgx.ErrNoRows) { + return SubAccount{}, ErrNotFound + } + if err != nil { + return SubAccount{}, err + } + return a, nil +} + func (s *Store) Delete(ctx context.Context, ownerAddr, agent string) error { tag, err := s.DB.Pool.Exec(ctx, `DELETE FROM fmsg_api_sub_account diff --git a/internal/handlers/messages.go b/internal/handlers/messages.go index 3a7613a..94fff13 100644 --- a/internal/handlers/messages.go +++ b/internal/handlers/messages.go @@ -19,6 +19,7 @@ import ( "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5" + "github.com/markmnl/fmsg-webapi/internal/apiauth" "github.com/markmnl/fmsg-webapi/internal/db" "github.com/markmnl/fmsg-webapi/internal/middleware" "github.com/markmnl/fmsg-webapi/internal/models" @@ -31,11 +32,20 @@ type MessageHandler struct { MaxDataSize int64 MaxMsgSize int64 ShortTextSize int + SubAccounts *apiauth.Store } // NewMessageHandler creates a MessageHandler. -func NewMessageHandler(database *db.DB, dataDir string, maxDataSize, maxMsgSize int64, shortTextSize int) *MessageHandler { - return &MessageHandler{DB: database, DataDir: dataDir, MaxDataSize: maxDataSize, MaxMsgSize: maxMsgSize, ShortTextSize: shortTextSize} +func NewMessageHandler(database *db.DB, dataDir string, maxDataSize, maxMsgSize int64, shortTextSize int, subAccounts *apiauth.Store) *MessageHandler { + return &MessageHandler{DB: database, DataDir: dataDir, MaxDataSize: maxDataSize, MaxMsgSize: maxMsgSize, ShortTextSize: shortTextSize, SubAccounts: subAccounts} +} + +// visibleAddrs returns the set of fmsg addresses whose messages the caller +// may see — always exactly the one identity they authenticated as (owner, +// or the sub-account named via X-FMSG-Act-As / a sub-account's own API-key +// token). Callers never see another identity's messages in the same request. +func (h *MessageHandler) visibleAddrs(c *gin.Context) ([]string, error) { + return []string{middleware.GetIdentity(c)}, nil } // messageListItem is the JSON shape for each message in the list response. @@ -75,7 +85,12 @@ type messageInput struct { // List handles GET /fmsg — lists messages where the authenticated user is a recipient. func (h *MessageHandler) List(c *gin.Context) { - identity := middleware.GetIdentity(c) + addrs, err := h.visibleAddrs(c) + if err != nil { + log.Printf("list messages: resolve visible addrs: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list messages"}) + return + } limit, offset, ok := parseLimitOffset(c) if !ok { @@ -87,15 +102,15 @@ func (h *MessageHandler) List(c *gin.Context) { rows, err := h.DB.Pool.Query(ctx, `SELECT m.id, m.version, m.pid, m.no_reply, m.is_important, m.is_deflate, m.time_sent, m.from_addr, m.topic, m.type, m.size, m.filepath, COALESCE( - (SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = m.id AND mt.addr = $1), - (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = m.id AND mat.addr = $1) + (SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = m.id AND mt.addr = ANY($1)), + (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = m.id AND mat.addr = ANY($1)) ) AS time_read FROM msg m - WHERE EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = m.id AND mt.addr = $1) - OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = m.id AND mat.addr = $1) + WHERE EXISTS (SELECT 1 FROM msg_to mt WHERE mt.msg_id = m.id AND mt.addr = ANY($1)) + OR EXISTS (SELECT 1 FROM msg_add_to mat WHERE mat.msg_id = m.id AND mat.addr = ANY($1)) ORDER BY m.id DESC LIMIT $2 OFFSET $3`, - identity, limit, offset, + addrs, limit, offset, ) if err != nil { log.Printf("list messages: %v", err) @@ -184,6 +199,12 @@ func (h *MessageHandler) Sent(c *gin.Context) { c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) return } + addrs, err := h.visibleAddrs(c) + if err != nil { + log.Printf("list sent messages: resolve visible addrs: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to list sent messages"}) + return + } limit, offset, ok := parseLimitOffset(c) if !ok { @@ -195,10 +216,10 @@ func (h *MessageHandler) Sent(c *gin.Context) { rows, err := h.DB.Pool.Query(ctx, `SELECT m.id, m.version, m.pid, m.no_reply, m.is_important, m.is_deflate, m.time_sent, m.from_addr, m.topic, m.type, m.size, m.filepath FROM msg m - WHERE m.from_addr = $1 + WHERE m.from_addr = ANY($1) ORDER BY m.id DESC LIMIT $2 OFFSET $3`, - identity, limit, offset, + addrs, limit, offset, ) if err != nil { log.Printf("list sent messages: %v", err) @@ -382,7 +403,12 @@ func (h *MessageHandler) Create(c *gin.Context) { // Get handles GET /fmsg/:id — retrieves a message. func (h *MessageHandler) Get(c *gin.Context) { - identity := middleware.GetIdentity(c) + addrs, err := h.visibleAddrs(c) + if err != nil { + log.Printf("get message: resolve visible addrs: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve message"}) + return + } msgID, ok := parseID(c) if !ok { return @@ -400,8 +426,9 @@ func (h *MessageHandler) Get(c *gin.Context) { return } - // Authorization: owner or recipient (to or add_to). - if msg.From != identity && !isRecipient(msg.To, identity) && !addToContains(msg.AddTo, identity) { + // Authorization: owner or recipient (to or add_to), across all visible addrs. + isSender := isRecipient(addrs, msg.From) + if !isSender && !anyRecipient(addrs, msg.To) && !anyAddToContains(addrs, msg.AddTo) { c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) return } @@ -411,14 +438,14 @@ func (h *MessageHandler) Get(c *gin.Context) { // Populate per-recipient read state for the calling user. The sender // has no read state of their own. - if msg.From != identity { + if !isSender { var timeRead *float64 err := h.DB.Pool.QueryRow(ctx, `SELECT COALESCE( - (SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = $1 AND mt.addr = $2), - (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = $1 AND mat.addr = $2) + (SELECT mt.time_read FROM msg_to mt WHERE mt.msg_id = $1 AND mt.addr = ANY($2)), + (SELECT mat.time_read FROM msg_add_to mat WHERE mat.msg_id = $1 AND mat.addr = ANY($2)) )`, - msgID, identity, + msgID, addrs, ).Scan(&timeRead) if err == nil { msg.TimeRead = timeRead @@ -431,7 +458,12 @@ func (h *MessageHandler) Get(c *gin.Context) { // DownloadData handles GET /fmsg/:id/data — downloads the message body as a file. func (h *MessageHandler) DownloadData(c *gin.Context) { - identity := middleware.GetIdentity(c) + addrs, err := h.visibleAddrs(c) + if err != nil { + log.Printf("download data: resolve visible addrs: %v", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to retrieve message"}) + return + } msgID, ok := parseID(c) if !ok { return @@ -442,7 +474,7 @@ func (h *MessageHandler) DownloadData(c *gin.Context) { // Fetch message metadata for auth check and file path. var fromAddr string var dataPath string - err := h.DB.Pool.QueryRow(ctx, + err = h.DB.Pool.QueryRow(ctx, "SELECT from_addr, filepath FROM msg WHERE id = $1", msgID, ).Scan(&fromAddr, &dataPath) if err != nil { @@ -455,15 +487,15 @@ func (h *MessageHandler) DownloadData(c *gin.Context) { return } - // Authorize: must be owner or recipient. - if fromAddr != identity { + // Authorize: must be owner or recipient (across all visible addrs). + if !isRecipient(addrs, fromAddr) { var recipientCount int if err = h.DB.Pool.QueryRow(ctx, `SELECT COUNT(*) FROM ( - SELECT 1 FROM msg_to WHERE msg_id = $1 AND addr = $2 + SELECT 1 FROM msg_to WHERE msg_id = $1 AND addr = ANY($2) UNION ALL - SELECT 1 FROM msg_add_to WHERE msg_id = $1 AND addr = $2 - ) r`, msgID, identity, + SELECT 1 FROM msg_add_to WHERE msg_id = $1 AND addr = ANY($2) + ) r`, msgID, addrs, ).Scan(&recipientCount); err != nil || recipientCount == 0 { c.JSON(http.StatusForbidden, gin.H{"error": "access denied"}) return @@ -1141,6 +1173,26 @@ func addToContains(batches []models.AddToBatch, addr string) bool { return false } +// anyRecipient reports whether any of addrs appears in the to list. +func anyRecipient(addrs, to []string) bool { + for _, a := range addrs { + if isRecipient(to, a) { + return true + } + } + return false +} + +// anyAddToContains reports whether any of addrs is a recipient in any add-to batch. +func anyAddToContains(addrs []string, batches []models.AddToBatch) bool { + for _, a := range addrs { + if addToContains(batches, a) { + return true + } + } + return false +} + // isZip reports whether data starts with the zip local file header signature. func isZip(data []byte) bool { return len(data) >= 4 && data[0] == 0x50 && data[1] == 0x4b && data[2] == 0x03 && data[3] == 0x04 diff --git a/internal/handlers/subaccounts.go b/internal/handlers/subaccounts.go index 28c8322..5ea394f 100644 --- a/internal/handlers/subaccounts.go +++ b/internal/handlers/subaccounts.go @@ -28,8 +28,11 @@ type subAccountInput struct { } type rotateKeyInput struct { + KeyExpiresAt string `json:"key_expires_at"` +} + +type updateCIDRsInput struct { AllowedCIDRs []string `json:"allowed_cidrs"` - KeyExpiresAt string `json:"key_expires_at"` } type subAccountResponse struct { @@ -44,7 +47,7 @@ type subAccountResponse struct { } func (h *SubAccountHandler) List(c *gin.Context) { - owner, ok := requireRS256Owner(c) + owner, ok := requireIdPOwner(c) if !ok { return } @@ -71,8 +74,34 @@ func (h *SubAccountHandler) List(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"max_sub_accounts": max, "sub_accounts": out}) } +func (h *SubAccountHandler) Get(c *gin.Context) { + owner, ok := requireIdPOwner(c) + if !ok { + return + } + agent := c.Param("agent") + if err := apiauth.ValidateAgent(agent); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent"}) + return + } + account, err := h.store.Get(c.Request.Context(), owner, agent) + if err != nil { + respondSubAccountStoreError(c, err) + return + } + c.JSON(http.StatusOK, subAccountResponse{ + Agent: account.Agent, + Addr: account.Addr, + GrantType: account.GrantType, + DisplayName: account.DisplayName, + KeyID: account.KeyID, + AllowedCIDRs: account.AllowedCIDRs, + KeyExpiresAt: account.KeyExpiresAt.UTC().Format(time.RFC3339), + }) +} + func (h *SubAccountHandler) Create(c *gin.Context) { - owner, ok := requireRS256Owner(c) + owner, ok := requireIdPOwner(c) if !ok { return } @@ -122,7 +151,7 @@ func (h *SubAccountHandler) Create(c *gin.Context) { } func (h *SubAccountHandler) RotateKey(c *gin.Context) { - owner, ok := requireRS256Owner(c) + owner, ok := requireIdPOwner(c) if !ok { return } @@ -150,13 +179,6 @@ func (h *SubAccountHandler) RotateKey(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "key_expires_at must be a future RFC3339 timestamp"}) return } - replaceCIDRs := in.AllowedCIDRs != nil - if replaceCIDRs { - if err := apiauth.ValidateCIDRs(in.AllowedCIDRs); err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "allowed_cidrs must contain valid CIDR ranges"}) - return - } - } key, hash, err := newPlaintextKey() if err != nil { @@ -164,7 +186,7 @@ func (h *SubAccountHandler) RotateKey(c *gin.Context) { c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to generate api key"}) return } - subAddr, err := h.store.RotateKey(c.Request.Context(), owner, agent, key.ID, hash, expires, in.AllowedCIDRs, replaceCIDRs) + subAddr, err := h.store.RotateKey(c.Request.Context(), owner, agent, key.ID, hash, expires) if err != nil { respondSubAccountStoreError(c, err) return @@ -179,8 +201,45 @@ func (h *SubAccountHandler) RotateKey(c *gin.Context) { }) } +func (h *SubAccountHandler) UpdateCIDRs(c *gin.Context) { + owner, ok := requireIdPOwner(c) + if !ok { + return + } + agent := c.Param("agent") + if err := apiauth.ValidateAgent(agent); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid agent"}) + return + } + + var in updateCIDRsInput + if err := c.ShouldBindJSON(&in); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := apiauth.ValidateCIDRs(in.AllowedCIDRs); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "allowed_cidrs must contain valid CIDR ranges"}) + return + } + + account, err := h.store.UpdateCIDRs(c.Request.Context(), owner, agent, in.AllowedCIDRs) + if err != nil { + respondSubAccountStoreError(c, err) + return + } + c.JSON(http.StatusOK, subAccountResponse{ + Agent: account.Agent, + Addr: account.Addr, + GrantType: account.GrantType, + DisplayName: account.DisplayName, + KeyID: account.KeyID, + AllowedCIDRs: account.AllowedCIDRs, + KeyExpiresAt: account.KeyExpiresAt.UTC().Format(time.RFC3339), + }) +} + func (h *SubAccountHandler) Delete(c *gin.Context) { - owner, ok := requireRS256Owner(c) + owner, ok := requireIdPOwner(c) if !ok { return } @@ -196,9 +255,9 @@ func (h *SubAccountHandler) Delete(c *gin.Context) { c.Status(http.StatusNoContent) } -func requireRS256Owner(c *gin.Context) (string, bool) { - if middleware.GetAuthType(c) != middleware.AuthTypeRS256 || middleware.GetIdentity(c) != middleware.GetOwnerIdentity(c) { - c.JSON(http.StatusForbidden, gin.H{"error": "RS256 owner authentication is required"}) +func requireIdPOwner(c *gin.Context) (string, bool) { + if middleware.GetAuthType(c) != middleware.AuthTypeIdP || middleware.GetIdentity(c) != middleware.GetOwnerIdentity(c) { + c.JSON(http.StatusForbidden, gin.H{"error": "identity-provider owner authentication is required"}) return "", false } return middleware.GetOwnerIdentity(c), true diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go index 7e6e976..b65abb8 100644 --- a/internal/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -24,8 +24,8 @@ const ( OwnerIdentityKey = "owner" AuthTypeKey = "auth_type" - AuthTypeRS256 = "rs256" - AuthTypeAPI = "api_token" + AuthTypeIdP = "idp" + AuthTypeAPI = "api_token" ) // DefaultClockSkew is the leeway applied to iat/nbf/exp validation to tolerate @@ -39,7 +39,9 @@ type APIKeyChecker interface { // Config configures authentication. type Config struct { - // RS256/JWKS provider token verification. Enabled when JWKS is non-nil. + // EdDSA/JWKS provider token verification. Enabled when JWKS is non-nil. + // Audience is optional; when empty, tokens are not required to carry an + // aud claim. JWKS jwt.Keyfunc Issuer string Audience string @@ -68,8 +70,8 @@ type authResult struct { // Verifier verifies fmsg bearer tokens. It is safe for concurrent use and is // shared by Gin middleware and the WebSocket handler. type Verifier struct { - rsParser *jwt.Parser - rsKeyFunc jwt.Keyfunc + idpParser *jwt.Parser + idpKeyFunc jwt.Keyfunc issuer string audience string addressClaim string @@ -88,28 +90,28 @@ func NewVerifier(cfg Config) (*Verifier, error) { if cfg.JWKS != nil { if cfg.Issuer == "" { - return nil, errors.New("middleware: RS256 mode requires an Issuer") - } - if cfg.Audience == "" { - return nil, errors.New("middleware: RS256 mode requires an Audience") + return nil, errors.New("middleware: EdDSA mode requires an Issuer") } if cfg.AddressClaim == "" { - return nil, errors.New("middleware: RS256 mode requires an AddressClaim") + return nil, errors.New("middleware: EdDSA mode requires an AddressClaim") } - v.rsKeyFunc = func(t *jwt.Token) (interface{}, error) { - if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok { + v.idpKeyFunc = func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodEd25519); !ok { return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg()) } return cfg.JWKS(t) } - v.rsParser = jwt.NewParser( - jwt.WithValidMethods([]string{jwt.SigningMethodRS256.Alg()}), + parserOpts := []jwt.ParserOption{ + jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}), jwt.WithLeeway(cfg.ClockSkew), jwt.WithExpirationRequired(), jwt.WithIssuedAt(), jwt.WithIssuer(cfg.Issuer), - jwt.WithAudience(cfg.Audience), - ) + } + if cfg.Audience != "" { + parserOpts = append(parserOpts, jwt.WithAudience(cfg.Audience)) + } + v.idpParser = jwt.NewParser(parserOpts...) v.issuer = cfg.Issuer v.audience = cfg.Audience v.addressClaim = cfg.AddressClaim @@ -137,7 +139,7 @@ func NewVerifier(cfg Config) (*Verifier, error) { ) } - if v.rsParser == nil && v.apiParser == nil { + if v.idpParser == nil && v.apiParser == nil { return nil, errors.New("middleware: at least one auth mode must be configured") } return v, nil @@ -152,8 +154,8 @@ func (v *Verifier) Authenticate(tokenStr string) (addr string, status int, msg s } func (v *Verifier) AuthenticateRequest(ctx context.Context, tokenStr, remoteAddr, actAs string) (authResult, int, string) { - if v.rsParser != nil { - res, err := v.authenticateRS256(ctx, tokenStr, actAs) + if v.idpParser != nil { + res, err := v.authenticateIdP(ctx, tokenStr, actAs) if err == nil { return res, http.StatusOK, "" } @@ -174,9 +176,9 @@ func (v *Verifier) AuthenticateRequest(ctx context.Context, tokenStr, remoteAddr return authResult{}, http.StatusUnauthorized, "invalid token" } -func (v *Verifier) authenticateRS256(ctx context.Context, tokenStr, actAs string) (authResult, error) { +func (v *Verifier) authenticateIdP(ctx context.Context, tokenStr, actAs string) (authResult, error) { claims := jwt.MapClaims{} - if _, err := v.rsParser.ParseWithClaims(tokenStr, claims, v.rsKeyFunc); err != nil { + if _, err := v.idpParser.ParseWithClaims(tokenStr, claims, v.idpKeyFunc); err != nil { return authResult{}, err } @@ -189,7 +191,7 @@ func (v *Verifier) authenticateRS256(ctx context.Context, tokenStr, actAs string if status, msg := validateIdentity(owner, v.idURL); status != http.StatusOK { return authResult{}, authError{status: status, msg: msg} } - res := authResult{Addr: owner, OwnerAddr: owner, AuthType: AuthTypeRS256} + res := authResult{Addr: owner, OwnerAddr: owner, AuthType: AuthTypeIdP} if strings.TrimSpace(actAs) == "" { return res, nil @@ -213,7 +215,7 @@ func (v *Verifier) authenticateRS256(ctx context.Context, tokenStr, actAs string func (v *Verifier) authenticateAPIToken(ctx context.Context, tokenStr, remoteAddr, actAs string) (authResult, error) { if strings.TrimSpace(actAs) != "" { - return authResult{}, authError{status: http.StatusForbidden, msg: "act-as is only available with RS256 authentication"} + return authResult{}, authError{status: http.StatusForbidden, msg: "act-as is only available with identity-provider authentication"} } claims := &apiauth.TokenClaims{} _, err := v.apiParser.ParseWithClaims(tokenStr, claims, func(t *jwt.Token) (interface{}, error) { diff --git a/internal/middleware/jwt_test.go b/internal/middleware/jwt_test.go index bbc5b80..5f89e9f 100644 --- a/internal/middleware/jwt_test.go +++ b/internal/middleware/jwt_test.go @@ -4,7 +4,6 @@ import ( "context" "crypto/ed25519" "crypto/rand" - "crypto/rsa" "encoding/json" "errors" "net/http" @@ -70,7 +69,7 @@ func TestIsValidAddr(t *testing.T) { } } -func fakeJWKS(kid string, pub *rsa.PublicKey) jwt.Keyfunc { +func fakeJWKS(kid string, pub ed25519.PublicKey) jwt.Keyfunc { return func(t *jwt.Token) (interface{}, error) { k, _ := t.Header["kid"].(string) if k != kid { @@ -108,9 +107,9 @@ func runMiddleware(t *testing.T, mw gin.HandlerFunc, token string, actAs string) return w } -func signRS256(t *testing.T, priv *rsa.PrivateKey, kid string, claims jwt.MapClaims) string { +func signEdDSA(t *testing.T, priv ed25519.PrivateKey, kid string, claims jwt.MapClaims) string { t.Helper() - tok := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims) tok.Header["kid"] = kid s, err := tok.SignedString(priv) if err != nil { @@ -129,7 +128,7 @@ func signHS256(t *testing.T, secret []byte, claims jwt.MapClaims) string { return s } -func rs256Claims(addr string) jwt.MapClaims { +func providerClaims(addr string) jwt.MapClaims { claims := jwt.MapClaims{ "iss": testIssuer, "aud": testAudience, @@ -143,16 +142,16 @@ func rs256Claims(addr string) jwt.MapClaims { return claims } -func newRS256Fixture(t *testing.T) (priv *rsa.PrivateKey, jwks jwt.Keyfunc) { +func newEdDSAFixture(t *testing.T) (priv ed25519.PrivateKey, jwks jwt.Keyfunc) { t.Helper() - priv, err := rsa.GenerateKey(rand.Reader, 2048) + pub, priv, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatalf("genkey: %v", err) } - return priv, fakeJWKS("prod-1", &priv.PublicKey) + return priv, fakeJWKS("prod-1", pub) } -func rs256Config(idURL string, jwks jwt.Keyfunc) Config { +func providerConfig(idURL string, jwks jwt.Keyfunc) Config { return Config{ JWKS: jwks, Issuer: testIssuer, @@ -162,22 +161,22 @@ func rs256Config(idURL string, jwks jwt.Keyfunc) Config { } } -func TestRS256Mode_Happy(t *testing.T) { +func TestEdDSAMode_Happy(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - priv, jwks := newRS256Fixture(t) - mw, err := New(rs256Config(srv.URL, jwks)) + priv, jwks := newEdDSAFixture(t) + mw, err := New(providerConfig(srv.URL, jwks)) if err != nil { t.Fatalf("New: %v", err) } - tok := signRS256(t, priv, "prod-1", rs256Claims("@alice@example.com")) + tok := signEdDSA(t, priv, "prod-1", providerClaims("@alice@example.com")) if w := runMiddleware(t, mw, tok, ""); w.Code != http.StatusOK { t.Fatalf("expected 200, got %d body=%s", w.Code, w.Body.String()) } } -func TestRS256Mode_ActAsSubAccount(t *testing.T) { +func TestEdDSAMode_ActAsSubAccount(t *testing.T) { fmsgIDCache.Delete("@alice@example.com") fmsgIDCache.Delete("@alice_bot@example.com") defer fmsgIDCache.Delete("@alice@example.com") @@ -185,7 +184,7 @@ func TestRS256Mode_ActAsSubAccount(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - priv, jwks := newRS256Fixture(t) + priv, jwks := newEdDSAFixture(t) apiPub, _, err := ed25519.GenerateKey(rand.Reader) if err != nil { t.Fatal(err) @@ -203,7 +202,7 @@ func TestRS256Mode_ActAsSubAccount(t *testing.T) { t.Fatalf("New: %v", err) } - tok := signRS256(t, priv, "prod-1", rs256Claims("@alice@example.com")) + tok := signEdDSA(t, priv, "prod-1", providerClaims("@alice@example.com")) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = httptest.NewRequest(http.MethodGet, "/fmsg", nil) @@ -222,64 +221,86 @@ func TestRS256Mode_ActAsSubAccount(t *testing.T) { } } -func TestRS256Mode_MissingAddressClaim(t *testing.T) { +func TestEdDSAMode_MissingAddressClaim(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - priv, jwks := newRS256Fixture(t) - mw, err := New(rs256Config(srv.URL, jwks)) + priv, jwks := newEdDSAFixture(t) + mw, err := New(providerConfig(srv.URL, jwks)) if err != nil { t.Fatal(err) } - tok := signRS256(t, priv, "prod-1", rs256Claims("")) + tok := signEdDSA(t, priv, "prod-1", providerClaims("")) if w := runMiddleware(t, mw, tok, ""); w.Code != http.StatusForbidden { t.Fatalf("expected 403, got %d body=%s", w.Code, w.Body.String()) } } -func TestRS256Mode_WrongIssuerAudienceAndExpired(t *testing.T) { +func TestEdDSAMode_WrongIssuerAudienceAndExpired(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - priv, jwks := newRS256Fixture(t) - mw, err := New(rs256Config(srv.URL, jwks)) + priv, jwks := newEdDSAFixture(t) + mw, err := New(providerConfig(srv.URL, jwks)) if err != nil { t.Fatal(err) } - claims := rs256Claims("@alice@example.com") + claims := providerClaims("@alice@example.com") claims["iss"] = "https://evil.example.com/" - if w := runMiddleware(t, mw, signRS256(t, priv, "prod-1", claims), ""); w.Code != http.StatusUnauthorized { + if w := runMiddleware(t, mw, signEdDSA(t, priv, "prod-1", claims), ""); w.Code != http.StatusUnauthorized { t.Fatalf("wrong issuer expected 401, got %d", w.Code) } - claims = rs256Claims("@alice@example.com") + claims = providerClaims("@alice@example.com") claims["aud"] = "other" - if w := runMiddleware(t, mw, signRS256(t, priv, "prod-1", claims), ""); w.Code != http.StatusUnauthorized { + if w := runMiddleware(t, mw, signEdDSA(t, priv, "prod-1", claims), ""); w.Code != http.StatusUnauthorized { t.Fatalf("wrong audience expected 401, got %d", w.Code) } - claims = rs256Claims("@alice@example.com") + claims = providerClaims("@alice@example.com") claims["exp"] = time.Now().Add(-time.Hour).Unix() - if w := runMiddleware(t, mw, signRS256(t, priv, "prod-1", claims), ""); w.Code != http.StatusUnauthorized { + if w := runMiddleware(t, mw, signEdDSA(t, priv, "prod-1", claims), ""); w.Code != http.StatusUnauthorized { t.Fatalf("expired expected 401, got %d", w.Code) } } -func TestRS256Mode_RejectsHMACAlg(t *testing.T) { +func TestEdDSAMode_AudienceOptional(t *testing.T) { srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() - _, jwks := newRS256Fixture(t) - mw, err := New(rs256Config(srv.URL, jwks)) + priv, jwks := newEdDSAFixture(t) + mw, err := New(Config{ + JWKS: jwks, + Issuer: testIssuer, + AddressClaim: testAddressClaim, + IDURL: srv.URL, + }) + if err != nil { + t.Fatalf("New: %v", err) + } + + claims := providerClaims("@alice@example.com") + delete(claims, "aud") + tok := signEdDSA(t, priv, "prod-1", claims) + if w := runMiddleware(t, mw, tok, ""); w.Code != http.StatusOK { + t.Fatalf("expected 200 with no configured audience, got %d body=%s", w.Code, w.Body.String()) + } +} + +func TestEdDSAMode_RejectsHMACAlg(t *testing.T) { + srv := fmsgIDServer(t, http.StatusOK, true) + defer srv.Close() + _, jwks := newEdDSAFixture(t) + mw, err := New(providerConfig(srv.URL, jwks)) if err != nil { t.Fatal(err) } - tok := signHS256(t, []byte("anything"), rs256Claims("@alice@example.com")) + tok := signHS256(t, []byte("anything"), providerClaims("@alice@example.com")) if w := runMiddleware(t, mw, tok, ""); w.Code != http.StatusUnauthorized { t.Fatalf("expected 401, got %d", w.Code) } } -func TestRS256Mode_ConfigValidation(t *testing.T) { - _, jwks := newRS256Fixture(t) +func TestEdDSAMode_ConfigValidation(t *testing.T) { + _, jwks := newEdDSAFixture(t) if _, err := NewVerifier(Config{Issuer: testIssuer, Audience: testAudience, AddressClaim: testAddressClaim}); err == nil { t.Error("missing auth modes: expected error") @@ -287,24 +308,24 @@ func TestRS256Mode_ConfigValidation(t *testing.T) { if _, err := NewVerifier(Config{JWKS: jwks, Audience: testAudience, AddressClaim: testAddressClaim}); err == nil { t.Error("missing Issuer: expected error") } - if _, err := NewVerifier(Config{JWKS: jwks, Issuer: testIssuer, AddressClaim: testAddressClaim}); err == nil { - t.Error("missing Audience: expected error") - } if _, err := NewVerifier(Config{JWKS: jwks, Issuer: testIssuer, Audience: testAudience}); err == nil { t.Error("missing AddressClaim: expected error") } + if _, err := NewVerifier(Config{JWKS: jwks, Issuer: testIssuer, AddressClaim: testAddressClaim}); err != nil { + t.Errorf("Audience should be optional: %v", err) + } } -func TestRS256Mode_FmsgIDFailures(t *testing.T) { - priv, jwks := newRS256Fixture(t) +func TestEdDSAMode_FmsgIDFailures(t *testing.T) { + priv, jwks := newEdDSAFixture(t) fmsgIDCache.Delete("@alice@example.com") srv := fmsgIDServer(t, http.StatusNotFound, false) - mw, err := New(rs256Config(srv.URL, jwks)) + mw, err := New(providerConfig(srv.URL, jwks)) if err != nil { t.Fatal(err) } - tok := signRS256(t, priv, "prod-1", rs256Claims("@alice@example.com")) + tok := signEdDSA(t, priv, "prod-1", providerClaims("@alice@example.com")) if w := runMiddleware(t, mw, tok, ""); w.Code != http.StatusBadRequest { t.Fatalf("not found expected 400, got %d", w.Code) } @@ -313,7 +334,7 @@ func TestRS256Mode_FmsgIDFailures(t *testing.T) { fmsgIDCache.Delete("@alice@example.com") srv = fmsgIDServer(t, http.StatusOK, false) defer srv.Close() - mw, err = New(rs256Config(srv.URL, jwks)) + mw, err = New(providerConfig(srv.URL, jwks)) if err != nil { t.Fatal(err) }