mirror of
https://github.com/DeBrosOfficial/orama.git
synced 2026-06-16 22:54:12 +00:00
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:
parent
cd869a588f
commit
d4bf187e94
@ -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) {
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user