fix(gateway): /v1/auth/token resolves HMAC-hashed api keys

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.
This commit is contained in:
anonpenguin23 2026-06-15 21:57:29 +03:00
parent cd869a588f
commit d4bf187e94
2 changed files with 169 additions and 18 deletions

View File

@ -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) {

View File

@ -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)