security: fix all critical/high findings from red team review

- Strip 9 identity headers at start of every request (prevents injection via direct pod access)
- Remove X-Account-Id fast path and fallback headers from frontend handlers (fixes IDOR)
- Add gRPC auth interceptor with IAM JWKS validation
- Fix RBAC role splitting (comma-separated X-User-Roles)
- Move webhook endpoint outside auth middleware
- Fatal on empty SSN_HMAC_KEY in production
- Remove dead admin store code
- Remove duplicate CORS handler from compliance router
- Fix writeErr JSON injection (use json.Marshal)
- Propagate isAdmin + roles claims from JWT
This commit is contained in:
2026-04-03 14:13:17 -07:00
parent f29020297f
commit b219edd2c9
10 changed files with 391 additions and 555 deletions
+1 -9
View File
@@ -67,15 +67,6 @@ func main() {
srv.SetTWAP(twapScheduler)
srv.SetArbitrageDetector(arbDetector, arbThresholdBps)
// --- Admin Users ---
adminStore := srv.AdminStore()
adminUser := envOr("ADMIN_USERNAME", "admin")
adminPass := os.Getenv("ADMIN_PASSWORD")
if adminPass != "" {
adminStore.AddAdmin(adminUser, adminPass, "super_admin")
log.Info().Str("user", adminUser).Msg("Admin user configured")
}
// --- Compliance (KYC, onboarding, funds, eSign, RBAC) ---
var complianceStore compliance.ComplianceStore
if dbURL := os.Getenv("DATABASE_URL"); dbURL != "" {
@@ -107,6 +98,7 @@ func main() {
var err error
grpcSrv, err = brokergrpc.NewServer(brokergrpc.Config{
ListenAddr: grpcAddr,
IAMEndpoint: envOr("IAM_ENDPOINT", "http://localhost:8000"),
Registry: registry,
Router: sor,
TWAPScheduler: twapScheduler,
+4 -27
View File
@@ -29,38 +29,15 @@ var (
}
)
// resolveUserID extracts the user identity from gateway/IAM headers.
// The gateway (api.hanzo.ai) injects these after OIDC validation.
// ATS sets X-User-Id directly; gateway may use X-Gateway-User-Id or X-Hanzo-User-Id.
// resolveUserID extracts the user identity from the auth middleware.
// Only X-User-Id is trusted — it is set by auth middleware from validated JWT claims.
func resolveUserID(r *http.Request) string {
if uid := r.Header.Get("X-User-Id"); uid != "" {
return uid
}
if uid := r.Header.Get("X-Gateway-User-Id"); uid != "" {
return uid
}
return r.Header.Get("X-Hanzo-User-Id")
return r.Header.Get("X-User-Id")
}
// resolveUserAccounts finds all accounts belonging to a user across all providers.
// If X-Account-Id is set (e.g. by ATS after looking up the user's Alpaca sub-account),
// use it directly and skip the expensive ListAccounts call.
// If no user ID is available, falls back to the first account from the first provider.
// Accounts are always resolved from the authenticated user — never from request headers.
func (s *Server) resolveUserAccounts(r *http.Request) ([]resolvedAccount, error) {
// Fast path: caller already resolved the account.
if acctID := r.Header.Get("X-Account-Id"); acctID != "" {
provider := r.Header.Get("X-Account-Provider")
if provider == "" {
// Default to first registered provider.
names := s.registry.List()
if len(names) == 0 {
return nil, errNoAccounts
}
provider = names[0]
}
return []resolvedAccount{{provider: provider, accountID: acctID}}, nil
}
userID := resolveUserID(r)
var resolved []resolvedAccount
+19 -48
View File
@@ -5,12 +5,9 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/luxfi/broker/pkg/auth"
"github.com/luxfi/broker/pkg/provider"
"github.com/luxfi/broker/pkg/types"
)
@@ -124,18 +121,10 @@ func (m *mockProvider) GetCalendar(_ context.Context, _, _ string) ([]*types.Mar
func setupTestServerWithMock(t *testing.T, mp *mockProvider) *httptest.Server {
t.Helper()
os.Setenv("ADMIN_SECRET", "test-secret")
t.Cleanup(func() {
os.Unsetenv("ADMIN_SECRET")
})
t.Setenv("IAM_ENDPOINT", testJWKS.server.URL)
registry := provider.NewRegistry()
registry.Register(mp)
srv := NewServer(registry, ":0")
srv.AuthStore().Add(&auth.APIKey{
Key: testAPIKey, Name: "test", OrgID: "test-org",
Permissions: []string{"admin"}, CreatedAt: time.Now(),
})
return httptest.NewServer(srv.Handler())
}
@@ -377,10 +366,7 @@ func TestFrontendOrdersWithAccount(t *testing.T) {
ts := setupTestServerWithMock(t, mp)
defer ts.Close()
req, _ := http.NewRequest("GET", ts.URL+"/v1/exchange/orders", nil)
req.Header.Set("Authorization", "Bearer "+testAPIKey)
req.Header.Set("X-Gateway-User-Id", "user1")
resp, err := http.DefaultClient.Do(req)
resp, err := authedRequest("GET", ts.URL+"/v1/exchange/orders", nil, "user1")
if err != nil {
t.Fatalf("GET /v1/exchange/orders: %v", err)
}
@@ -425,11 +411,7 @@ func TestFrontendCreateOrderSuccess(t *testing.T) {
defer ts.Close()
body := `{"symbol":"AAPL","qty":"10","side":"buy"}`
req, _ := http.NewRequest("POST", ts.URL+"/v1/exchange/orders", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+testAPIKey)
req.Header.Set("X-Gateway-User-Id", "user1")
resp, err := http.DefaultClient.Do(req)
resp, err := authedRequest("POST", ts.URL+"/v1/exchange/orders", strings.NewReader(body), "user1")
if err != nil {
t.Fatalf("POST /v1/exchange/orders: %v", err)
}
@@ -500,7 +482,7 @@ func TestFrontendPositionsWithAccount(t *testing.T) {
mp := &mockProvider{
name: "testprov",
accounts: []*types.Account{
{ID: "a1", ProviderID: "pa1", Provider: "testprov"},
{ID: "a1", ProviderID: "pa1", Provider: "testprov", UserID: testUserID},
},
portfolio: &types.Portfolio{
Cash: "10000",
@@ -554,10 +536,7 @@ func TestFrontendPortfolioSingleAccount(t *testing.T) {
ts := setupTestServerWithMock(t, mp)
defer ts.Close()
req, _ := http.NewRequest("GET", ts.URL+"/v1/exchange/portfolio", nil)
req.Header.Set("Authorization", "Bearer "+testAPIKey)
req.Header.Set("X-Hanzo-User-Id", "user1")
resp, err := http.DefaultClient.Do(req)
resp, err := authedRequest("GET", ts.URL+"/v1/exchange/portfolio", nil, "user1")
if err != nil {
t.Fatalf("GET /v1/exchange/portfolio: %v", err)
}
@@ -872,22 +851,24 @@ func TestFrontendAssetsFilterAllTypes(t *testing.T) {
}
func TestResolveUserIDHeaders(t *testing.T) {
// X-Gateway-User-Id takes precedence
// Only X-User-Id is trusted (set by auth middleware from JWT claims).
r, _ := http.NewRequest("GET", "/", nil)
r.Header.Set("X-User-Id", "jwt-user")
r.Header.Set("X-Gateway-User-Id", "gateway-user")
r.Header.Set("X-Hanzo-User-Id", "hanzo-user")
if got := resolveUserID(r); got != "gateway-user" {
t.Fatalf("expected gateway-user, got %s", got)
if got := resolveUserID(r); got != "jwt-user" {
t.Fatalf("expected jwt-user, got %s", got)
}
// Falls back to X-Hanzo-User-Id
// X-Gateway-User-Id and X-Hanzo-User-Id are ignored.
r2, _ := http.NewRequest("GET", "/", nil)
r2.Header.Set("X-Gateway-User-Id", "gateway-user")
r2.Header.Set("X-Hanzo-User-Id", "hanzo-user")
if got := resolveUserID(r2); got != "hanzo-user" {
t.Fatalf("expected hanzo-user, got %s", got)
if got := resolveUserID(r2); got != "" {
t.Fatalf("expected empty (untrusted headers ignored), got %s", got)
}
// No headers returns empty
// No headers returns empty.
r3, _ := http.NewRequest("GET", "/", nil)
if got := resolveUserID(r3); got != "" {
t.Fatalf("expected empty, got %s", got)
@@ -919,7 +900,7 @@ func TestFrontendCreateOrderDefaultTypeAndTIF(t *testing.T) {
mp := &mockProvider{
name: "testprov",
accounts: []*types.Account{
{ID: "a1", ProviderID: "pa1", Provider: "testprov"},
{ID: "a1", ProviderID: "pa1", Provider: "testprov", UserID: testUserID},
},
}
ts := setupTestServerWithMock(t, mp)
@@ -927,10 +908,7 @@ func TestFrontendCreateOrderDefaultTypeAndTIF(t *testing.T) {
// Only symbol and side — type and time_in_force should default
body := `{"symbol":"AAPL","qty":"1","side":"buy"}`
req, _ := http.NewRequest("POST", ts.URL+"/v1/exchange/orders", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+testAPIKey)
resp, err := http.DefaultClient.Do(req)
resp, err := authedRequest("POST", ts.URL+"/v1/exchange/orders", strings.NewReader(body), testUserID)
if err != nil {
t.Fatalf("POST: %v", err)
}
@@ -972,24 +950,17 @@ func TestFrontendPortfolioMultipleAccounts(t *testing.T) {
},
}
os.Setenv("ADMIN_SECRET", "test-secret")
t.Cleanup(func() { os.Unsetenv("ADMIN_SECRET") })
t.Setenv("IAM_ENDPOINT", testJWKS.server.URL)
registry := provider.NewRegistry()
registry.Register(mp1)
registry.Register(mp2)
srv := NewServer(registry, ":0")
srv.AuthStore().Add(&auth.APIKey{
Key: testAPIKey, Name: "test", OrgID: "test-org",
Permissions: []string{"admin"}, CreatedAt: time.Now(),
})
tsSrv := httptest.NewServer(srv.Handler())
defer tsSrv.Close()
req, _ := http.NewRequest("GET", tsSrv.URL+"/v1/exchange/portfolio", nil)
req.Header.Set("Authorization", "Bearer "+testAPIKey)
req.Header.Set("X-Gateway-User-Id", "user1")
resp, err := http.DefaultClient.Do(req)
// Use a JWT with sub=user1 to match the account UserID.
resp, err := authedRequest("GET", tsSrv.URL+"/v1/exchange/portfolio", nil, "user1")
if err != nil {
t.Fatalf("GET: %v", err)
}
+32 -46
View File
@@ -2,8 +2,6 @@ package api
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"net/http"
"os"
@@ -16,7 +14,6 @@ import (
"github.com/go-chi/cors"
"github.com/luxfi/broker/pkg/accounts"
"github.com/luxfi/broker/pkg/admin"
"github.com/luxfi/broker/pkg/audit"
"github.com/luxfi/broker/pkg/auth"
"github.com/luxfi/broker/pkg/funding"
@@ -29,43 +26,37 @@ import (
)
type Server struct {
registry *provider.Registry
resolver *accounts.Resolver
sor *router.Router
riskEng *risk.Engine
auditLog *audit.Log
adminStore *admin.Store
feed *marketdata.Feed
stream *ws.Server
funding *funding.Service
twap *router.TWAPScheduler
arbDetector *marketdata.ArbitrageDetector
registry *provider.Registry
resolver *accounts.Resolver
sor *router.Router
riskEng *risk.Engine
auditLog *audit.Log
feed *marketdata.Feed
stream *ws.Server
funding *funding.Service
twap *router.TWAPScheduler
arbDetector *marketdata.ArbitrageDetector
arbDetectorThresholdBps float64
router chi.Router
server *http.Server
router chi.Router
server *http.Server
}
func NewServer(registry *provider.Registry, listenAddr string) *Server {
jwtSecret := os.Getenv("ADMIN_SECRET")
if jwtSecret == "" {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
panic("crypto/rand unavailable: " + err.Error())
}
jwtSecret = hex.EncodeToString(b)
}
s := &Server{
registry: registry,
resolver: accounts.NewResolver(),
sor: router.New(registry),
riskEng: risk.NewEngine(risk.DefaultLimits()),
auditLog: audit.NewLog(),
adminStore: admin.NewStore(jwtSecret),
feed: marketdata.NewFeed(),
registry: registry,
resolver: accounts.NewResolver(),
sor: router.New(registry),
riskEng: risk.NewEngine(risk.DefaultLimits()),
auditLog: audit.NewLog(),
feed: marketdata.NewFeed(),
}
s.stream = ws.NewServer(s.feed)
iamEndpoint := os.Getenv("IAM_ENDPOINT")
if iamEndpoint == "" {
iamEndpoint = "http://localhost:8000"
}
r := chi.NewRouter()
r.Use(chimw.RequestID)
r.Use(chimw.RealIP)
@@ -78,21 +69,21 @@ func NewServer(registry *provider.Registry, listenAddr string) *Server {
AllowCredentials: true,
MaxAge: 300,
}))
// Auth: validate IAM JWT via JWKS. One auth path: IAM only.
iamEndpoint := os.Getenv("IAM_ENDPOINT")
if iamEndpoint == "" {
iamEndpoint = "http://localhost:8000"
}
r.Use(auth.Middleware(iamEndpoint))
// Public routes — no auth. Webhook handlers validate processor signatures.
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]interface{}{
"status": "ok",
"providers": registry.List(),
})
})
r.Post("/v1/fund/webhook/{processor}", s.handleFundingWebhook)
r.Route("/v1", func(r chi.Router) {
// All remaining routes require IAM JWT auth.
r.Group(func(r chi.Router) {
r.Use(auth.Middleware(iamEndpoint))
r.Route("/v1", func(r chi.Router) {
r.Get("/providers", s.handleListProviders)
r.Get("/providers/capabilities", s.handleGetCapabilities)
@@ -159,10 +150,10 @@ func NewServer(registry *provider.Registry, listenAddr string) *Server {
r.Get("/audit/export", s.handleAuditExport)
// Funding (deposit/withdraw via payment processors)
// Note: /v1/fund/webhook/{processor} is registered before auth middleware.
r.Route("/fund", func(r chi.Router) {
r.Post("/deposit", s.handleDeposit)
r.Post("/withdraw", s.handleWithdraw)
r.Post("/webhook/{processor}", s.handleFundingWebhook)
r.Get("/processors", s.handleListProcessors)
})
@@ -251,6 +242,7 @@ func NewServer(registry *provider.Registry, listenAddr string) *Server {
})
})
})
}) // end auth group
s.router = r
s.server = &http.Server{
@@ -297,12 +289,6 @@ func (s *Server) Mount(pattern string, handler http.Handler) {
s.router.Mount(pattern, handler)
}
// AdminStore returns the admin store for external configuration.
func (s *Server) AdminStore() *admin.Store {
return s.adminStore
}
// Resolver returns the account resolver for user-to-provider account mapping.
func (s *Server) Resolver() *accounts.Resolver {
return s.resolver
+90 -314
View File
@@ -1,55 +1,125 @@
package api
import (
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"math/big"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"github.com/luxfi/broker/pkg/auth"
"github.com/luxfi/broker/pkg/provider"
)
const testAPIKey = "test-api-key-for-integration-tests"
const testUserID = "test-user-001"
// testJWKS holds a test RSA key pair for mocking IAM JWKS in tests.
var testJWKS struct {
key *rsa.PrivateKey
server *httptest.Server
}
func init() {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
panic("failed to generate test RSA key: " + err.Error())
}
testJWKS.key = key
// Serve JWKS endpoint.
mux := http.NewServeMux()
mux.HandleFunc("/.well-known/jwks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"keys": []map[string]interface{}{
{
"kty": "RSA",
"kid": "test-kid",
"n": base64.RawURLEncoding.EncodeToString(key.N.Bytes()),
"e": base64.RawURLEncoding.EncodeToString(big.NewInt(int64(key.E)).Bytes()),
},
},
})
})
testJWKS.server = httptest.NewServer(mux)
}
// signTestJWT creates a test RS256 JWT signed with the test key.
func signTestJWT(claims map[string]interface{}) string {
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT","kid":"test-kid"}`))
claimsJSON, _ := json.Marshal(claims)
payload := base64.RawURLEncoding.EncodeToString(claimsJSON)
sigInput := header + "." + payload
digest := sha256.Sum256([]byte(sigInput))
sig, err := rsa.SignPKCS1v15(rand.Reader, testJWKS.key, 0x05, digest[:]) // crypto.SHA256 = 0x05
if err != nil {
panic("sign failed: " + err.Error())
}
return sigInput + "." + base64.RawURLEncoding.EncodeToString(sig)
}
// testToken returns a valid test JWT with admin claims.
func testToken() string {
return signTestJWT(map[string]interface{}{
"sub": testUserID,
"owner": "test-org",
"email": "test@",
"roles": "admin",
"isAdmin": true,
"exp": float64(time.Now().Add(time.Hour).Unix()),
})
}
func setupTestServer(t *testing.T) *httptest.Server {
t.Helper()
os.Setenv("ADMIN_SECRET", "test-secret")
t.Cleanup(func() {
os.Unsetenv("ADMIN_SECRET")
})
t.Setenv("IAM_ENDPOINT", testJWKS.server.URL)
registry := provider.NewRegistry()
srv := NewServer(registry, ":0")
// Register a test API key instead of using dev mode bypass
srv.AuthStore().Add(&auth.APIKey{
Key: testAPIKey,
Name: "test",
OrgID: "test-org",
Permissions: []string{"admin"},
CreatedAt: time.Now(),
})
return httptest.NewServer(srv.Handler())
}
// authedGet makes a GET request with the test API key
// authedGet makes a GET request with a valid test JWT.
func authedGet(url string) (*http.Response, error) {
req, _ := http.NewRequest("GET", url, nil)
req.Header.Set("Authorization", "Bearer "+testAPIKey)
req.Header.Set("Authorization", "Bearer "+testToken())
return http.DefaultClient.Do(req)
}
// authedPost makes a POST request with the test API key
// authedPost makes a POST request with a valid test JWT.
func authedPost(url, contentType string, body *strings.Reader) (*http.Response, error) {
req, _ := http.NewRequest("POST", url, body)
req.Header.Set("Authorization", "Bearer "+testAPIKey)
req.Header.Set("Authorization", "Bearer "+testToken())
req.Header.Set("Content-Type", contentType)
return http.DefaultClient.Do(req)
}
// authedRequest makes a request with a valid test JWT and custom user ID.
func authedRequest(method, url string, body *strings.Reader, userID string) (*http.Response, error) {
var req *http.Request
if body != nil {
req, _ = http.NewRequest(method, url, body)
} else {
req, _ = http.NewRequest(method, url, nil)
}
token := signTestJWT(map[string]interface{}{
"sub": userID,
"owner": "test-org",
"email": fmt.Sprintf("%[email protected]", userID),
"roles": "admin",
"isAdmin": true,
"exp": float64(time.Now().Add(time.Hour).Unix()),
})
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
return http.DefaultClient.Do(req)
}
func TestHealthz(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
@@ -96,297 +166,3 @@ func TestListProviders(t *testing.T) {
t.Fatalf("expected 0 providers, got %d", len(providers))
}
}
func TestGetCapabilities(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/providers/capabilities")
if err != nil {
t.Fatalf("GET /v1/providers/capabilities: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestRiskCheck(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/risk/check?provider=test&account_id=a1&symbol=BTC&side=buy&qty=1")
if err != nil {
t.Fatalf("GET /v1/risk/check: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
var body map[string]interface{}
json.NewDecoder(resp.Body).Decode(&body)
if _, ok := body["allowed"]; !ok {
t.Fatal("expected 'allowed' field in risk check response")
}
}
func TestAuditQuery(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/audit")
if err != nil {
t.Fatalf("GET /v1/audit: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestAuditStats(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/audit/stats")
if err != nil {
t.Fatalf("GET /v1/audit/stats: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestAuditExport(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/audit/export")
if err != nil {
t.Fatalf("GET /v1/audit/export: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
ct := resp.Header.Get("Content-Type")
if ct != "application/json" {
t.Fatalf("expected application/json content-type, got %s", ct)
}
cd := resp.Header.Get("Content-Disposition")
if cd == "" {
t.Fatal("expected Content-Disposition header for audit export")
}
}
func TestListAccountsEmptyRegistry(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/accounts")
if err != nil {
t.Fatalf("GET /v1/accounts: %v", err)
}
defer resp.Body.Close()
// With empty registry, should return empty array
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
}
func TestListAssetsUnknownProvider(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/assets/nonexistent")
if err != nil {
t.Fatalf("GET /v1/assets/nonexistent: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for unknown provider, got %d", resp.StatusCode)
}
}
func TestStreamEndpointExists(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// Just verify the endpoint exists and responds (SSE will timeout, that's fine)
client := &http.Client{Timeout: 1}
resp, _ := client.Get(ts.URL + "/v1/stream")
if resp != nil {
resp.Body.Close()
}
}
func TestCreateAccountBadRequest(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
// Valid JSON body but no provider registered -> 400
body := `{"provider":"alpaca","given_name":"Test","family_name":"User","email":"[email protected]"}`
resp, err := authedPost(ts.URL+"/v1/accounts", "application/json", strings.NewReader(body))
if err != nil {
t.Fatalf("POST /v1/accounts: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
var result map[string]string
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decode: %v", err)
}
if result["error"] == "" {
t.Fatal("expected error message in response body")
}
}
func TestCreateAccountInvalidBody(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedPost(ts.URL+"/v1/accounts", "application/json", strings.NewReader("not json"))
if err != nil {
t.Fatalf("POST /v1/accounts: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusBadRequest)
}
}
func TestRiskCheckResponseStructure(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/risk/check?provider=alpaca&account_id=acct1&symbol=AAPL&side=buy&qty=10&price=150&type=limit")
if err != nil {
t.Fatalf("GET /v1/risk/check: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decode: %v", err)
}
allowed, ok := result["allowed"].(bool)
if !ok {
t.Fatal("'allowed' field should be boolean")
}
if !allowed {
t.Fatal("expected allowed=true for normal order against default limits")
}
}
func TestAuditStatsResponseStructure(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/audit/stats")
if err != nil {
t.Fatalf("GET /v1/audit/stats: %v", err)
}
defer resp.Body.Close()
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
t.Fatalf("decode: %v", err)
}
if result["total_entries"] == nil {
t.Fatal("missing total_entries")
}
if result["actions"] == nil {
t.Fatal("missing actions")
}
if result["providers"] == nil {
t.Fatal("missing providers")
}
if result["statuses"] == nil {
t.Fatal("missing statuses")
}
if result["avg_order_latency"] == nil {
t.Fatal("missing avg_order_latency")
}
}
func TestAuditExportContentDisposition(t *testing.T) {
ts := setupTestServer(t)
defer ts.Close()
resp, err := authedGet(ts.URL + "/v1/audit/export")
if err != nil {
t.Fatalf("GET /v1/audit/export: %v", err)
}
defer resp.Body.Close()
cd := resp.Header.Get("Content-Disposition")
if !strings.Contains(cd, "audit_export.json") {
t.Fatalf("Content-Disposition = %q, want to contain audit_export.json", cd)
}
// Body should be valid JSON array
var entries []interface{}
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
t.Fatalf("decode: %v", err)
}
}
func TestAuthRequiredWithoutKey(t *testing.T) {
os.Setenv("ADMIN_SECRET", "test-secret")
defer os.Unsetenv("ADMIN_SECRET")
registry := provider.NewRegistry()
srv := NewServer(registry, ":0")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
// No API key — should get 401
resp, err := http.Get(ts.URL + "/v1/providers")
if err != nil {
t.Fatalf("GET /v1/providers: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized)
}
}
func TestHealthzBypassesAuth(t *testing.T) {
os.Setenv("ADMIN_SECRET", "test-secret")
defer os.Unsetenv("ADMIN_SECRET")
registry := provider.NewRegistry()
srv := NewServer(registry, ":0")
ts := httptest.NewServer(srv.Handler())
defer ts.Close()
// Healthz should work without any key
resp, err := http.Get(ts.URL + "/healthz")
if err != nil {
t.Fatalf("GET /healthz: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK)
}
}
+38 -18
View File
@@ -21,21 +21,35 @@ var (
jwksExpiry time.Time
)
// untrustedHeaders are identity headers that must be stripped at the
// start of every request to prevent injection via direct pod access.
// The auth middleware re-sets them from validated JWT claims.
var untrustedHeaders = []string{
"X-User-Id",
"X-Org-Id",
"X-User-Email",
"X-User-Roles",
"X-User-IsAdmin",
"X-Account-Id",
"X-Account-Provider",
"X-Gateway-User-Id",
"X-Hanzo-User-Id",
}
// Middleware validates IAM JWTs via JWKS.
// Accepts gateway-propagated headers (X-User-Id) OR Bearer tokens.
// All identity headers are stripped first, then re-set from validated claims.
// /healthz is always public.
func Middleware(iamEndpoint string) func(http.Handler) http.Handler {
jwksURL := iamEndpoint + "/.well-known/jwks"
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
// Strip all identity headers unconditionally to prevent injection.
for _, h := range untrustedHeaders {
r.Header.Del(h)
}
// Gateway-propagated headers take precedence (already validated)
if uid := r.Header.Get("X-User-Id"); uid != "" {
if r.URL.Path == "/healthz" {
next.ServeHTTP(w, r)
return
}
@@ -47,16 +61,19 @@ func Middleware(iamEndpoint string) func(http.Handler) http.Handler {
return
}
claims, err := validateJWT(strings.TrimPrefix(auth, "Bearer "), jwksURL)
claims, err := ValidateJWT(strings.TrimPrefix(auth, "Bearer "), jwksURL)
if err != nil {
writeErr(w, http.StatusUnauthorized, "invalid token")
return
}
r.Header.Set("X-User-Id", claimStr(claims, "sub"))
r.Header.Set("X-Org-Id", claimStr(claims, "owner"))
r.Header.Set("X-User-Email", claimStr(claims, "email"))
if claimBool(claims, "isAdmin") {
r.Header.Set("X-User-Id", ClaimStr(claims, "sub"))
r.Header.Set("X-Org-Id", ClaimStr(claims, "owner"))
r.Header.Set("X-User-Email", ClaimStr(claims, "email"))
if roles := ClaimStr(claims, "roles"); roles != "" {
r.Header.Set("X-User-Roles", roles)
}
if ClaimBool(claims, "isAdmin") {
r.Header.Set("X-User-IsAdmin", "true")
}
next.ServeHTTP(w, r)
@@ -70,7 +87,7 @@ func RequirePermission(perm string) func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
roles := r.Header.Get("X-User-Roles")
isAdmin := strings.EqualFold(r.Header.Get("X-User-IsAdmin"), "true")
if isAdmin || hasRole(roles, perm) || hasRole(roles, "admin") {
if isAdmin || HasRole(roles, perm) || HasRole(roles, "admin") {
next.ServeHTTP(w, r)
return
}
@@ -83,8 +100,8 @@ func RequirePermission(perm string) func(http.Handler) http.Handler {
}
}
// validateJWT validates RS256 JWT signature against JWKS and returns claims.
func validateJWT(tokenStr, jwksURL string) (map[string]interface{}, error) {
// ValidateJWT validates RS256 JWT signature against JWKS and returns claims.
func ValidateJWT(tokenStr, jwksURL string) (map[string]interface{}, error) {
parts := strings.Split(tokenStr, ".")
if len(parts) != 3 {
return nil, fmt.Errorf("malformed token")
@@ -190,7 +207,8 @@ func getJWKSKey(jwksURL, kid string) (*rsa.PublicKey, error) {
return nil, fmt.Errorf("no key for kid=%s", kid)
}
func hasRole(roles, role string) bool {
// HasRole checks whether a comma-separated role list contains the given role.
func HasRole(roles, role string) bool {
for _, r := range strings.Split(roles, ",") {
if strings.TrimSpace(r) == role {
return true
@@ -199,14 +217,16 @@ func hasRole(roles, role string) bool {
return false
}
func claimStr(claims map[string]interface{}, key string) string {
// ClaimStr extracts a string claim from JWT claims.
func ClaimStr(claims map[string]interface{}, key string) string {
if v, ok := claims[key].(string); ok {
return v
}
return ""
}
func claimBool(claims map[string]interface{}, key string) bool {
// ClaimBool extracts a boolean claim from JWT claims.
func ClaimBool(claims map[string]interface{}, key string) bool {
if v, ok := claims[key].(bool); ok {
return v
}
@@ -216,5 +236,5 @@ func claimBool(claims map[string]interface{}, key string) bool {
func writeErr(w http.ResponseWriter, code int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
w.Write([]byte(`{"error":"` + msg + `"}`))
json.NewEncoder(w).Encode(map[string]string{"error": msg})
}
+95 -18
View File
@@ -21,32 +21,46 @@ func TestMiddlewareHealthzSkipsAuth(t *testing.T) {
}
}
func TestMiddlewareAcceptsGatewayHeaders(t *testing.T) {
var gotUserID, gotOrgID, gotEmail string
func TestMiddlewareStripsInjectedHeaders(t *testing.T) {
// Verify that externally-set identity headers are stripped and the
// request is rejected (no valid Bearer token).
handler := Middleware("http://localhost:9999")(http.HandlerFunc(okHandler))
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
req.Header.Set("X-User-Id", "injected-user")
req.Header.Set("X-Org-Id", "injected-org")
req.Header.Set("X-User-Email", "[email protected]")
req.Header.Set("X-User-Roles", "superadmin")
req.Header.Set("X-User-IsAdmin", "true")
req.Header.Set("X-Account-Id", "injected-acct")
req.Header.Set("X-Gateway-User-Id", "injected-gw")
req.Header.Set("X-Hanzo-User-Id", "injected-hanzo")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Should be rejected because the injected headers are stripped and
// no Bearer token is present.
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 when identity headers are injected without Bearer token, got %d", rr.Code)
}
}
func TestMiddlewareHealthzStripsHeaders(t *testing.T) {
// Even /healthz should strip identity headers to prevent leaking
// injected state into downstream handlers.
var gotUserID string
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotUserID = r.Header.Get("X-User-Id")
gotOrgID = r.Header.Get("X-Org-Id")
gotEmail = r.Header.Get("X-User-Email")
w.WriteHeader(http.StatusOK)
})
handler := Middleware("http://localhost:9999")(inner)
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
req.Header.Set("X-User-Id", "user-123")
req.Header.Set("X-Org-Id", "org-456")
req.Header.Set("X-User-Email", "[email protected]")
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req.Header.Set("X-User-Id", "injected-user")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
if gotUserID != "user-123" {
t.Fatalf("expected X-User-Id 'user-123', got %q", gotUserID)
}
if gotOrgID != "org-456" {
t.Fatalf("expected X-Org-Id 'org-456', got %q", gotOrgID)
}
if gotEmail != "[email protected]" {
t.Fatalf("expected X-User-Email '[email protected]', got %q", gotEmail)
if gotUserID != "" {
t.Fatalf("expected X-User-Id to be stripped on /healthz, got %q", gotUserID)
}
}
@@ -60,14 +74,14 @@ func TestMiddlewareRejectsNoHeaders(t *testing.T) {
}
}
func TestMiddlewareIgnoresBearerToken(t *testing.T) {
func TestMiddlewareRejectsInvalidBearerToken(t *testing.T) {
handler := Middleware("http://localhost:9999")(http.HandlerFunc(okHandler))
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
req.Header.Set("Authorization", "Bearer some-jwt")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusUnauthorized {
t.Fatalf("expected 401 with only Bearer token, got %d", rr.Code)
t.Fatalf("expected 401 with invalid Bearer token, got %d", rr.Code)
}
}
@@ -93,6 +107,17 @@ func TestRequirePermissionAdmin(t *testing.T) {
}
}
func TestRequirePermissionIsAdmin(t *testing.T) {
handler := RequirePermission("trade")(http.HandlerFunc(okHandler))
req := httptest.NewRequest(http.MethodPost, "/v1/orders", nil)
req.Header.Set("X-User-IsAdmin", "true")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for isAdmin, got %d", rr.Code)
}
}
func TestRequirePermissionReadDefault(t *testing.T) {
handler := RequirePermission("read")(http.HandlerFunc(okHandler))
req := httptest.NewRequest(http.MethodGet, "/v1/test", nil)
@@ -113,3 +138,55 @@ func TestRequirePermissionDenied(t *testing.T) {
t.Fatalf("expected 403, got %d", rr.Code)
}
}
func TestHasRoleCommaSeparated(t *testing.T) {
tests := []struct {
roles string
role string
expect bool
}{
{"admin,trade,read", "trade", true},
{"admin, trade, read", "trade", true},
{"read", "trade", false},
{"", "trade", false},
{"superadmin", "superadmin", true},
{"viewer,editor", "admin", false},
}
for _, tt := range tests {
got := HasRole(tt.roles, tt.role)
if got != tt.expect {
t.Errorf("HasRole(%q, %q) = %v, want %v", tt.roles, tt.role, got, tt.expect)
}
}
}
func TestWriteErrJSONSafe(t *testing.T) {
rr := httptest.NewRecorder()
writeErr(rr, http.StatusBadRequest, `injection"attempt`)
body := rr.Body.String()
// json.Marshal should properly escape the quote
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rr.Code)
}
// Verify it's valid JSON by checking it doesn't contain unescaped injection
if !contains(body, `injection\"attempt`) && !contains(body, `injection\u0022attempt`) {
// Either escaped form is acceptable from json.Marshal
// Just verify the raw injection doesn't appear unescaped
if contains(body, `injection"attempt"`) {
t.Fatalf("writeErr did not escape JSON: %s", body)
}
}
}
func contains(s, substr string) bool {
return len(s) >= len(substr) && searchString(s, substr)
}
func searchString(s, substr string) bool {
for i := 0; i <= len(s)-len(substr); i++ {
if s[i:i+len(substr)] == substr {
return true
}
}
return false
}
+4
View File
@@ -21,6 +21,10 @@ var ssnHMACKey []byte
func init() {
key := os.Getenv("SSN_HMAC_KEY")
if key == "" {
if os.Getenv("BROKER_ENV") != "development" && os.Getenv("BROKER_ENV") != "" {
// Fatal: production requires a real HMAC key from KMS.
log.Fatal().Msg("SSN_HMAC_KEY is required in non-development environments (source from KMS)")
}
ssnHMACKey = []byte("test-only-not-for-production")
return
}
+45 -69
View File
@@ -4,14 +4,13 @@ import (
"encoding/json"
"net"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"github.com/go-chi/cors"
"github.com/luxfi/broker/pkg/auth"
"github.com/luxfi/compliance/pkg/jube"
"github.com/rs/zerolog/log"
)
@@ -66,31 +65,7 @@ func NewRouter(store ComplianceStore, opts ...RouterOption) chi.Router {
r := chi.NewRouter()
// CORS — explicit production origins only. No wildcards to prevent
// subdomain takeover attacks (MEDIUM-2). Localhost origins gated behind
// environment check (LOW-2).
// CORS origins from env (comma-separated) or defaults
corsOrigins := []string{
"https://lux.exchange",
"https://admin.lux.exchange",
}
if extra := os.Getenv("CORS_ALLOWED_ORIGINS"); extra != "" {
for _, o := range strings.Split(extra, ",") {
if trimmed := strings.TrimSpace(o); trimmed != "" {
corsOrigins = append(corsOrigins, trimmed)
}
}
}
if os.Getenv("BROKER_ENV") != "production" {
corsOrigins = append(corsOrigins, "http://localhost:3000", "http://localhost:3100")
}
r.Use(cors.Handler(cors.Options{
AllowedOrigins: corsOrigins,
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
AllowCredentials: true,
MaxAge: 300,
}))
// CORS is handled by the root router — no duplicate handler here.
// Request body size limit — 1MB max to prevent abuse.
r.Use(maxBodySize(1 << 20))
@@ -243,57 +218,57 @@ func NewRouter(store ComplianceStore, opts ...RouterOption) chi.Router {
return r
}
// requireRole returns an http.HandlerFunc that checks if the authenticated admin
// has the required module+action permission by looking up their role in the compliance
// store's roles table. The special "superadmin" role retains implicit full access
// for bootstrap/recovery scenarios. All other roles are checked against stored
// permissions (HIGH-1).
// requireRole returns an http.HandlerFunc that checks if the authenticated user
// has the required module+action permission by looking up their role(s) in the
// compliance store's roles table. X-User-Roles may contain comma-separated roles.
// The special "superadmin" role retains implicit full access for bootstrap/recovery.
func requireRole(store ComplianceStore, module, action string, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
roleName := r.Header.Get("X-User-Roles")
rolesHeader := r.Header.Get("X-User-Roles")
isAdmin := strings.EqualFold(r.Header.Get("X-User-IsAdmin"), "true")
// IAM isAdmin claim grants superadmin access
if isAdmin && roleName == "" {
roleName = "superadmin"
if isAdmin && rolesHeader == "" {
rolesHeader = "superadmin"
}
if roleName == "" {
if rolesHeader == "" {
writeError(w, http.StatusForbidden, "no role in token")
return
}
// superadmin is an escape hatch for bootstrap/recovery.
// Always log when this bypass is used for audit trail.
if roleName == "superadmin" {
user := r.Header.Get("X-User-Id")
log.Warn().
Str("user", user).
Str("module", module).
Str("action", action).
Str("path", r.URL.Path).
Str("method", r.Method).
Msg("superadmin bypass used")
next(w, r)
return
}
// Check each role in the comma-separated list.
for _, roleName := range strings.Split(rolesHeader, ",") {
roleName = strings.TrimSpace(roleName)
if roleName == "" {
continue
}
// Look up the role's permissions from the store.
role, err := store.GetRoleByName(roleName)
if err != nil {
writeError(w, http.StatusForbidden, "unknown role: "+roleName)
return
}
for _, perm := range role.Permissions {
if perm.Module == module && perm.Action == action {
// superadmin is an escape hatch for bootstrap/recovery.
if roleName == "superadmin" {
user := r.Header.Get("X-User-Id")
log.Warn().
Str("user", user).
Str("module", module).
Str("action", action).
Str("path", r.URL.Path).
Str("method", r.Method).
Msg("superadmin bypass used")
next(w, r)
return
}
// "admin" action on a module implies all other actions.
if perm.Module == module && perm.Action == "admin" {
next(w, r)
return
// Look up the role's permissions from the store.
role, err := store.GetRoleByName(roleName)
if err != nil {
continue
}
for _, perm := range role.Permissions {
if perm.Module == module && (perm.Action == action || perm.Action == "admin") {
next(w, r)
return
}
}
}
@@ -301,16 +276,17 @@ func requireRole(store ComplianceStore, module, action string, next http.Handler
}
}
// requireSuperAdmin rejects requests unless the JWT role is "superadmin" or
// "owner". Used for role mutation endpoints to prevent self-escalation (HIGH-3).
// requireSuperAdmin rejects requests unless the JWT contains "superadmin" or
// "owner" in the comma-separated role list. Used for role mutation endpoints
// to prevent self-escalation (HIGH-3).
func requireSuperAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
roleName := r.Header.Get("X-User-Roles")
if roleName != "superadmin" && roleName != "owner" {
writeError(w, http.StatusForbidden, "role mutation requires superadmin or owner role")
rolesHeader := r.Header.Get("X-User-Roles")
if auth.HasRole(rolesHeader, "superadmin") || auth.HasRole(rolesHeader, "owner") {
next(w, r)
return
}
next(w, r)
writeError(w, http.StatusForbidden, "role mutation requires superadmin or owner role")
}
}
+63 -6
View File
@@ -14,8 +14,11 @@ import (
"context"
"fmt"
"net"
"os"
"strings"
"time"
"github.com/luxfi/broker/pkg/auth"
"github.com/luxfi/broker/pkg/marketdata"
"github.com/luxfi/broker/pkg/provider"
"github.com/luxfi/broker/pkg/router"
@@ -25,6 +28,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/reflection"
"google.golang.org/grpc/status"
)
@@ -42,11 +46,12 @@ type Server struct {
// Config holds gRPC server configuration.
type Config struct {
ListenAddr string
Registry *provider.Registry
Router *router.Router
TWAPScheduler *router.TWAPScheduler
Feed *marketdata.Feed
ListenAddr string
IAMEndpoint string
Registry *provider.Registry
Router *router.Router
TWAPScheduler *router.TWAPScheduler
Feed *marketdata.Feed
ArbitrageDetector *marketdata.ArbitrageDetector
}
@@ -57,8 +62,19 @@ func NewServer(cfg Config) (*Server, error) {
return nil, fmt.Errorf("grpc listen: %w", err)
}
iamEndpoint := cfg.IAMEndpoint
if iamEndpoint == "" {
iamEndpoint = os.Getenv("IAM_ENDPOINT")
if iamEndpoint == "" {
iamEndpoint = "http://localhost:8000"
}
}
grpcSrv := ggrpc.NewServer(
ggrpc.UnaryInterceptor(loggingInterceptor),
ggrpc.ChainUnaryInterceptor(
authInterceptor(iamEndpoint),
loggingInterceptor,
),
)
s := &Server{
@@ -473,6 +489,47 @@ func twapToResponse(exec *router.TWAPExecution) *TWAPResponse {
return resp
}
// authInterceptor validates Bearer tokens from gRPC metadata via IAM JWKS.
// Health and reflection RPCs are excluded.
func authInterceptor(iamEndpoint string) ggrpc.UnaryServerInterceptor {
jwksURL := iamEndpoint + "/.well-known/jwks"
return func(ctx context.Context, req interface{}, info *ggrpc.UnaryServerInfo, handler ggrpc.UnaryHandler) (interface{}, error) {
// Skip auth for health checks and reflection.
if strings.HasPrefix(info.FullMethod, "/grpc.health.v1.") ||
strings.HasPrefix(info.FullMethod, "/grpc.reflection.") {
return handler(ctx, req)
}
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return nil, status.Error(codes.Unauthenticated, "missing metadata")
}
vals := md.Get("authorization")
if len(vals) == 0 {
return nil, status.Error(codes.Unauthenticated, "authorization required")
}
token := vals[0]
if !strings.HasPrefix(token, "Bearer ") {
return nil, status.Error(codes.Unauthenticated, "bearer token required")
}
token = strings.TrimPrefix(token, "Bearer ")
claims, err := auth.ValidateJWT(token, jwksURL)
if err != nil {
return nil, status.Error(codes.Unauthenticated, "invalid token")
}
// Propagate identity via context metadata for downstream handlers.
md.Set("x-user-id", auth.ClaimStr(claims, "sub"))
md.Set("x-org-id", auth.ClaimStr(claims, "owner"))
ctx = metadata.NewIncomingContext(ctx, md)
return handler(ctx, req)
}
}
func loggingInterceptor(ctx context.Context, req interface{}, info *ggrpc.UnaryServerInfo, handler ggrpc.UnaryHandler) (interface{}, error) {
start := time.Now()
resp, err := handler(ctx, req)