diff --git a/api/auth_middleware_test.go b/api/auth_middleware_test.go index 36900fc6..98d8d651 100644 --- a/api/auth_middleware_test.go +++ b/api/auth_middleware_test.go @@ -1,10 +1,12 @@ package api import ( + "context" "encoding/base64" "fmt" "io" "net/http/httptest" + "strings" "testing" "api.audius.co/database" @@ -207,14 +209,24 @@ func TestGetApiSignerBasicAuth(t *testing.T) { assert.Contains(t, string(body), "missing Authorization header") }) - t.Run("invalid basic auth format - not basic", func(t *testing.T) { + t.Run("invalid Bearer token", func(t *testing.T) { req := httptest.NewRequest("POST", "/test", nil) req.Header.Set("Authorization", "Bearer invalidtoken") res, err := testApp.Test(req, -1) assert.NoError(t, err) assert.Equal(t, fiber.StatusInternalServerError, res.StatusCode) body, _ := io.ReadAll(res.Body) - assert.Contains(t, string(body), "Authorization header is not Basic Auth") + assert.Contains(t, string(body), "invalid Bearer token") + }) + + t.Run("invalid Basic auth format - not Bearer or Basic", func(t *testing.T) { + req := httptest.NewRequest("POST", "/test", nil) + req.Header.Set("Authorization", "Digest some-credentials") + res, err := testApp.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, fiber.StatusInternalServerError, res.StatusCode) + body, _ := io.ReadAll(res.Body) + assert.Contains(t, string(body), "Authorization must be Bearer or Basic") }) t.Run("invalid private key", func(t *testing.T) { @@ -254,6 +266,77 @@ func TestGetApiSignerBasicAuth(t *testing.T) { }) } +func TestGetApiSignerWithApiAccessKey(t *testing.T) { + app := emptyTestApp(t) + if app.writePool == nil { + t.Skip("writePool required for api_access_key lookup") + } + + ctx := context.Background() + ensureApiKeysTables(t, app, ctx) + + // Same private key as TestGetApiSignerBasicAuth - derives to 0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266 + testPrivateKey := "ac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80" + parentApiKey := "0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266" + apiAccessKey := "test-access-key-123" + + _, err := app.writePool.Exec(ctx, ` + INSERT INTO api_keys (api_key, api_secret, rps, rpm) + VALUES ($1, $2, 10, 500000) + ON CONFLICT (api_key) DO UPDATE SET api_secret = EXCLUDED.api_secret + `, parentApiKey, testPrivateKey) + assert.NoError(t, err) + + _, err = app.writePool.Exec(ctx, ` + INSERT INTO api_access_keys (api_key, api_access_key, is_active) + VALUES ($1, $2, true) + ON CONFLICT (api_key, api_access_key) DO UPDATE SET is_active = true + `, parentApiKey, apiAccessKey) + assert.NoError(t, err) + + testApp := fiber.New() + testApp.Post("/test", func(c *fiber.Ctx) error { + signer, err := app.getApiSigner(c) + if err != nil { + return fiber.NewError(fiber.StatusInternalServerError, err.Error()) + } + return c.JSON(fiber.Map{ + "address": signer.Address, + }) + }) + + req := httptest.NewRequest("POST", "/test", nil) + req.Header.Set("Authorization", "Basic "+encodeBasicAuth("", apiAccessKey)) + res, err := testApp.Test(req, -1) + assert.NoError(t, err) + assert.Equal(t, fiber.StatusOK, res.StatusCode) + body, _ := io.ReadAll(res.Body) + assert.True(t, strings.Contains(strings.ToLower(string(body)), strings.ToLower(parentApiKey)), + "body %s should contain address %s", string(body), parentApiKey) +} + +// ensureApiKeysTables creates api_keys and api_access_keys if they do not exist. +func ensureApiKeysTables(t *testing.T, app *ApiServer, ctx context.Context) { + t.Helper() + _, err := app.writePool.Exec(ctx, ` + CREATE TABLE IF NOT EXISTS api_keys ( + api_key VARCHAR(255) NOT NULL PRIMARY KEY, + api_secret VARCHAR(255), + rps INTEGER NOT NULL DEFAULT 10, + rpm INTEGER NOT NULL DEFAULT 500000, + created_at TIMESTAMP NOT NULL DEFAULT NOW() + ); + CREATE TABLE IF NOT EXISTS api_access_keys ( + api_key VARCHAR(255) NOT NULL, + api_access_key VARCHAR(255) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT NOW(), + is_active BOOLEAN NOT NULL DEFAULT true, + PRIMARY KEY (api_key, api_access_key) + ); + `) + assert.NoError(t, err) +} + // Helper function to encode basic auth credentials func encodeBasicAuth(username, password string) string { auth := username + ":" + password diff --git a/api/dbv1/models.go b/api/dbv1/models.go index 340496ee..7499836d 100644 --- a/api/dbv1/models.go +++ b/api/dbv1/models.go @@ -783,6 +783,21 @@ type AlbumPriceHistory struct { CreatedAt time.Time `json:"created_at"` } +type ApiAccessKey struct { + ApiKey string `json:"api_key"` + ApiAccessKey string `json:"api_access_key"` + CreatedAt time.Time `json:"created_at"` + IsActive bool `json:"is_active"` +} + +type ApiKey struct { + ApiKey string `json:"api_key"` + ApiSecret pgtype.Text `json:"api_secret"` + Rps int32 `json:"rps"` + Rpm int32 `json:"rpm"` + CreatedAt time.Time `json:"created_at"` +} + type ApiMetricsApp struct { Date pgtype.Date `json:"date"` ApiKey string `json:"api_key"` diff --git a/api/frontend_auth.go b/api/frontend_auth.go new file mode 100644 index 00000000..6bf023b4 --- /dev/null +++ b/api/frontend_auth.go @@ -0,0 +1,65 @@ +package api + +import ( + "strings" + + "github.com/ethereum/go-ethereum/crypto" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +// requireFrontendAppAuth returns a middleware that validates Bearer token and checks +// that the given frontend app (identified by its private key secret) has a grant from the user. +// Must run after requireUserIdMiddleware. +func (app *ApiServer) requireFrontendAppAuth(secret string, appName string) fiber.Handler { + return func(c *fiber.Ctx) error { + if secret == "" { + app.logger.Error(appName+" secret not configured", zap.String("app", appName)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": appName + " not configured", + }) + } + + authHeader := c.Get("Authorization") + if authHeader == "" || !strings.HasPrefix(authHeader, "Bearer ") { + return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{ + "error": "Missing or invalid Authorization header. Use Bearer ", + }) + } + token := strings.TrimPrefix(authHeader, "Bearer ") + + pathUserId := app.getUserId(c) + if pathUserId == 0 { + return fiber.NewError(fiber.StatusBadRequest, "invalid userId") + } + + jwtUserId, err := app.validateOAuthJWTTokenToUserId(c.Context(), token) + if err != nil { + return err + } + + if int32(jwtUserId) != pathUserId { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "Token userId does not match path userId", + }) + } + + // Derive app address from private key + privateKey, err := crypto.HexToECDSA(strings.TrimPrefix(secret, "0x")) + if err != nil { + app.logger.Error("Invalid "+appName+" secret", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": appName + " misconfigured", + }) + } + appAddress := strings.ToLower(crypto.PubkeyToAddress(privateKey.PublicKey).Hex()) + + if !app.isAuthorizedRequest(c.Context(), pathUserId, appAddress) { + return c.Status(fiber.StatusForbidden).JSON(fiber.Map{ + "error": "User has not granted the " + strings.ToLower(appName) + " write access. Log in with OAuth scope 'write'.", + }) + } + + return c.Next() + } +} diff --git a/api/metrics_middleware.go b/api/metrics_middleware.go index c76fb035..d9a46904 100644 --- a/api/metrics_middleware.go +++ b/api/metrics_middleware.go @@ -3,6 +3,7 @@ package api import ( "context" "runtime" + "strings" "sync" "time" @@ -96,13 +97,22 @@ func NewMetricsCollector(logger *zap.Logger, writePool *pgxpool.Pool) *MetricsCo return collector } -// Fiber middleware that collects metrics -func (rmc *MetricsCollector) Middleware() fiber.Handler { +// Fiber middleware that collects metrics. Pass apiServer to resolve identifier from Bearer or Basic Auth signer first; if nil or no signer, falls back to api_key/app_name query params. +func (rmc *MetricsCollector) Middleware(apiServer *ApiServer) fiber.Handler { return func(c *fiber.Ctx) error { err := c.Next() - apiKey := c.Query("api_key") - appName := c.Query("app_name") + var apiKey, appName string + if apiServer != nil { + signer, signerErr := apiServer.getApiSigner(c) + if signerErr == nil && signer != nil { + apiKey = fiberutils.CopyString(strings.ToLower(signer.Address)) + } + } + if apiKey == "" && appName == "" { + apiKey = c.Query("api_key") + appName = c.Query("app_name") + } ipAddress := utils.GetIP(c) // Only record if we have some identifier diff --git a/api/rate_limit_middleware.go b/api/rate_limit_middleware.go new file mode 100644 index 00000000..2b2b3764 --- /dev/null +++ b/api/rate_limit_middleware.go @@ -0,0 +1,227 @@ +package api + +import ( + "context" + "strconv" + "strings" + "sync" + "time" + + "api.audius.co/utils" + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/maypok86/otter" + "go.uber.org/zap" +) + +const defaultRPS = 5 + +// rpmMonthCacheTTL - on expiry, next request triggers DB refresh to pick up flushed metrics. +const rpmMonthCacheTTL = 5 * time.Minute + +type apiKeysLimits struct { + // Requests per second + RPS int + // Requests per month + RPM int +} + +// rpsState tracks request timestamps per identifier for sliding window RPS +type rpsState struct { + mu sync.Mutex + data map[string][]int64 +} + +func (r *rpsState) allow(identifier string, limit int, now int64) bool { + r.mu.Lock() + defer r.mu.Unlock() + if r.data == nil { + r.data = make(map[string][]int64) + } + windowStart := now - int64(time.Second) + // Prune old timestamps + timestamps := r.data[identifier] + i := 0 + for _, ts := range timestamps { + if ts > windowStart { + timestamps[i] = ts + i++ + } + } + timestamps = timestamps[:i] + if len(timestamps) >= limit { + return false + } + timestamps = append(timestamps, now) + r.data[identifier] = timestamps + return true +} + +// NewRateLimitMiddleware creates middleware that enforces RPS and RPM (requests per month) from api_keys. +func NewRateLimitMiddleware(logger *zap.Logger, writePool *pgxpool.Pool) *RateLimitMiddleware { + limitsCache, err := otter.MustBuilder[string, apiKeysLimits](50_000). + WithTTL(5 * time.Minute). + CollectStats(). + Build() + if err != nil { + panic(err) + } + rpmMonthCacheVal, err := otter.MustBuilder[string, int64](50_000). + WithTTL(rpmMonthCacheTTL). + Build() + if err != nil { + panic(err) + } + return &RateLimitMiddleware{ + logger: logger.With(zap.String("component", "RateLimitMiddleware")), + writePool: writePool, + limitsCache: &limitsCache, + rpmMonthCache: &rpmMonthCacheVal, + rpmMonthMu: &sync.Mutex{}, + rpmMonthPending: make(map[string]int64), + rpsState: &rpsState{data: make(map[string][]int64)}, + } +} + +// RateLimitMiddleware enforces RPS and RPM (requests per month) from api_keys table. +type RateLimitMiddleware struct { + logger *zap.Logger + writePool *pgxpool.Pool + limitsCache *otter.Cache[string, apiKeysLimits] + rpmMonthCache *otter.Cache[string, int64] // base count from api_metrics_apps at last refresh + rpmMonthMu *sync.Mutex + rpmMonthPending map[string]int64 // identifier -> count allowed since last DB refresh + rpsState *rpsState +} + +// Middleware returns the Fiber handler. Pass apiServer to resolve identifier from Bearer or Basic Auth signer first; if nil or no signer, falls back to api_key/app_name query params. +func (rlm *RateLimitMiddleware) Middleware(apiServer *ApiServer) fiber.Handler { + return func(c *fiber.Ctx) error { + var identifier string + if apiServer != nil { + signer, err := apiServer.getApiSigner(c) + if err == nil && signer != nil { + identifier = strings.ToLower(signer.Address) + } + } + if identifier == "" { + apiKey := c.Query("api_key") + appName := c.Query("app_name") + identifier = apiKey + if identifier == "" { + identifier = appName + } + } + ipAddress := utils.GetIP(c) + + // Resolve rate limits + rps, rpm, useDefault := rlm.getLimits(c.Context(), identifier) + if useDefault { + // Unlisted: RPS only, no RPM; key by IP when no identifier + rps = defaultRPS + if identifier == "" { + identifier = "ip:" + ipAddress + } + } + + now := time.Now().UnixNano() + + // Check RPS + if !rlm.rpsState.allow(identifier, rps, now) { + rlm.logger.Debug("RPS rate limit exceeded", + zap.String("identifier", identifier), + zap.Int("rps", rps)) + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "Rate limit exceeded. Try again later.", + }) + } + + // Check RPM only for apps with an api_key (useDefault=false) + var remainingMonth int64 = -1 // -1 = not applicable (no RPM check) + if !useDefault { + allowed, remaining := rlm.checkRpm(c.Context(), identifier, int64(rpm)) + remainingMonth = remaining + if !allowed { + rlm.logger.Debug("RPM rate limit exceeded (requests per month)", + zap.String("identifier", identifier), + zap.Int("rpm", rpm)) + c.Set("X-RateLimit-Remaining-Month", "0") + c.Set("X-RateLimit-Limit-Month", strconv.Itoa(rpm)) + return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{ + "error": "Rate limit exceeded. Try again later.", + }) + } + } + + if !useDefault && remainingMonth >= 0 { + c.Set("X-RateLimit-Remaining-Month", strconv.FormatInt(remainingMonth, 10)) + c.Set("X-RateLimit-Limit-Month", strconv.FormatInt(int64(rpm), 10)) + } + return c.Next() + } +} + +// checkRpm returns (allowed, remaining). remaining = requests left in the month after this one. +// On cache miss: queries api_metrics_apps and loads into cache. On cache hit: uses +// baseCount + pending and increments pending on allow. +func (rlm *RateLimitMiddleware) checkRpm(ctx context.Context, identifier string, limit int64) (allowed bool, remaining int64) { + if rlm.writePool == nil { + return true, limit + } + baseCount, ok := rlm.rpmMonthCache.Get(identifier) + if !ok { + var dbCount int64 + err := rlm.writePool.QueryRow(ctx, ` + SELECT COALESCE(SUM(request_count), 0) + FROM api_metrics_apps + WHERE (LOWER(api_key) = LOWER($1) OR LOWER(app_name) = LOWER($1)) + AND date >= CURRENT_DATE - INTERVAL '30 days' + `, identifier).Scan(&dbCount) + if err != nil { + rlm.logger.Debug("Failed to get monthly request count", zap.String("identifier", identifier), zap.Error(err)) + return true, limit + } + baseCount = dbCount + rlm.rpmMonthCache.Set(identifier, baseCount) + rlm.rpmMonthMu.Lock() + rlm.rpmMonthPending[identifier] = 0 + rlm.rpmMonthMu.Unlock() + } + + rlm.rpmMonthMu.Lock() + pending := rlm.rpmMonthPending[identifier] + total := baseCount + pending + if total >= limit { + rlm.rpmMonthMu.Unlock() + return false, 0 + } + rlm.rpmMonthPending[identifier] = pending + 1 + rlm.rpmMonthMu.Unlock() + // remaining = limit - count after this request + return true, limit - total - 1 +} + +// getLimits returns rps, rpm (requests per month), and useDefault (true if identifier not in api_keys). +func (rlm *RateLimitMiddleware) getLimits(ctx context.Context, identifier string) (rps, rpm int, useDefault bool) { + if identifier == "" { + return 0, 0, true + } + if hit, ok := rlm.limitsCache.Get(identifier); ok { + return hit.RPS, hit.RPM, false + } + if rlm.writePool == nil { + return 0, 0, true + } + var rpsVal, rpmVal int + err := rlm.writePool.QueryRow(ctx, ` + SELECT COALESCE(rps, 10), COALESCE(rpm, 500000) + FROM api_keys + WHERE LOWER(api_key) = LOWER($1) + `, identifier).Scan(&rpsVal, &rpmVal) + if err == pgx.ErrNoRows || err != nil { + return 0, 0, true + } + rlm.limitsCache.Set(identifier, apiKeysLimits{RPS: rpsVal, RPM: rpmVal}) + return rpsVal, rpmVal, false +} diff --git a/api/rate_limit_middleware_test.go b/api/rate_limit_middleware_test.go new file mode 100644 index 00000000..8d2b3718 --- /dev/null +++ b/api/rate_limit_middleware_test.go @@ -0,0 +1,46 @@ +package api + +import ( + "context" + "net/http/httptest" + "testing" + + "api.audius.co/config" + "api.audius.co/database" + "api.audius.co/logging" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" +) + +func TestRateLimitMiddleware(t *testing.T) { + pool := database.CreateTestDatabase(t, "test_api") + ctx := context.Background() + + _, err := pool.Exec(ctx, ` + INSERT INTO api_keys (api_key, api_secret, rps, rpm) + VALUES ('rate-test-key', NULL, 1, 2) + ON CONFLICT (api_key) DO UPDATE SET rps = 1, rpm = 2 + `) + assert.NoError(t, err) + + logger := logging.NewZapLogger(config.Config{}).With() + rlm := NewRateLimitMiddleware(logger, pool) + + testApp := fiber.New() + testApp.Use(rlm.Middleware(nil)) + testApp.Get("/test", func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + // First request should succeed + req1 := httptest.NewRequest("GET", "/test?api_key=rate-test-key", nil) + res1, err := testApp.Test(req1, -1) + assert.NoError(t, err) + assert.Equal(t, fiber.StatusOK, res1.StatusCode, "first request should succeed") + + // Second request within same second should be rate limited (rps=1) + req2 := httptest.NewRequest("GET", "/test?api_key=rate-test-key", nil) + res2, err := testApp.Test(req2, -1) + assert.NoError(t, err) + assert.Equal(t, fiber.StatusTooManyRequests, res2.StatusCode, "second request should be rate limited") +} diff --git a/api/request_helpers.go b/api/request_helpers.go index 54e5ead3..aca6266d 100644 --- a/api/request_helpers.go +++ b/api/request_helpers.go @@ -1,6 +1,7 @@ package api import ( + "context" "crypto/ecdsa" "encoding/base64" "fmt" @@ -9,9 +10,16 @@ import ( "github.com/ethereum/go-ethereum/crypto" "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgtype" ) +// apiAccessKeySignerEntry caches the result of api_access_key -> (api_key, api_secret) lookup +type apiAccessKeySignerEntry struct { + ApiKey string + ApiSecret string +} + // Signer holds the address, public key, and private key for signing transactions type Signer struct { UserId int @@ -30,53 +38,113 @@ func getOptionalBool(c *fiber.Ctx, key string) (pgtype.Bool, error) { return pgtype.Bool{}, nil } -// getApiSigner extracts a private key from the Basic Auth header and returns a Signer -// The Basic Auth username is ignored, and the password should contain the private key hex string (without 0x prefix) +// getApiSigner extracts a signer from the Authorization header. +// Supports Bearer token and Basic auth. In both cases, the credential is checked +// as an api_access_key first; for Basic auth, a raw private key hex is also accepted. func (app *ApiServer) getApiSigner(c *fiber.Ctx) (*Signer, error) { authHeader := c.Get("Authorization") if authHeader == "" { return nil, fmt.Errorf("missing Authorization header") } - // Check if it's a Basic Auth header + // Bearer: extract token and look up as api_access_key + if strings.HasPrefix(authHeader, "Bearer ") { + token := strings.TrimSpace(strings.TrimPrefix(authHeader, "Bearer ")) + if token == "" { + return nil, fmt.Errorf("Bearer token is empty") + } + if app.writePool != nil { + if signer := app.getSignerFromApiAccessKey(c.Context(), token); signer != nil { + return signer, nil + } + } + return nil, fmt.Errorf("invalid Bearer token") + } + + // Basic: decode credentials and use password as api_access_key or private key if !strings.HasPrefix(authHeader, "Basic ") { - return nil, fmt.Errorf("Authorization header is not Basic Auth") + return nil, fmt.Errorf("Authorization must be Bearer or Basic") } - // Decode the base64 encoded credentials encodedCreds := strings.TrimPrefix(authHeader, "Basic ") decodedBytes, err := base64.StdEncoding.DecodeString(encodedCreds) if err != nil { return nil, fmt.Errorf("failed to decode Basic Auth credentials: %w", err) } - // Split username:password creds := string(decodedBytes) parts := strings.SplitN(creds, ":", 2) - if len(parts) != 2 { - return nil, fmt.Errorf("invalid Basic Auth format") + var password string + if len(parts) == 2 { + password = strings.TrimSpace(parts[1]) + } else { + password = strings.TrimSpace(creds) } + password = strings.TrimPrefix(password, "0x") - userId, err := strconv.Atoi(parts[0]) - if err != nil { - return nil, fmt.Errorf("invalid userId: %w", err) + // Try api_access_key lookup (also try raw encoded value for clients that send it un-encoded) + if app.writePool != nil { + for _, candidate := range []string{password, encodedCreds} { + if candidate == "" { + continue + } + if signer := app.getSignerFromApiAccessKey(c.Context(), candidate); signer != nil { + return signer, nil + } + } + } + if password == "" { + return nil, fmt.Errorf("invalid Basic Auth format") } - // The private key is in the password field (parts[1]) - privateKeyHex := strings.TrimPrefix(parts[1], "0x") - - // Parse the private key - privateKey, err := crypto.HexToECDSA(privateKeyHex) + // Fallback: treat password as raw private key hex + privateKey, err := crypto.HexToECDSA(password) if err != nil { return nil, fmt.Errorf("failed to parse private key: %w", err) } - - // Derive the public key and address from the private key address := crypto.PubkeyToAddress(privateKey.PublicKey) - return &Signer{ - UserId: userId, Address: address.Hex(), PrivateKey: privateKey, }, nil } + +// getSignerFromApiAccessKey looks up api_access_keys and api_keys to build a Signer. +func (app *ApiServer) getSignerFromApiAccessKey(ctx context.Context, apiAccessKey string) *Signer { + if hit, ok := app.apiAccessKeySignerCache.Get(apiAccessKey); ok { + privateKey, err := crypto.HexToECDSA(strings.TrimPrefix(hit.ApiSecret, "0x")) + if err != nil { + return nil + } + return &Signer{ + Address: strings.ToLower(hit.ApiKey), + PrivateKey: privateKey, + } + } + + var parentApiKey, apiSecret string + err := app.writePool.QueryRow(ctx, ` + SELECT aak.api_key, ak.api_secret + FROM api_access_keys aak + JOIN api_keys ak ON LOWER(ak.api_key) = LOWER(aak.api_key) + WHERE aak.api_access_key = $1 AND aak.is_active = true + `, apiAccessKey).Scan(&parentApiKey, &apiSecret) + if err == pgx.ErrNoRows || err != nil || apiSecret == "" { + return nil + } + + parentApiKeyLower := strings.ToLower(parentApiKey) + app.apiAccessKeySignerCache.Set(apiAccessKey, apiAccessKeySignerEntry{ + ApiKey: parentApiKeyLower, + ApiSecret: apiSecret, + }) + + privateKey, err := crypto.HexToECDSA(strings.TrimPrefix(apiSecret, "0x")) + if err != nil { + return nil + } + return &Signer{ + Address: parentApiKeyLower, + PrivateKey: privateKey, + } +} diff --git a/api/server.go b/api/server.go index cc7004bc..da0a82d1 100644 --- a/api/server.go +++ b/api/server.go @@ -124,6 +124,14 @@ func NewApiServer(config config.Config) *ApiServer { panic(err) } + apiAccessKeySignerCache, err := otter.MustBuilder[string, apiAccessKeySignerEntry](10_000). + WithTTL(5 * time.Minute). + CollectStats(). + Build() + if err != nil { + panic(err) + } + privateKey, err := crypto.HexToECDSA(config.DelegatePrivateKey) if err != nil { panic(err) @@ -194,8 +202,10 @@ func NewApiServer(config config.Config) *ApiServer { // Initialize metrics collector if writePool is available var metricsCollector *MetricsCollector + var rateLimitMiddleware *RateLimitMiddleware if writePool != nil && config.Env != "test" { metricsCollector = NewMetricsCollector(logger, writePool) + rateLimitMiddleware = NewRateLimitMiddleware(logger, writePool) } commsRpcProcessor, err := comms.NewProcessor(pool, writePool, &config, logger) @@ -211,34 +221,36 @@ func NewApiServer(config config.Config) *ApiServer { ReadBufferSize: 32_768, UnescapePath: true, }), - config: &config, - commsRpcProcessor: commsRpcProcessor, - env: config.Env, - audiusAppUrl: config.AudiusAppUrl, - skipAuthCheck: skipAuthCheck, - pool: pool, - writePool: writePool, - queries: dbv1.New(pool), - logger: logger, - esClient: esClient, - started: time.Now(), - resolveHandleCache: &resolveHandleCache, - resolveGrantCache: &resolveGrantCache, - resolveWalletCache: &resolveWalletCache, - requestValidator: requestValidator, - rewardAttester: rewardAttester, - transactionSender: transactionSender, - rewardManagerClient: rewardManagerClient, - claimableTokensClient: claimableTokensClient, - solanaConfig: &config.SolanaConfig, - antiAbuseOracles: config.AntiAbuseOracles, - validators: NewNodes(), - openAudioSDK: openAudioSDK, - openAudioPool: openAudioPool, - metricsCollector: metricsCollector, - birdeyeClient: birdeye.New(config.BirdeyeToken), - solanaRpcClient: solanaRpc, - meteoraDbcClient: meteoraDbcClient, + config: &config, + commsRpcProcessor: commsRpcProcessor, + env: config.Env, + audiusAppUrl: config.AudiusAppUrl, + skipAuthCheck: skipAuthCheck, + pool: pool, + writePool: writePool, + queries: dbv1.New(pool), + logger: logger, + esClient: esClient, + started: time.Now(), + resolveHandleCache: &resolveHandleCache, + resolveGrantCache: &resolveGrantCache, + resolveWalletCache: &resolveWalletCache, + apiAccessKeySignerCache: &apiAccessKeySignerCache, + requestValidator: requestValidator, + rewardAttester: rewardAttester, + transactionSender: transactionSender, + rewardManagerClient: rewardManagerClient, + claimableTokensClient: claimableTokensClient, + solanaConfig: &config.SolanaConfig, + antiAbuseOracles: config.AntiAbuseOracles, + validators: NewNodes(), + openAudioSDK: openAudioSDK, + openAudioPool: openAudioPool, + metricsCollector: metricsCollector, + rateLimitMiddleware: rateLimitMiddleware, + birdeyeClient: birdeye.New(config.BirdeyeToken), + solanaRpcClient: solanaRpc, + meteoraDbcClient: meteoraDbcClient, } // Set up a custom decoder for HashIds so they can be parsed in lists @@ -276,7 +288,11 @@ func NewApiServer(config config.Config) *ApiServer { // Add request metrics middleware if available if app.metricsCollector != nil { - app.Use(app.metricsCollector.Middleware()) + app.Use(app.metricsCollector.Middleware(app)) + } + // Add rate limit middleware after metrics, before auth + if app.rateLimitMiddleware != nil { + app.Use(app.rateLimitMiddleware.Middleware(app)) } app.Use(fiberzap.New(fiberzap.Config{ Logger: logger, @@ -417,6 +433,12 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/users/:userId/authorized-apps", app.v1UsersAuthorizedApps) g.Get("/users/:userId/developer_apps", app.v1UsersDeveloperApps) g.Get("/users/:userId/developer-apps", app.v1UsersDeveloperApps) + frontendAuth := app.requireFrontendAppAuth(config.AudiusApiSecret, "Plans app") + g.Post("/users/:userId/developer_apps", frontendAuth, app.postV1UsersDeveloperAppCreate) + g.Post("/users/:userId/developer-apps", frontendAuth, app.postV1UsersDeveloperAppCreate) + g.Delete("/users/:userId/developer-apps/:address", frontendAuth, app.deleteV1UsersDeveloperApp) + g.Post("/users/:userId/developer-apps/:address/access-keys/deactivate", frontendAuth, app.postV1UsersDeveloperAppAccessKeyDeactivate) + g.Post("/users/:userId/developer-apps/:address/access-keys", frontendAuth, app.postV1UsersDeveloperAppAccessKeyCreate) g.Get("/users/:userId/withdrawals/download", app.requireAuthForUserId, app.v1UsersWithdrawalsDownloadCsv) g.Get("/users/:userId/withdrawals/download/json", app.requireAuthForUserId, app.v1UsersWithdrawalsDownloadJson) g.Post("/users/:userId/follow", app.requireAuthMiddleware, app.postV1UserFollow) @@ -657,6 +679,9 @@ func NewApiServer(config config.Config) *ApiServer { // Plans React app - serve static assets first, then SPA routing app.Static("/plans/assets", "./static/plans/dist/assets") + app.Get("/plans/favicon.ico", func(c *fiber.Ctx) error { + return c.SendFile("./static/plans/dist/favicon.ico") + }) app.Get("/plans/*", app.servePlans) app.Static("/", "./static") @@ -703,34 +728,36 @@ type BirdeyeClient interface { type ApiServer struct { *fiber.App - config *config.Config - commsRpcProcessor *comms.RPCProcessor - pool *dbv1.DBPools - writePool *pgxpool.Pool - queries *dbv1.Queries - esClient *elasticsearch.Client - logger *zap.Logger - started time.Time - resolveHandleCache *otter.Cache[string, int32] - resolveGrantCache *otter.Cache[string, bool] - resolveWalletCache *otter.Cache[string, int] - requestValidator *RequestValidator - rewardManagerClient *reward_manager.RewardManagerClient - claimableTokensClient *claimable_tokens.ClaimableTokensClient - rewardAttester *rewards.RewardAttester - transactionSender *spl.TransactionSender - solanaConfig *config.SolanaConfig - antiAbuseOracles []string - env string - openAudioSDK *sdk.OpenAudioSDK - audiusAppUrl string - skipAuthCheck bool // set to true in a test if you don't care about auth middleware - metricsCollector *MetricsCollector - birdeyeClient BirdeyeClient - solanaRpcClient *rpc.Client - meteoraDbcClient *meteora_dbc.Client - validators *Nodes - openAudioPool *OpenAudioPool + config *config.Config + commsRpcProcessor *comms.RPCProcessor + pool *dbv1.DBPools + writePool *pgxpool.Pool + queries *dbv1.Queries + esClient *elasticsearch.Client + logger *zap.Logger + started time.Time + resolveHandleCache *otter.Cache[string, int32] + resolveGrantCache *otter.Cache[string, bool] + resolveWalletCache *otter.Cache[string, int] + apiAccessKeySignerCache *otter.Cache[string, apiAccessKeySignerEntry] + requestValidator *RequestValidator + rewardManagerClient *reward_manager.RewardManagerClient + claimableTokensClient *claimable_tokens.ClaimableTokensClient + rewardAttester *rewards.RewardAttester + transactionSender *spl.TransactionSender + solanaConfig *config.SolanaConfig + antiAbuseOracles []string + env string + openAudioSDK *sdk.OpenAudioSDK + audiusAppUrl string + skipAuthCheck bool // set to true in a test if you don't care about auth middleware + metricsCollector *MetricsCollector + rateLimitMiddleware *RateLimitMiddleware + birdeyeClient BirdeyeClient + solanaRpcClient *rpc.Client + meteoraDbcClient *meteora_dbc.Client + validators *Nodes + openAudioPool *OpenAudioPool } func (app *ApiServer) home(c *fiber.Ctx) error { diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 0d22ebae..17a54e9e 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -154,6 +154,7 @@ paths: operationId: Create Comment security: - BasicAuth: [] + - BearerAuth: [] requestBody: required: true content: @@ -206,6 +207,7 @@ paths: operationId: Update Comment security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: comment_id in: path @@ -247,6 +249,7 @@ paths: operationId: Delete Comment security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: comment_id in: path @@ -278,6 +281,7 @@ paths: operationId: React to Comment security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: comment_id in: path @@ -308,6 +312,7 @@ paths: operationId: Unreact to Comment security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: comment_id in: path @@ -339,6 +344,7 @@ paths: operationId: Pin Comment security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: comment_id in: path @@ -369,6 +375,7 @@ paths: operationId: Unpin Comment security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: comment_id in: path @@ -400,6 +407,7 @@ paths: operationId: Report Comment security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: comment_id in: path @@ -473,6 +481,7 @@ paths: operationId: Create Developer App security: - BasicAuth: [] + - BearerAuth: [] requestBody: required: true content: @@ -531,6 +540,7 @@ paths: operationId: Update Developer App security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: address in: path @@ -570,6 +580,7 @@ paths: operationId: Delete Developer App security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: address in: path @@ -899,6 +910,7 @@ paths: operationId: Create Playlist security: - BasicAuth: [] + - BearerAuth: [] requestBody: required: true content: @@ -1124,6 +1136,7 @@ paths: operationId: Update Playlist security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: playlist_id in: path @@ -1163,6 +1176,7 @@ paths: operationId: Delete Playlist security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: playlist_id in: path @@ -1246,6 +1260,7 @@ paths: operationId: Favorite Playlist security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: playlist_id in: path @@ -1276,6 +1291,7 @@ paths: operationId: Unfavorite Playlist security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: playlist_id in: path @@ -1307,6 +1323,7 @@ paths: operationId: Repost Playlist security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: playlist_id in: path @@ -1337,6 +1354,7 @@ paths: operationId: Unrepost Playlist security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: playlist_id in: path @@ -1368,6 +1386,7 @@ paths: operationId: Share Playlist security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: playlist_id in: path @@ -1557,6 +1576,7 @@ paths: operationId: Create Track security: - BasicAuth: [] + - BearerAuth: [] requestBody: required: true content: @@ -2367,6 +2387,7 @@ paths: operationId: Update Track security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: track_id in: path @@ -2406,6 +2427,7 @@ paths: operationId: Delete Track security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: track_id in: path @@ -2618,6 +2640,7 @@ paths: operationId: Favorite Track security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: track_id in: path @@ -2648,6 +2671,7 @@ paths: operationId: Unfavorite Track security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: track_id in: path @@ -2812,6 +2836,7 @@ paths: operationId: Repost Track security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: track_id in: path @@ -2842,6 +2867,7 @@ paths: operationId: Unrepost Track security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: track_id in: path @@ -2987,6 +3013,7 @@ paths: operationId: Share Track security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: track_id in: path @@ -3018,6 +3045,7 @@ paths: operationId: Record Track Download security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: track_id in: path @@ -3226,6 +3254,7 @@ paths: operationId: Create User security: - BasicAuth: [] + - BearerAuth: [] requestBody: required: true content: @@ -3696,6 +3725,7 @@ paths: operationId: Update User security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: id in: path @@ -3754,6 +3784,224 @@ paths: "500": description: Server error content: {} + /users/{id}/developer-apps: + get: + tags: + - users + - developer_apps + description: Get developer apps for the user (Plans API). Requires OAuth. + operationId: Get User Developer Apps + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + - name: include + in: query + description: Include metrics when set to "metrics" + schema: + type: string + enum: + - metrics + responses: + "200": + description: Success + content: + application/json: + schema: + type: object + properties: + data: + type: array + items: + $ref: '#/components/schemas/developer_app' + "400": + description: Bad request + content: {} + "500": + description: Server error + content: {} + post: + tags: + - users + - developer_apps + description: Create a new developer app (Plans API). Requires OAuth Bearer token with plans app grant. + operationId: Create User Developer App + security: + - BasicAuth: [] + - BearerAuth: [] + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/create_user_developer_app_request' + responses: + "200": + description: Developer app created successfully + content: + application/json: + schema: + type: object + properties: + api_key: + type: string + api_secret: + type: string + transaction_hash: + type: string + "400": + description: Bad request + content: {} + "401": + description: Unauthorized + content: {} + "500": + description: Server error + content: {} + /users/{id}/developer-apps/{address}: + delete: + tags: + - users + - developer_apps + description: Delete a developer app (Plans API). Requires OAuth Bearer token with plans app grant. + operationId: Delete User Developer App + security: + - BasicAuth: [] + - BearerAuth: [] + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + - name: address + in: path + description: Developer app address (API Key) + required: true + schema: + type: string + responses: + "200": + description: Developer app deleted successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + transaction_hash: + type: string + "401": + description: Unauthorized + content: {} + "404": + description: Developer app not found + content: {} + "500": + description: Server error + content: {} + /users/{id}/developer-apps/{address}/access-keys/deactivate: + post: + tags: + - users + - developer_apps + description: Deactivate a bearer token (API access key) for a developer app (Plans API). Requires OAuth Bearer token with plans app grant. The deactivated token will no longer authenticate requests. + operationId: Deactivate User Developer App Access Key + security: + - BasicAuth: [] + - BearerAuth: [] + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + - name: address + in: path + description: Developer app address (API Key) + required: true + schema: + type: string + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/deactivate_access_key_request' + responses: + "200": + description: Access key deactivated successfully + content: + application/json: + schema: + type: object + properties: + success: + type: boolean + "400": + description: Bad request (api_access_key required) + content: {} + "401": + description: Unauthorized + content: {} + "404": + description: Developer app or access key not found + content: {} + "500": + description: Server error + content: {} + /users/{id}/developer-apps/{address}/access-keys: + post: + tags: + - users + - developer_apps + description: Create a new bearer token (API access key) for a developer app (Plans API). Requires OAuth Bearer token with plans app grant. + operationId: Create User Developer App Access Key + security: + - BasicAuth: [] + - BearerAuth: [] + parameters: + - name: id + in: path + description: User ID + required: true + schema: + type: string + - name: address + in: path + description: Developer app address (API Key) + required: true + schema: + type: string + responses: + "200": + description: Access key created successfully + content: + application/json: + schema: + $ref: '#/components/schemas/create_access_key_response' + "401": + description: Unauthorized + content: {} + "404": + description: Developer app not found + content: {} + "500": + description: Server error + content: {} /users/{id}/balance/history: get: tags: @@ -4128,6 +4376,7 @@ paths: operationId: Follow User security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: id in: path @@ -4158,6 +4407,7 @@ paths: operationId: Unfollow User security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: id in: path @@ -4387,6 +4637,7 @@ paths: operationId: Mute User security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: id in: path @@ -4417,6 +4668,7 @@ paths: operationId: Unmute User security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: id in: path @@ -4993,6 +5245,7 @@ paths: operationId: Subscribe to User security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: id in: path @@ -5023,6 +5276,7 @@ paths: operationId: Unsubscribe from User security: - BasicAuth: [] + - BearerAuth: [] parameters: - name: id in: path @@ -9726,6 +9980,31 @@ components: imageUrl: type: string description: App logo/image URL (camelCase) + create_user_developer_app_request: + type: object + required: + - name + properties: + name: + type: string + description: Developer app name (Plans API create) + example: "My API Key" + deactivate_access_key_request: + type: object + required: + - api_access_key + properties: + api_access_key: + type: string + description: The bearer token (API access key) to deactivate + create_access_key_response: + type: object + required: + - api_access_key + properties: + api_access_key: + type: string + description: The newly created bearer token (API access key) responses: ParseError: description: When a mask can't be parsed @@ -9765,4 +10044,11 @@ components: The derived wallet address must be either: - The wallet of the user being acted upon (direct ownership) - A wallet with an approved, non-revoked grant for the user (manager mode) + BearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + OAuth JWT Bearer token for Plans API. Used for user developer app create/delete. + Obtain via OAuth flow with write scope. The plans app must have a grant from the user. x-original-swagger-version: "2.0" diff --git a/api/v1_users_developer_apps.go b/api/v1_users_developer_apps.go index 647ae526..d68c5b86 100644 --- a/api/v1_users_developer_apps.go +++ b/api/v1_users_developer_apps.go @@ -1,9 +1,25 @@ package api import ( + "context" + "crypto/ecdsa" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "api.audius.co/indexer" "api.audius.co/trashid" + corev1 "github.com/OpenAudio/go-openaudio/pkg/api/core/v1" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" "github.com/gofiber/fiber/v2" "github.com/jackc/pgx/v5" + "go.uber.org/zap" ) type DeveloperApp struct { @@ -15,13 +31,30 @@ type DeveloperApp struct { } type DeveloperAppWithMetrics struct { - Address string `json:"address" db:"address"` - UserId trashid.HashId `json:"user_id" db:"user_id"` - Name string `json:"name" db:"name"` - Description *string `json:"description" db:"description"` - ImageUrl *string `json:"image_url" db:"image_url"` - RequestCount int64 `json:"request_count" db:"request_count"` - RequestCountAllTime int64 `json:"request_count_all_time" db:"request_count_all_time"` + Address string `json:"address" db:"address"` + UserId trashid.HashId `json:"user_id" db:"user_id"` + Name string `json:"name" db:"name"` + Description *string `json:"description" db:"description"` + ImageUrl *string `json:"image_url" db:"image_url"` + RequestCount int64 `json:"request_count" db:"request_count"` + RequestCountAllTime int64 `json:"request_count_all_time" db:"request_count_all_time"` + IsLegacy bool `json:"is_legacy" db:"is_legacy"` + APIAccessKeys json.RawMessage `json:"api_access_keys" db:"api_access_keys"` +} + +// registerDeveloperAppRoutes adds developer app routes to the given router. +// The secret and appName identify the frontend app that must have OAuth write grant from the user +// to access the mutating endpoints (create, delete, manage access keys). +func (app *ApiServer) registerDeveloperAppRoutes(g fiber.Router, secret string, appName string) { + auth := app.requireFrontendAppAuth(secret, appName) + + g.Get("/users/:userId/developer_apps", app.v1UsersDeveloperApps) + g.Get("/users/:userId/developer-apps", app.v1UsersDeveloperApps) + g.Post("/users/:userId/developer_apps", auth, app.postV1UsersDeveloperAppCreate) + g.Post("/users/:userId/developer-apps", auth, app.postV1UsersDeveloperAppCreate) + g.Delete("/users/:userId/developer-apps/:address", auth, app.deleteV1UsersDeveloperApp) + g.Post("/users/:userId/developer-apps/:address/access-keys/deactivate", auth, app.postV1UsersDeveloperAppAccessKeyDeactivate) + g.Post("/users/:userId/developer-apps/:address/access-keys", auth, app.postV1UsersDeveloperAppAccessKeyCreate) } func (app *ApiServer) v1UsersDeveloperApps(c *fiber.Ctx) error { @@ -59,16 +92,26 @@ func (app *ApiServer) v1UsersDeveloperApps(c *fiber.Ctx) error { func (app *ApiServer) v1UsersDeveloperAppsWithMetrics(c *fiber.Ctx, userId int32) error { sql := ` - SELECT + SELECT da.address, da.user_id, da.name, da.description, da.image_url, COALESCE(SUM(ama.request_count) FILTER (WHERE ama.date >= DATE_TRUNC('month', CURRENT_DATE)::date AND ama.date <= CURRENT_DATE), 0)::bigint AS request_count, - COALESCE(SUM(ama.request_count), 0)::bigint AS request_count_all_time + COALESCE(SUM(ama.request_count), 0)::bigint AS request_count_all_time, + NOT EXISTS ( + SELECT 1 FROM api_access_keys aak + WHERE LOWER(aak.api_key) = LOWER(da.address) AND aak.is_active = true + ) AS is_legacy, + COALESCE( + (SELECT json_agg(json_build_object('api_access_key', aak.api_access_key, 'is_active', aak.is_active)) + FROM api_access_keys aak + WHERE LOWER(aak.api_key) = LOWER(da.address) AND aak.is_active = true), + '[]'::json + ) AS api_access_keys FROM developer_apps da - LEFT JOIN api_metrics_apps ama ON ama.api_key = da.address + LEFT JOIN api_metrics_apps ama ON LOWER(ama.api_key) = LOWER(da.address) WHERE da.user_id = @userId AND da.is_current = true AND da.is_delete = false @@ -91,3 +134,454 @@ func (app *ApiServer) v1UsersDeveloperAppsWithMetrics(c *fiber.Ctx, userId int32 "data": apps, }) } + +// validateOAuthJWTTokenToUserId validates the OAuth JWT and returns the userId from the payload. +func (app *ApiServer) validateOAuthJWTTokenToUserId(ctx context.Context, token string) (trashid.HashId, error) { + tokenParts := strings.Split(token, ".") + if len(tokenParts) != 3 { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid JWT token format") + } + + base64Header := tokenParts[0] + base64Payload := tokenParts[1] + base64Signature := tokenParts[2] + + paddedSignature := base64Signature + if len(paddedSignature)%4 != 0 { + paddedSignature += strings.Repeat("=", 4-len(paddedSignature)%4) + } + signatureDecoded, err := base64.URLEncoding.DecodeString(paddedSignature) + if err != nil { + return 0, fiber.NewError(fiber.StatusBadRequest, "The JWT signature could not be decoded") + } + signatureHex := string(signatureDecoded) + signatureBytes := common.FromHex(signatureHex) + + message := fmt.Sprintf("%s.%s", base64Header, base64Payload) + encodedToRecover := []byte(message) + prefixedMessage := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(encodedToRecover), encodedToRecover)) + finalHash := crypto.Keccak256Hash(prefixedMessage) + + if len(signatureBytes) != 65 { + return 0, fiber.NewError(fiber.StatusBadRequest, "The JWT signature was incorrectly signed") + } + if signatureBytes[64] >= 27 { + signatureBytes[64] -= 27 + } + publicKey, err := crypto.SigToPub(finalHash.Bytes(), signatureBytes) + if err != nil { + return 0, fiber.NewError(fiber.StatusUnauthorized, "The JWT signature is invalid") + } + recoveredAddr := crypto.PubkeyToAddress(*publicKey) + walletLower := strings.ToLower(recoveredAddr.Hex()) + + paddedPayload := base64Payload + if len(paddedPayload)%4 != 0 { + paddedPayload += strings.Repeat("=", 4-len(paddedPayload)%4) + } + stringifiedPayload, err := base64.URLEncoding.DecodeString(paddedPayload) + if err != nil { + return 0, fiber.NewError(fiber.StatusBadRequest, "JWT payload could not be decoded") + } + var payload map[string]interface{} + if err := json.Unmarshal(stringifiedPayload, &payload); err != nil { + return 0, fiber.NewError(fiber.StatusBadRequest, "JWT payload could not be unmarshalled") + } + + userIdInterface, exists := payload["userId"] + if !exists { + return 0, fiber.NewError(fiber.StatusBadRequest, "JWT payload missing userId field") + } + userIdStr, ok := userIdInterface.(string) + if !ok { + return 0, fiber.NewError(fiber.StatusBadRequest, "JWT payload userId must be a string") + } + jwtUserId, err := trashid.DecodeHashId(userIdStr) + if err != nil { + return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid JWT payload userId") + } + + walletUserId, err := app.queries.GetUserForWallet(ctx, walletLower) + if err != nil { + if err == pgx.ErrNoRows { + return 0, fiber.NewError(fiber.StatusUnauthorized, "The JWT signature is invalid - invalid wallet") + } + return 0, err + } + + if int32(walletUserId) != int32(jwtUserId) { + isManager, err := app.isActiveManager(ctx, int32(jwtUserId), int32(walletUserId)) + if err != nil { + return 0, err + } + if !isManager { + return 0, fiber.NewError(fiber.StatusForbidden, "The JWT signature is invalid - the wallet does not match the user") + } + } + + return trashid.HashId(jwtUserId), nil +} + +type createDeveloperAppBody struct { + Name string `json:"name"` +} + +func (app *ApiServer) postV1UsersDeveloperAppCreate(c *fiber.Ctx) error { + userID := app.getUserId(c) + + var body createDeveloperAppBody + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body", + }) + } + name := strings.TrimSpace(body.Name) + if name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "name is required", + }) + } + + if app.writePool == nil { + app.logger.Error("Write pool not configured") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Database write not available", + }) + } + + // Generate ECDSA keypair for the new app + privateKey, err := ecdsa.GenerateKey(crypto.S256(), rand.Reader) + if err != nil { + app.logger.Error("Failed to generate keypair", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + address := strings.ToLower(crypto.PubkeyToAddress(privateKey.PublicKey).Hex()) + apiSecretHex := hex.EncodeToString(privateKey.D.Bytes()) + + // Insert into api_keys + _, err = app.writePool.Exec(c.Context(), ` + INSERT INTO api_keys (api_key, api_secret, rps, rpm) + VALUES ($1, $2, 10, 500000) + ON CONFLICT (api_key) DO UPDATE SET api_secret = EXCLUDED.api_secret + `, address, apiSecretHex) + if err != nil { + app.logger.Error("Failed to insert api_keys", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + + // Build app_signature for ManageEntity (Ethereum personal sign format so indexer can recover address) + unixTs := strconv.FormatInt(time.Now().Unix(), 10) + message := "Creating Audius developer app at " + unixTs + prefixedMessage := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message)) + hash := crypto.Keccak256Hash(prefixedMessage) + signature, err := crypto.Sign(hash.Bytes(), privateKey) + if err != nil { + app.logger.Error("Failed to sign app message", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + signatureHex := hex.EncodeToString(signature) + + metadataObj := map[string]interface{}{ + "address": strings.ToLower(address), + "name": name, + "description": "", + "image_url": "", + "app_signature": map[string]interface{}{ + "message": message, + "signature": signatureHex, + }, + } + metadataBytes, _ := json.Marshal(metadataObj) + + // Sign the ManageEntity tx with the plans app (which has a grant from the user). + // The indexer's validate_signer requires the signer to be the user or an authorized grantee. + // The app_signature in metadata proves the new app controls its address. + plansSecret := app.config.AudiusApiSecret + if plansSecret == "" { + app.logger.Error("audiusApiSecret required for developer app create") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Plans app not configured", + }) + } + plansKey, err := crypto.HexToECDSA(strings.TrimPrefix(plansSecret, "0x")) + if err != nil { + app.logger.Error("Invalid audiusApiSecret", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Plans app misconfigured", + }) + } + plansAddress := strings.ToLower(crypto.PubkeyToAddress(plansKey.PublicKey).Hex()) + + nonce := time.Now().UnixNano() + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(plansAddress).String(), + UserId: int64(userID), + EntityId: 0, + Action: indexer.Action_Create, + EntityType: indexer.Entity_DeveloperApp, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: string(metadataBytes), + } + + response, err := app.sendTransactionWithSigner(manageEntityTx, plansKey) + if err != nil { + app.logger.Error("Failed to send developer app create transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + + // Generate api_access_key (random base64 for Basic Auth) + apiAccessKeyBytes := make([]byte, 32) + if _, err := rand.Read(apiAccessKeyBytes); err != nil { + app.logger.Error("Failed to generate api_access_key", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + apiAccessKey := base64.URLEncoding.EncodeToString(apiAccessKeyBytes) + + _, err = app.writePool.Exec(c.Context(), ` + INSERT INTO api_access_keys (api_key, api_access_key, is_active) + VALUES ($1, $2, true) + `, address, apiAccessKey) + if err != nil { + app.logger.Error("Failed to insert api_access_keys", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create developer app", + }) + } + + return c.JSON(fiber.Map{ + "api_key": address, + "api_secret": apiAccessKey, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + }) +} + +func (app *ApiServer) deleteV1UsersDeveloperApp(c *fiber.Ctx) error { + userID := app.getUserId(c) + address := c.Params("address") + if address == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "address is required", + }) + } + if !strings.HasPrefix(address, "0x") { + address = "0x" + address + } + + // Verify the app belongs to this user + var ownerUserID int32 + err := app.pool.QueryRow(c.Context(), ` + SELECT user_id FROM developer_apps + WHERE LOWER(address) = LOWER($1) + AND is_current = true + AND is_delete = false + ORDER BY created_at DESC + LIMIT 1 + `, address).Scan(&ownerUserID) + if err != nil || ownerUserID != userID { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "Developer app not found", + }) + } + + plansSecret := app.config.AudiusApiSecret + if plansSecret == "" { + app.logger.Error("audiusApiSecret required for developer app delete") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Plans app not configured", + }) + } + plansKey, err := crypto.HexToECDSA(strings.TrimPrefix(plansSecret, "0x")) + if err != nil { + app.logger.Error("Invalid audiusApiSecret", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Plans app misconfigured", + }) + } + plansAddress := strings.ToLower(crypto.PubkeyToAddress(plansKey.PublicKey).Hex()) + + // 1. Delete api_access_keys (revoke all access keys for this app) + // 2. Delete api_keys row + // 3. Send ManageEntity transaction to delete the developer app on-chain + if app.writePool != nil { + _, err = app.writePool.Exec(c.Context(), `DELETE FROM api_access_keys WHERE LOWER(api_key) = LOWER($1)`, address) + if err != nil { + app.logger.Error("Failed to delete api_access_keys", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to delete developer app", + }) + } + _, err = app.writePool.Exec(c.Context(), `DELETE FROM api_keys WHERE LOWER(api_key) = LOWER($1)`, address) + if err != nil { + app.logger.Error("Failed to delete api_keys", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to delete developer app", + }) + } + } + + metadataObj := map[string]interface{}{ + "address": strings.ToLower(address), + } + metadataBytes, _ := json.Marshal(metadataObj) + + nonce := time.Now().UnixNano() + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(plansAddress).String(), + UserId: int64(userID), + EntityId: 0, + Action: indexer.Action_Delete, + EntityType: indexer.Entity_DeveloperApp, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: string(metadataBytes), + } + + response, err := app.sendTransactionWithSigner(manageEntityTx, plansKey) + if err != nil { + app.logger.Error("Failed to send developer app delete transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to delete developer app", + }) + } + + return c.JSON(fiber.Map{ + "success": true, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + }) +} + +type deactivateAccessKeyBody struct { + ApiAccessKey string `json:"api_access_key"` +} + +func (app *ApiServer) postV1UsersDeveloperAppAccessKeyDeactivate(c *fiber.Ctx) error { + userID := app.getUserId(c) + address := c.Params("address") + if address == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "address is required", + }) + } + if !strings.HasPrefix(address, "0x") { + address = "0x" + address + } + + var body deactivateAccessKeyBody + if err := c.BodyParser(&body); err != nil || strings.TrimSpace(body.ApiAccessKey) == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "api_access_key is required", + }) + } + apiAccessKey := strings.TrimSpace(body.ApiAccessKey) + + // Verify the app belongs to this user + var ownerUserID int32 + err := app.pool.QueryRow(c.Context(), ` + SELECT user_id FROM developer_apps + WHERE LOWER(address) = LOWER($1) + AND is_current = true + AND is_delete = false + ORDER BY created_at DESC + LIMIT 1 + `, address).Scan(&ownerUserID) + if err != nil || ownerUserID != userID { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "Developer app not found", + }) + } + + if app.writePool == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Database write not available", + }) + } + + result, err := app.writePool.Exec(c.Context(), ` + UPDATE api_access_keys + SET is_active = false + WHERE LOWER(api_key) = LOWER($1) AND api_access_key = $2 + `, address, apiAccessKey) + if err != nil { + app.logger.Error("Failed to deactivate api_access_key", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to deactivate access key", + }) + } + if result.RowsAffected() == 0 { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "Access key not found", + }) + } + + // Invalidate signer cache so deactivated key is no longer accepted + app.apiAccessKeySignerCache.Delete(apiAccessKey) + + return c.JSON(fiber.Map{"success": true}) +} + +func (app *ApiServer) postV1UsersDeveloperAppAccessKeyCreate(c *fiber.Ctx) error { + userID := app.getUserId(c) + address := c.Params("address") + if address == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "address is required", + }) + } + if !strings.HasPrefix(address, "0x") { + address = "0x" + address + } + address = strings.ToLower(address) + + // Verify the app belongs to this user + var ownerUserID int32 + err := app.pool.QueryRow(c.Context(), ` + SELECT user_id FROM developer_apps + WHERE LOWER(address) = LOWER($1) + AND is_current = true + AND is_delete = false + ORDER BY created_at DESC + LIMIT 1 + `, address).Scan(&ownerUserID) + if err != nil || ownerUserID != userID { + return c.Status(fiber.StatusNotFound).JSON(fiber.Map{ + "error": "Developer app not found", + }) + } + + if app.writePool == nil { + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Database write not available", + }) + } + + apiAccessKeyBytes := make([]byte, 32) + if _, err := rand.Read(apiAccessKeyBytes); err != nil { + app.logger.Error("Failed to generate api_access_key", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create access key", + }) + } + apiAccessKey := base64.URLEncoding.EncodeToString(apiAccessKeyBytes) + + _, err = app.writePool.Exec(c.Context(), ` + INSERT INTO api_access_keys (api_key, api_access_key, is_active) + VALUES ($1, $2, true) + `, address, apiAccessKey) + if err != nil { + app.logger.Error("Failed to insert api_access_keys", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to create access key", + }) + } + + return c.JSON(fiber.Map{"api_access_key": apiAccessKey}) +} diff --git a/config/config.go b/config/config.go index 6c314fec..962aeae8 100644 --- a/config/config.go +++ b/config/config.go @@ -49,6 +49,8 @@ type Config struct { RewardCodeAuthorizedKeys []string LaunchpadDeterministicSecret string UnsplashKeys []string + // Optional API secret to be used for api.audius.co frontends + AudiusApiSecret string } var Cfg = Config{ @@ -72,6 +74,7 @@ var Cfg = Config{ CommsMessagePush: true, LaunchpadDeterministicSecret: os.Getenv("launchpadDeterministicSecret"), UnsplashKeys: strings.Split(os.Getenv("unsplashKeys"), ","), + AudiusApiSecret: os.Getenv("audiusApiSecret"), } func init() { diff --git a/sql/01_schema.sql b/sql/01_schema.sql index 41f43800..8d259a98 100644 --- a/sql/01_schema.sql +++ b/sql/01_schema.sql @@ -2,8 +2,9 @@ -- PostgreSQL database dump -- --- Dumped from database version 17.5 (Debian 17.5-1.pgdg120+1) --- Dumped by pg_dump version 17.5 (Debian 17.5-1.pgdg120+1) + +-- Dumped from database version 17.7 (Debian 17.7-3.pgdg13+1) +-- Dumped by pg_dump version 17.7 (Debian 17.7-3.pgdg13+1) SET statement_timeout = 0; SET lock_timeout = 0; @@ -5847,6 +5848,31 @@ CREATE TABLE public.album_price_history ( ); +-- +-- Name: api_access_keys; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.api_access_keys ( + api_key character varying(255) NOT NULL, + api_access_key character varying(255) NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL, + is_active boolean DEFAULT true NOT NULL +); + + +-- +-- Name: api_keys; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.api_keys ( + api_key character varying(255) NOT NULL, + api_secret character varying(255), + rps integer DEFAULT 10 NOT NULL, + rpm integer DEFAULT 500000 NOT NULL, + created_at timestamp without time zone DEFAULT now() NOT NULL +); + + -- -- Name: api_metrics_apps; Type: TABLE; Schema: public; Owner: - -- @@ -9438,6 +9464,22 @@ ALTER TABLE ONLY public.album_price_history ADD CONSTRAINT album_price_history_pkey PRIMARY KEY (playlist_id, block_timestamp); +-- +-- Name: api_access_keys api_access_keys_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_access_keys + ADD CONSTRAINT api_access_keys_pkey PRIMARY KEY (api_key, api_access_key); + + +-- +-- Name: api_keys api_keys_pkey; Type: CONSTRAINT; Schema: public; Owner: - +-- + +ALTER TABLE ONLY public.api_keys + ADD CONSTRAINT api_keys_pkey PRIMARY KEY (api_key); + + -- -- Name: api_metrics_apps api_metrics_apps_pkey; Type: CONSTRAINT; Schema: public; Owner: - -- @@ -10739,6 +10781,20 @@ CREATE INDEX follows_inbound_idx ON public.follows USING btree (followee_user_id CREATE INDEX idx_aggregate_user_follower_count ON public.aggregate_user USING btree (user_id, follower_count); +-- +-- Name: idx_api_access_keys_api_access_key; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_api_access_keys_api_access_key ON public.api_access_keys USING btree (api_access_key); + + +-- +-- Name: idx_api_access_keys_is_active; Type: INDEX; Schema: public; Owner: - +-- + +CREATE INDEX idx_api_access_keys_is_active ON public.api_access_keys USING btree (api_key, is_active) WHERE (is_active = true); + + -- -- Name: idx_api_metrics_apps_api_key; Type: INDEX; Schema: public; Owner: - -- @@ -12577,3 +12633,4 @@ ALTER TABLE ONLY public.users -- PostgreSQL database dump complete -- + diff --git a/static/plans/README.md b/static/plans/README.md index 5cbbbf17..780c7e74 100644 --- a/static/plans/README.md +++ b/static/plans/README.md @@ -5,16 +5,19 @@ React application for displaying Audius API plans at `api.audius.co/plans`. ## Setup 1. Install dependencies: + ```bash npm install ``` 2. Set environment variable: + ```bash export VITE_AUDIUS_API_KEY=your_api_key_here ``` Or create a `.env` file: + ``` VITE_AUDIUS_API_KEY=your_api_key_here ``` @@ -22,6 +25,7 @@ VITE_AUDIUS_API_KEY=your_api_key_here ## Development Run the development server: + ```bash npm run dev ``` @@ -29,6 +33,7 @@ npm run dev ## Building Build for production: + ```bash npm run build ``` @@ -40,5 +45,6 @@ This will create a `dist/` directory with the built files that will be served by After building, the Go server will serve the built files from `./static/plans/dist/` at the `/plans` route. The app is configured to: + - Serve static assets from `/plans/assets/` - Serve the SPA from `/plans/*` routes diff --git a/static/plans/index.html b/static/plans/index.html index aeb28278..1dacf6a2 100644 --- a/static/plans/index.html +++ b/static/plans/index.html @@ -2,7 +2,7 @@ - + Audius API Plans