From 06634b4ae745e16b45f28691032d129373a0c3ae Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Mon, 10 Aug 2026 17:09:00 +1000 Subject: [PATCH 1/2] check key issuance --- README.md | 4 +++- cmd/fmsg-webapi/apikey_cli.go | 37 ++++++++++++++++++++++++++++++ cmd/fmsg-webapi/apikey_cli_test.go | 28 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index d361d67..ee2bca6 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,9 @@ DO UPDATE SET max_sub_accounts = EXCLUDED.max_sub_accounts; 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. +the plaintext API key once. Creation verifies that the owner and delegated +address are present and accepting new messages in fmsgid; derived sub-accounts +are registered in fmsgid automatically. Derived sub-account: diff --git a/cmd/fmsg-webapi/apikey_cli.go b/cmd/fmsg-webapi/apikey_cli.go index f675592..ed6812a 100644 --- a/cmd/fmsg-webapi/apikey_cli.go +++ b/cmd/fmsg-webapi/apikey_cli.go @@ -4,6 +4,7 @@ import ( "context" "flag" "fmt" + "net/http" "os" "strings" "time" @@ -49,6 +50,18 @@ func runAPIKeyCreate(ctx context.Context, args []string) error { if len(allowed) == 0 { return fmt.Errorf("cidr is required for create") } + idURL := envOrDefault("FMSG_ID_URL", "http://127.0.0.1:8080") + if err := requireAcceptingCLIAddress(idURL, *owner, "owner"); err != nil { + return err + } + // Match the self-service sub-account flow: derived addresses are created in + // fmsgid before their API key is persisted. + if err := middleware.RegisterFmsgID(idURL, subAddr); err != nil { + return fmt.Errorf("registering derived address with fmsgid: %w", err) + } + if err := requireAcceptingCLIAddress(idURL, subAddr, "derived address"); err != nil { + return err + } database, err := db.New(ctx, "") if err != nil { return err @@ -124,6 +137,13 @@ func runAPIKeyCreateDelegation(ctx context.Context, args []string) error { if !middleware.IsValidAddr(*addr) { return fmt.Errorf("addr must be an fmsg address") } + idURL := envOrDefault("FMSG_ID_URL", "http://127.0.0.1:8080") + if err := requireAcceptingCLIAddress(idURL, *owner, "owner"); err != nil { + return err + } + if err := requireAcceptingCLIAddress(idURL, *addr, "delegated address"); err != nil { + return err + } database, err := db.New(ctx, "") if err != nil { return err @@ -214,6 +234,23 @@ func prepareCLIGrantInputs(owner, agent, cidrsRaw, expiresRaw string) ([]string, return allowed, expires, key, apiauth.HashAPIKey(key.Value), nil } +func requireAcceptingCLIAddress(idURL, addr, role string) error { + code, accepting, err := middleware.CheckFmsgID(idURL, addr) + if err != nil { + return fmt.Errorf("checking %s in fmsgid: %w", role, err) + } + if code == http.StatusNotFound { + return fmt.Errorf("%s %s not found in fmsgid", role, addr) + } + if code != http.StatusOK { + return fmt.Errorf("checking %s in fmsgid: unexpected status %d", role, code) + } + if !accepting { + return fmt.Errorf("%s %s is not accepting new messages", role, addr) + } + return nil +} + func printCLIKey(owner, agent, subAddr string, key apiauth.APIKey) { fmt.Printf("owner=%s\n", owner) fmt.Printf("agent=%s\n", agent) diff --git a/cmd/fmsg-webapi/apikey_cli_test.go b/cmd/fmsg-webapi/apikey_cli_test.go index a702846..2b9468a 100644 --- a/cmd/fmsg-webapi/apikey_cli_test.go +++ b/cmd/fmsg-webapi/apikey_cli_test.go @@ -1,6 +1,8 @@ package main import ( + "net/http" + "net/http/httptest" "strings" "testing" "time" @@ -23,6 +25,32 @@ func TestPrepareCLIGrantInputsAllowsArbitraryDelegatedAddressFlow(t *testing.T) } } +func TestRequireAcceptingCLIAddress(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/fmsgid/@alice@exists.test": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"acceptingNew":true}`)) + case "/fmsgid/@alice@disabled.test": + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"acceptingNew":false}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + + if err := requireAcceptingCLIAddress(server.URL, "@alice@exists.test", "owner"); err != nil { + t.Fatalf("existing accepting address: %v", err) + } + if err := requireAcceptingCLIAddress(server.URL, "@alice@missing.test", "owner"); err == nil || !strings.Contains(err.Error(), "not found") { + t.Fatalf("missing address error = %v", err) + } + if err := requireAcceptingCLIAddress(server.URL, "@alice@disabled.test", "owner"); err == nil || !strings.Contains(err.Error(), "not accepting") { + t.Fatalf("disabled address error = %v", err) + } +} + func TestPrepareCLIKeyInputsStillDerivesSubAccountAddress(t *testing.T) { expires := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) subAddr, _, _, _, _, err := prepareCLIKeyInputs("@mark@example.com", "bot", "203.0.113.0/24", expires) From db6a6b584ae493c54cd6c64be4d37c84acdec35c Mon Sep 17 00:00:00 2001 From: Mark Mennell Date: Thu, 17 Sep 2026 11:10:09 +0800 Subject: [PATCH 2/2] Harden fmsgid validation and test CLI key creation end to end --- README.md | 16 +- .../apikey_cli_integration_test.go | 289 ++++++++++++++++++ internal/middleware/fmsgid_test.go | 91 ++++++ internal/middleware/jwt.go | 38 ++- internal/middleware/jwt_test.go | 7 - 5 files changed, 416 insertions(+), 25 deletions(-) create mode 100644 cmd/fmsg-webapi/apikey_cli_integration_test.go create mode 100644 internal/middleware/fmsgid_test.go diff --git a/README.md b/README.md index e7db09f..0a9c4e8 100644 --- a/README.md +++ b/README.md @@ -145,9 +145,19 @@ DO UPDATE SET max_sub_accounts = EXCLUDED.max_sub_accounts; 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. Creation verifies that the owner and delegated -address are present and accepting new messages in fmsgid; derived sub-accounts -are registered in fmsgid automatically. +the plaintext API key once. Creation also requires the fmsgid service configured +by `FMSG_ID_URL`: + +- `create` checks that the owner exists and accepts new messages, registers the + derived address with fmsgid's default quotas, and verifies it accepts new + messages before storing the key. Existing address settings are preserved; + quotas are independent of the owner's. +- `create-delegation` requires both the owner and delegated address to already + exist and accept new messages. It does not register either address. + +Failed fmsgid checks prevent key creation. Token exchange still requires the +granted address to exist and accept new messages. Rotation replaces an existing +key without registering addresses or changing their quotas. Derived sub-account: diff --git a/cmd/fmsg-webapi/apikey_cli_integration_test.go b/cmd/fmsg-webapi/apikey_cli_integration_test.go new file mode 100644 index 0000000..6bf0b68 --- /dev/null +++ b/cmd/fmsg-webapi/apikey_cli_integration_test.go @@ -0,0 +1,289 @@ +package main + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/golang-jwt/jwt/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/markmnl/fmsg-webapi/internal/apiauth" + "github.com/markmnl/fmsg-webapi/internal/db" + "github.com/markmnl/fmsg-webapi/internal/handlers" +) + +func newAPIKeyCLIDB(t *testing.T) *db.DB { + t.Helper() + dsn := os.Getenv("FMSG_TEST_DATABASE_URL") + if dsn == "" { + t.Skip("set FMSG_TEST_DATABASE_URL to run PostgreSQL integration tests") + } + ctx := context.Background() + admin, err := pgxpool.New(ctx, dsn) + if err != nil { + t.Fatal(err) + } + t.Cleanup(admin.Close) + schema := fmt.Sprintf("apikey_cli_%d", time.Now().UnixNano()) + if _, err := admin.Exec(ctx, "CREATE SCHEMA "+schema); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _, _ = admin.Exec(ctx, "DROP SCHEMA "+schema+" CASCADE") }) + config, err := pgxpool.ParseConfig(dsn) + if err != nil { + t.Fatal(err) + } + config.ConnConfig.RuntimeParams["search_path"] = schema + pool, err := pgxpool.NewWithConfig(ctx, config) + if err != nil { + t.Fatal(err) + } + t.Cleanup(pool.Close) + dd, err := os.ReadFile("../../dd.sql") + if err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(ctx, string(dd)); err != nil { + t.Fatal(err) + } + + // The CLI uses PG* configuration; keep all its writes in this test schema. + cfg := config.ConnConfig + t.Setenv("PGSERVICE", "") + t.Setenv("PGHOST", cfg.Host) + t.Setenv("PGPORT", strconv.Itoa(int(cfg.Port))) + t.Setenv("PGUSER", cfg.User) + t.Setenv("PGPASSWORD", cfg.Password) + t.Setenv("PGDATABASE", cfg.Database) + t.Setenv("PGOPTIONS", "-c search_path="+schema) + if cfg.TLSConfig == nil { + t.Setenv("PGSSLMODE", "disable") + } else { + t.Setenv("PGSSLMODE", "require") + } + return &db.DB{Pool: pool} +} + +func runCapturedAPIKeyCLI(t *testing.T, args []string) (string, error) { + t.Helper() + out, err := os.CreateTemp(t.TempDir(), "cli-output") + if err != nil { + t.Fatal(err) + } + defer out.Close() + previous := os.Stdout + os.Stdout = out + defer func() { os.Stdout = previous }() + cliErr := runAPIKeyCLI(context.Background(), args) + data, err := os.ReadFile(out.Name()) + if err != nil { + t.Fatal(err) + } + return string(data), cliErr +} + +func TestAPIKeyCLICreation(t *testing.T) { + for _, tc := range []struct { + name string + delegated bool + existing bool + ownerStatus int + ownerBody string + targetBody string + registrationStatus int + missingAfterCreate bool + wantErr string + }{ + {name: "register derived address"}, + {name: "existing derived address", existing: true}, + {name: "missing owner", ownerStatus: 404, wantErr: "owner @alice@example.com not found"}, + {name: "disabled owner", ownerBody: `{"acceptingNew":false}`, wantErr: "owner @alice@example.com is not accepting"}, + {name: "unavailable owner service", ownerStatus: 503, wantErr: "unexpected status 503"}, + {name: "malformed owner response", ownerBody: "upstream error", wantErr: "checking owner in fmsgid"}, + {name: "registration failure", registrationStatus: 503, wantErr: "registering derived address"}, + {name: "missing after registration", missingAfterCreate: true, wantErr: "derived address @alice_bot@example.com not found"}, + {name: "disabled derived address", existing: true, targetBody: `{"acceptingNew":false}`, wantErr: "derived address @alice_bot@example.com is not accepting"}, + {name: "malformed derived response", targetBody: "upstream error", wantErr: "checking derived address in fmsgid"}, + {name: "existing delegated address", delegated: true, existing: true}, + {name: "missing delegated address", delegated: true, wantErr: "delegated address @sales@example.com not found"}, + {name: "disabled delegated address", delegated: true, existing: true, targetBody: `{"acceptingNew":false}`, wantErr: "delegated address @sales@example.com is not accepting"}, + } { + t.Run(tc.name, func(t *testing.T) { + database := newAPIKeyCLIDB(t) + const owner = "@alice@example.com" + target := "@alice_bot@example.com" + command := "create" + if tc.delegated { + target = "@sales@example.com" + command = "create-delegation" + } + var mu sync.Mutex + var requests []string + registered := tc.existing + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + defer mu.Unlock() + requests = append(requests, r.Method+" "+r.URL.Path) + switch { + case r.Method == http.MethodGet && r.URL.Path == "/fmsgid/"+owner: + if tc.ownerStatus != 0 { + w.WriteHeader(tc.ownerStatus) + } else if tc.ownerBody != "" { + _, _ = w.Write([]byte(tc.ownerBody)) + } else { + _, _ = w.Write([]byte(`{"acceptingNew":true}`)) + } + case r.Method == http.MethodPost && r.URL.Path == "/fmsgid": + var body struct{ Address string } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Address != target || tc.delegated { + t.Errorf("unexpected registration: address=%q, err=%v", body.Address, err) + w.WriteHeader(http.StatusBadRequest) + return + } + if tc.registrationStatus != 0 { + w.WriteHeader(tc.registrationStatus) + return + } + if registered { + w.WriteHeader(http.StatusOK) + } else { + registered = !tc.missingAfterCreate + w.WriteHeader(http.StatusCreated) + } + case r.Method == http.MethodGet && r.URL.Path == "/fmsgid/"+target: + if !registered { + w.WriteHeader(http.StatusNotFound) + } else if tc.targetBody != "" { + _, _ = w.Write([]byte(tc.targetBody)) + } else { + _, _ = w.Write([]byte(`{"acceptingNew":true}`)) + } + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + t.Setenv("FMSG_ID_URL", server.URL) + args := []string{command, "-owner", owner, "-agent", "bot", "-cidr", "127.0.0.0/8", "-expires", time.Now().Add(time.Hour).UTC().Format(time.RFC3339)} + if tc.delegated { + args = append(args, "-addr", target) + } + output, err := runCapturedAPIKeyCLI(t, args) + mu.Lock() + creationRequests := append([]string(nil), requests...) + mu.Unlock() + var count int + if queryErr := database.Pool.QueryRow(context.Background(), "SELECT count(*) FROM fmsg_api_sub_account").Scan(&count); queryErr != nil { + t.Fatal(queryErr) + } + if tc.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("creation error = %v, want %q", err, tc.wantErr) + } + if count != 0 || output != "" { + t.Fatalf("failed creation persisted %d grants or printed a key", count) + } + } else { + if err != nil || count != 1 { + t.Fatalf("creation: grants=%d, err=%v", count, err) + } + var key string + for line := range strings.SplitSeq(output, "\n") { + if value, ok := strings.CutPrefix(line, "api_key="); ok { + key = value + } + } + verifyCLIKeyExchange(t, database, server.URL, key, owner, target) + } + + want := []string{"GET /fmsgid/" + owner} + if tc.ownerStatus == 0 && tc.ownerBody == "" { + if !tc.delegated { + want = append(want, "POST /fmsgid") + } + if tc.registrationStatus == 0 { + want = append(want, "GET /fmsgid/"+target) + } + } + if strings.Join(creationRequests, "\n") != strings.Join(want, "\n") { + t.Fatalf("creation requests = %v, want %v", creationRequests, want) + } + }) + } +} + +func exchangeCLIKey(t *testing.T, database *db.DB, idURL, key string) (*httptest.ResponseRecorder, *apiauth.TokenIssuer) { + t.Helper() + _, private, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + issuer := apiauth.NewTokenIssuer(private, "", "", time.Minute) + handler := handlers.NewTokenHandler(apiauth.NewStore(database), issuer, idURL) + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Request = httptest.NewRequest(http.MethodPost, "/fmsg/token", nil) + c.Request.RemoteAddr = "127.0.0.1:12345" + c.Request.Header.Set("Authorization", "Bearer "+key) + handler.Exchange(c) + return w, issuer +} + +func verifyCLIKeyExchange(t *testing.T, database *db.DB, idURL, key, owner, target string) { + t.Helper() + w, issuer := exchangeCLIKey(t, database, idURL, key) + if w.Code != http.StatusOK { + t.Fatalf("token exchange status = %d", w.Code) + } + var response struct { + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil { + t.Fatal(err) + } + var claims apiauth.TokenClaims + token, err := jwt.ParseWithClaims(response.AccessToken, &claims, func(*jwt.Token) (any, error) { + return issuer.PublicKey(), nil + }, jwt.WithValidMethods([]string{"EdDSA"}), jwt.WithIssuer(issuer.Issuer()), jwt.WithAudience(issuer.Audience())) + if err != nil || !token.Valid || claims.Subject != target || claims.OwnerAddr != owner || claims.APIKeyID == "" { + t.Fatalf("token does not authenticate the expected owner and granted address: %v", err) + } +} + +func TestAPIKeyExchangeRejectsUnregisteredDerivedAddress(t *testing.T) { + database := newAPIKeyCLIDB(t) + key, err := apiauth.GenerateAPIKey() + if err != nil { + t.Fatal(err) + } + const owner = "@alice@example.com" + const target = "@alice_bot@example.com" + store := apiauth.NewStore(database) + if err := store.Create(context.Background(), owner, "bot", target, key.ID, apiauth.HashAPIKey(key.Value), []string{"127.0.0.0/8"}, time.Now().Add(time.Hour)); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/fmsgid/"+target { + t.Errorf("unexpected lookup: %s %s", r.Method, r.URL) + } + w.WriteHeader(http.StatusNotFound) + })) + defer server.Close() + w, _ := exchangeCLIKey(t, database, server.URL, key.Value) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), "granted address not found in fmsgid") { + t.Fatalf("unregistered address exchange status = %d", w.Code) + } +} diff --git a/internal/middleware/fmsgid_test.go b/internal/middleware/fmsgid_test.go new file mode 100644 index 0000000..54ac904 --- /dev/null +++ b/internal/middleware/fmsgid_test.go @@ -0,0 +1,91 @@ +package middleware + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +func TestCheckFmsgIDRejectsInvalidResponse(t *testing.T) { + for _, body := range []string{"", "upstream error", `{"acceptingNew":"true"}`, `{`, `{}`, `null`, `{"acceptingNew":null}`} { + t.Run(body, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write([]byte(body)) + })) + defer server.Close() + _, accepting, err := CheckFmsgID(server.URL, "@alice@example.com") + if err == nil || accepting { + t.Fatalf("invalid response: accepting=%v, err=%v", accepting, err) + } + }) + } +} + +func TestCheckFmsgIDSeparatesServices(t *testing.T) { + accepting := fmsgIDServer(t, http.StatusOK, true) + defer accepting.Close() + disabled := fmsgIDServer(t, http.StatusOK, false) + defer disabled.Close() + const addr = "@service-cache@example.com" + if _, ok, err := CheckFmsgID(accepting.URL, addr); err != nil || !ok { + t.Fatalf("accepting service: accepting=%v, err=%v", ok, err) + } + if _, ok, err := CheckFmsgID(disabled.URL, addr); err != nil || ok { + t.Fatalf("disabled service: accepting=%v, err=%v", ok, err) + } +} + +func TestRegisterFmsgIDRefreshesExistingAddress(t *testing.T) { + for _, status := range []int{http.StatusOK, http.StatusCreated} { + t.Run(http.StatusText(status), func(t *testing.T) { + const addr = "@alice_bot@example.com" + var accepting atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodPost && r.URL.Path == "/fmsgid": + var body struct{ Address string } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Address != addr { + t.Errorf("registration payload: address=%q, err=%v", body.Address, err) + w.WriteHeader(http.StatusBadRequest) + return + } + accepting.Store(true) + w.WriteHeader(status) + case r.Method == http.MethodGet && r.URL.Path == "/fmsgid/"+addr: + _ = json.NewEncoder(w).Encode(map[string]bool{"acceptingNew": accepting.Load()}) + default: + t.Errorf("unexpected request: %s %s", r.Method, r.URL) + w.WriteHeader(http.StatusNotFound) + } + })) + defer server.Close() + if _, ok, err := CheckFmsgID(server.URL, addr); err != nil || ok { + t.Fatalf("initial lookup: accepting=%v, err=%v", ok, err) + } + if err := RegisterFmsgID(server.URL+"/", addr); err != nil { + t.Fatal(err) + } + if _, ok, err := CheckFmsgID(server.URL, addr); err != nil || !ok { + t.Fatalf("lookup after registration: accepting=%v, err=%v", ok, err) + } + }) + } +} + +func TestCheckFmsgIDEscapesAddress(t *testing.T) { + const addr = "@alice?bot#1%tag@example.com" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/fmsgid/"+addr || r.URL.RawQuery != "" { + t.Errorf("lookup changed address: %s", r.URL) + w.WriteHeader(http.StatusNotFound) + return + } + _, _ = w.Write([]byte(`{"acceptingNew":true}`)) + })) + defer server.Close() + if code, accepting, err := CheckFmsgID(server.URL, addr); err != nil || code != http.StatusOK || !accepting { + t.Fatalf("lookup: code=%d, accepting=%v, err=%v", code, accepting, err) + } +} diff --git a/internal/middleware/jwt.go b/internal/middleware/jwt.go index 83b7e86..8352b40 100644 --- a/internal/middleware/jwt.go +++ b/internal/middleware/jwt.go @@ -10,6 +10,7 @@ import ( "fmt" "log" "net/http" + "net/url" "strings" "sync" "time" @@ -395,7 +396,7 @@ type fmsgIDEntry struct { acceptingNew bool } -var fmsgIDCache sync.Map // map[string]fmsgIDEntry, key = addr +var fmsgIDCache sync.Map // map[string]fmsgIDEntry, key = address lookup URL var fmsgIDGroup singleflight.Group @@ -406,22 +407,23 @@ type fmsgIDResult struct { // CheckFmsgID queries the fmsgid service for a user address. func CheckFmsgID(idURL, addr string) (int, bool, error) { - if v, ok := fmsgIDCache.Load(addr); ok { + lookupURL := fmsgIDAddressURL(idURL, addr) + if v, ok := fmsgIDCache.Load(lookupURL); ok { entry := v.(fmsgIDEntry) if time.Now().Before(entry.expires) { return entry.code, entry.acceptingNew, nil } - fmsgIDCache.Delete(addr) + fmsgIDCache.Delete(lookupURL) } - v, err, _ := fmsgIDGroup.Do(addr, func() (any, error) { - if v, ok := fmsgIDCache.Load(addr); ok { + v, err, _ := fmsgIDGroup.Do(lookupURL, func() (any, error) { + if v, ok := fmsgIDCache.Load(lookupURL); ok { entry := v.(fmsgIDEntry) if time.Now().Before(entry.expires) { return fmsgIDResult{code: entry.code, acceptingNew: entry.acceptingNew}, nil } } - return fetchFmsgID(idURL, addr) + return fetchFmsgID(lookupURL) }) if err != nil { return 0, false, err @@ -450,13 +452,16 @@ func RegisterFmsgID(idURL, addr string) error { if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { return fmt.Errorf("fmsgid registration failed: status %d", resp.StatusCode) } - fmsgIDCache.Delete(addr) + fmsgIDCache.Delete(fmsgIDAddressURL(idURL, addr)) return nil } -func fetchFmsgID(idURL, addr string) (fmsgIDResult, error) { - url := strings.TrimRight(idURL, "/") + "/fmsgid/" + addr - resp, err := fmsgIDClient.Get(url) //nolint:gosec // URL constructed from trusted config + validated addr +func fmsgIDAddressURL(idURL, addr string) string { + return strings.TrimRight(idURL, "/") + "/fmsgid/" + url.PathEscape(addr) +} + +func fetchFmsgID(lookupURL string) (fmsgIDResult, error) { + resp, err := fmsgIDClient.Get(lookupURL) //nolint:gosec // URL constructed from trusted config + escaped addr if err != nil { return fmsgIDResult{}, err } @@ -470,16 +475,19 @@ func fetchFmsgID(idURL, addr string) (fmsgIDResult, error) { } var result struct { - AcceptingNew bool `json:"acceptingNew"` + AcceptingNew *bool `json:"acceptingNew"` } if err := decodeJSON(resp.Body, &result); err != nil { - return fmsgIDResult{code: http.StatusOK, acceptingNew: true}, nil + return fmsgIDResult{}, fmt.Errorf("decoding fmsgid address: %w", err) + } + if result.AcceptingNew == nil { + return fmsgIDResult{}, errors.New("fmsgid address response is missing acceptingNew") } - fmsgIDCache.Store(addr, fmsgIDEntry{ + fmsgIDCache.Store(lookupURL, fmsgIDEntry{ expires: time.Now().Add(fmsgIDCacheTTL), code: http.StatusOK, - acceptingNew: result.AcceptingNew, + acceptingNew: *result.AcceptingNew, }) - return fmsgIDResult{code: http.StatusOK, acceptingNew: result.AcceptingNew}, nil + return fmsgIDResult{code: http.StatusOK, acceptingNew: *result.AcceptingNew}, nil } diff --git a/internal/middleware/jwt_test.go b/internal/middleware/jwt_test.go index 8c98c3b..7c53492 100644 --- a/internal/middleware/jwt_test.go +++ b/internal/middleware/jwt_test.go @@ -177,11 +177,6 @@ func TestEdDSAMode_Happy(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") - defer fmsgIDCache.Delete("@alice_bot@example.com") - srv := fmsgIDServer(t, http.StatusOK, true) defer srv.Close() priv, jwks := newEdDSAFixture(t) @@ -319,7 +314,6 @@ func TestEdDSAMode_ConfigValidation(t *testing.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(providerConfig(srv.URL, jwks)) if err != nil { @@ -331,7 +325,6 @@ func TestEdDSAMode_FmsgIDFailures(t *testing.T) { } srv.Close() - fmsgIDCache.Delete("@alice@example.com") srv = fmsgIDServer(t, http.StatusOK, false) defer srv.Close() mw, err = New(providerConfig(srv.URL, jwks))