From d4bf187e943884f81525c084f3ff315f0951e036 Mon Sep 17 00:00:00 2001 From: anonpenguin23 Date: Mon, 15 Jun 2026 21:57:29 +0300 Subject: [PATCH] fix(gateway): /v1/auth/token resolves HMAC-hashed api keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APIKeyToJWTHandler looked up the namespace by the raw api key, but keys are stored HMAC-hashed (Service.HashAPIKey), so it always returned 'invalid API key' for real keys — no api-key holder could exchange for a JWT. Resolve the hashed key first with a raw-key fallback for legacy rows, mirroring the gateway middleware's lookupAPIKeyNamespace. Adds args-aware mock + tests for the hashed-key, raw-fallback, and unknown-key paths. --- .../gateway/handlers/auth/handlers_test.go | 137 +++++++++++++++++- core/pkg/gateway/handlers/auth/jwt_handler.go | 50 ++++--- 2 files changed, 169 insertions(+), 18 deletions(-) diff --git a/core/pkg/gateway/handlers/auth/handlers_test.go b/core/pkg/gateway/handlers/auth/handlers_test.go index 122b3e3..5253c71 100644 --- a/core/pkg/gateway/handlers/auth/handlers_test.go +++ b/core/pkg/gateway/handlers/auth/handlers_test.go @@ -3,9 +3,12 @@ package auth import ( "bytes" "context" + "crypto/ed25519" + "crypto/rand" "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" authsvc "github.com/DeBrosOfficial/network/pkg/gateway/auth" @@ -18,12 +21,18 @@ import ( // --------------------------------------------------------------------------- // mockDatabaseClient implements DatabaseClient with configurable query results. +// queryFn, when set, takes precedence and lets a test inspect the bound args +// (e.g. to return a row only for the HMAC-hashed key, not the raw key). type mockDatabaseClient struct { queryResult *QueryResult queryErr error + queryFn func(query string, args ...interface{}) (*QueryResult, error) } -func (m *mockDatabaseClient) Query(_ context.Context, _ string, _ ...interface{}) (*QueryResult, error) { +func (m *mockDatabaseClient) Query(_ context.Context, query string, args ...interface{}) (*QueryResult, error) { + if m.queryFn != nil { + return m.queryFn(query, args...) + } return m.queryResult, m.queryErr } @@ -474,6 +483,132 @@ func TestAPIKeyToJWTHandler_NilAuthService(t *testing.T) { } } +// jwtCapableService builds a Service that can both hash API keys (HMAC secret +// set, so HashAPIKey produces a value distinct from the raw key) and mint JWTs +// (EdDSA signing key set). +func jwtCapableService(t *testing.T, hmacSecret string) *authsvc.Service { + t.Helper() + svc, err := authsvc.NewService(testLogger(), nil, "", "default") + if err != nil { + t.Fatalf("NewService failed: %v", err) + } + if hmacSecret != "" { + svc.SetAPIKeyHMACSecret(hmacSecret) + } + _, edPriv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("ed25519 keygen failed: %v", err) + } + svc.SetEdDSAKey(edPriv) + return svc +} + +func nsLookupRow(ns string) *QueryResult { + return &QueryResult{Count: 1, Rows: []interface{}{[]interface{}{ns}}} +} + +// TestAPIKeyToJWTHandler_HashedKeyLookup proves the fix: keys are stored +// HMAC-hashed, so the handler must resolve the namespace by the HASHED key. +// The mock returns a row ONLY for the hashed key — the pre-fix raw-key-only +// lookup would have returned 401 here. +func TestAPIKeyToJWTHandler_HashedKeyLookup(t *testing.T) { + const rawKey = "ak_live_abc123" + svc := jwtCapableService(t, "hmac-secret-xyz") + hashed := svc.HashAPIKey(rawKey) + if hashed == rawKey { + t.Fatal("precondition: HashAPIKey must differ from the raw key") + } + + db := &mockDatabaseClient{queryFn: func(_ string, args ...interface{}) (*QueryResult, error) { + if len(args) > 0 { + if k, _ := args[0].(string); k == hashed { + return nsLookupRow("vrf708"), nil + } + } + return &QueryResult{Count: 0}, nil + }} + h := NewHandlers(testLogger(), svc, &mockNetworkClient{db: db}, "default", noopInternalAuth) + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/token", nil) + req.Header.Set("X-API-Key", rawKey) + rec := httptest.NewRecorder() + + h.APIKeyToJWTHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", rec.Code, rec.Body.String()) + } + m := decodeBody(t, rec) + if m["namespace"] != "vrf708" { + t.Errorf("expected namespace vrf708, got %v", m["namespace"]) + } + tok, _ := m["access_token"].(string) + if strings.Count(tok, ".") != 2 { + t.Errorf("expected a JWT (2 dots) in access_token, got %q", tok) + } +} + +// TestAPIKeyToJWTHandler_RawKeyFallback covers the rolling-upgrade fallback: +// a legacy row stored under the RAW (unhashed) key still resolves. +func TestAPIKeyToJWTHandler_RawKeyFallback(t *testing.T) { + const rawKey = "ak_legacy_unhashed" + svc := jwtCapableService(t, "hmac-secret-xyz") + hashed := svc.HashAPIKey(rawKey) + + db := &mockDatabaseClient{queryFn: func(_ string, args ...interface{}) (*QueryResult, error) { + if len(args) > 0 { + if k, _ := args[0].(string); k == rawKey && k != hashed { + return nsLookupRow("vrf708"), nil + } + } + return &QueryResult{Count: 0}, nil + }} + h := NewHandlers(testLogger(), svc, &mockNetworkClient{db: db}, "default", noopInternalAuth) + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/token", nil) + req.Header.Set("X-API-Key", rawKey) + rec := httptest.NewRecorder() + + h.APIKeyToJWTHandler(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200 via raw-key fallback, got %d (body: %s)", rec.Code, rec.Body.String()) + } +} + +// TestAPIKeyToJWTHandler_UnknownKey verifies an unknown key still 401s after +// both candidate lookups miss. +func TestAPIKeyToJWTHandler_UnknownKey(t *testing.T) { + svc := jwtCapableService(t, "hmac-secret-xyz") + db := &mockDatabaseClient{queryFn: func(_ string, _ ...interface{}) (*QueryResult, error) { + return &QueryResult{Count: 0}, nil + }} + h := NewHandlers(testLogger(), svc, &mockNetworkClient{db: db}, "default", noopInternalAuth) + + req := httptest.NewRequest(http.MethodPost, "/v1/auth/token", nil) + req.Header.Set("X-API-Key", "ak_does_not_exist") + rec := httptest.NewRecorder() + + h.APIKeyToJWTHandler(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("expected 401 for unknown key, got %d", rec.Code) + } +} + +func TestApiKeyLookupCandidates(t *testing.T) { + // Distinct hash → hashed first, raw fallback. + got := apiKeyLookupCandidates("raw", "hashed") + if len(got) != 2 || got[0] != "hashed" || got[1] != "raw" { + t.Errorf("distinct: expected [hashed raw], got %v", got) + } + // No HMAC secret (hashed == raw) → single candidate, no duplicate query. + got = apiKeyLookupCandidates("raw", "raw") + if len(got) != 1 || got[0] != "raw" { + t.Errorf("equal: expected [raw], got %v", got) + } +} + // --- RegisterHandler tests ------------------------------------------------ func TestRegisterHandler_MissingFields(t *testing.T) { diff --git a/core/pkg/gateway/handlers/auth/jwt_handler.go b/core/pkg/gateway/handlers/auth/jwt_handler.go index 51575f8..260fd49 100644 --- a/core/pkg/gateway/handlers/auth/jwt_handler.go +++ b/core/pkg/gateway/handlers/auth/jwt_handler.go @@ -32,28 +32,33 @@ func (h *Handlers) APIKeyToJWTHandler(w http.ResponseWriter, r *http.Request) { return } - // Validate and get namespace + // Validate and get namespace. API keys are stored HMAC-hashed (see + // Service.HashAPIKey), so resolve the hashed key first and fall back to the + // raw key for any legacy unhashed rows during a rolling upgrade — mirroring + // the gateway middleware's lookupAPIKeyNamespace. Without the hashed lookup + // this endpoint always returned "invalid API key" for real keys, so api-key + // holders could never exchange for a JWT. db := h.netClient.Database() ctx := r.Context() internalCtx := h.internalAuthFn(ctx) - q := "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1" - res, err := db.Query(internalCtx, q, key) - if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 { - writeError(w, http.StatusUnauthorized, "invalid API key") - return - } + const q = "SELECT namespaces.name FROM api_keys JOIN namespaces ON api_keys.namespace_id = namespaces.id WHERE api_keys.key = ? LIMIT 1" - // Extract namespace from first row - row, ok := res.Rows[0].([]interface{}) - if !ok || len(row) == 0 { - writeError(w, http.StatusUnauthorized, "invalid API key") - return + ns := "" + for _, candidate := range apiKeyLookupCandidates(key, h.authService.HashAPIKey(key)) { + res, err := db.Query(internalCtx, q, candidate) + if err != nil || res == nil || res.Count == 0 || len(res.Rows) == 0 { + continue + } + row, ok := res.Rows[0].([]interface{}) + if !ok || len(row) == 0 { + continue + } + if s, ok := row[0].(string); ok && s != "" { + ns = s + break + } } - - var ns string - if s, ok := row[0].(string); ok { - ns = s - } else { + if ns == "" { writeError(w, http.StatusUnauthorized, "invalid API key") return } @@ -179,6 +184,17 @@ func (h *Handlers) LogoutHandler(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"status": "ok"}) } +// apiKeyLookupCandidates returns the api_keys.key values to try, hashed first +// (new keys are stored HMAC-hashed) then the raw key as a rolling-upgrade +// fallback for legacy unhashed rows. The raw fallback is skipped when hashing +// is a no-op (hashedKey == rawKey) so we never issue a duplicate query. +func apiKeyLookupCandidates(rawKey, hashedKey string) []string { + if hashedKey == rawKey { + return []string{rawKey} + } + return []string{hashedKey, rawKey} +} + // extractAPIKey extracts API key from Authorization, X-API-Key header, or query parameters func extractAPIKey(r *http.Request) string { // Prefer X-API-Key header (most explicit)