diff --git a/api/auth_middleware.go b/api/auth_middleware.go index 75d13cb7..8b3b3e49 100644 --- a/api/auth_middleware.go +++ b/api/auth_middleware.go @@ -2,6 +2,8 @@ package api import ( "context" + "encoding/base64" + "encoding/json" "fmt" "net/url" "strconv" @@ -9,6 +11,7 @@ import ( "time" comms "api.audius.co/api/comms" + "api.audius.co/trashid" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/gofiber/fiber/v2" @@ -98,6 +101,131 @@ func (app *ApiServer) getAuthedWallet(c *fiber.Ctx) string { return c.Locals("authedWallet").(string) } +// 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 +} + +// validateOAuthJWTTokenToWalletAndUserId validates the OAuth JWT and returns (wallet, userId). +// Used by auth middleware when Bearer token is not an api_access_key. +func (app *ApiServer) validateOAuthJWTTokenToWalletAndUserId(ctx context.Context, token string) (wallet string, userId int32, err error) { + id, err := app.validateOAuthJWTTokenToUserId(ctx, token) + if err != nil { + return "", 0, err + } + tokenParts := strings.Split(token, ".") + if len(tokenParts) != 3 { + return "", 0, fiber.NewError(fiber.StatusBadRequest, "Invalid JWT token format") + } + base64Payload, base64Signature := tokenParts[1], 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") + } + signatureBytes := common.FromHex(string(signatureDecoded)) + if len(signatureBytes) != 65 { + return "", 0, fiber.NewError(fiber.StatusBadRequest, "The JWT signature was incorrectly signed") + } + if signatureBytes[64] >= 27 { + signatureBytes[64] -= 27 + } + message := fmt.Sprintf("%s.%s", tokenParts[0], base64Payload) + prefixedMessage := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message)) + finalHash := crypto.Keccak256Hash(prefixedMessage) + 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) + return strings.ToLower(recoveredAddr.Hex()), int32(id), nil +} + // Middleware to set authedUserId and authedWallet in context // Returns a 403 if either // - the user is not authorized to act on behalf of "myId" diff --git a/api/server.go b/api/server.go index b1c24766..d76054e3 100644 --- a/api/server.go +++ b/api/server.go @@ -526,11 +526,13 @@ func NewApiServer(config config.Config) *ApiServer { g.Get("/search/full", app.v1SearchFull) g.Get("/search/tags", app.v1SearchFull) - // Developer Apps (Plans API - user_id query; indexer validates grants) + // Developer Apps g.Get("/developer_apps/:address", app.v1DeveloperApps) g.Get("/developer-apps/:address", app.v1DeveloperApps) g.Post("/developer_apps", app.postV1UsersDeveloperApp) g.Post("/developer-apps", app.postV1UsersDeveloperApp) + g.Put("/developer_apps/:address", app.putV1UsersDeveloperApp) + g.Put("/developer-apps/:address", app.putV1UsersDeveloperApp) g.Delete("/developer_apps/:address", app.deleteV1UsersDeveloperApp) g.Delete("/developer-apps/:address", app.deleteV1UsersDeveloperApp) g.Post("/developer_apps/:address/access-keys/deactivate", app.postV1UsersDeveloperAppAccessKeyDeactivate) diff --git a/api/swagger/swagger-v1.yaml b/api/swagger/swagger-v1.yaml index 980c1f22..9b3c3b78 100644 --- a/api/swagger/swagger-v1.yaml +++ b/api/swagger/swagger-v1.yaml @@ -558,7 +558,7 @@ paths: post: tags: - developer_apps - description: Create a new developer app (Plans API). Indexer validates grants. + description: Create a new developer app. Indexer validates grants. operationId: Create Developer App parameters: - name: user_id @@ -622,7 +622,7 @@ paths: delete: tags: - developer_apps - description: Deletes a developer app (Plans API). Indexer validates grants. + description: Deletes a developer app. Indexer validates grants. operationId: Delete Developer App parameters: - name: user_id @@ -653,11 +653,55 @@ paths: "500": description: Server error content: {} + put: + tags: + - developer_apps + description: Updates a developer app. Indexer validates grants. + operationId: Update Developer App + parameters: + - name: user_id + in: query + description: The user ID of the user who owns the developer app + required: true + schema: + type: string + - name: address + in: path + description: Developer app address (API Key) + required: true + schema: + type: string + requestBody: + x-codegen-request-body-name: metadata + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/update_developer_app_request_body" + responses: + "200": + description: Developer app updated successfully + content: + application/json: + schema: + $ref: "#/components/schemas/write_response" + "400": + description: Bad request + content: {} + "401": + description: Unauthorized + content: {} + "404": + description: Developer app not found + content: {} + "500": + description: Server error + content: {} /developer-apps/{address}/access-keys/deactivate: post: tags: - developer_apps - description: Deactivate a bearer token (API access key) for a developer app (Plans API). Indexer validates grants. + description: Deactivate a bearer token (API access key) for a developer app. Indexer validates grants. operationId: Deactivate Developer App Access Key parameters: - name: user_id @@ -702,7 +746,7 @@ paths: post: tags: - developer_apps - description: Create a new bearer token (API access key) for a developer app (Plans API). Indexer validates grants. + description: Create a new bearer token (API access key) for a developer app. Indexer validates grants. operationId: Create Developer App Access Key parameters: - name: user_id @@ -10328,7 +10372,7 @@ components: type: string description: Solana USDC payout wallet address playlist_library: - $ref: '#/components/schemas/user_playlist_library' + $ref: "#/components/schemas/user_playlist_library" events: type: object description: User events for tracking referrals and mobile users @@ -10404,7 +10448,7 @@ components: type: string description: Coin flair mint address playlist_library: - $ref: '#/components/schemas/user_playlist_library' + $ref: "#/components/schemas/user_playlist_library" events: type: object description: User events for tracking referrals and mobile users @@ -10584,7 +10628,7 @@ components: properties: name: type: string - description: Developer app name (Plans API create) + description: Developer app name example: "My API Key" deactivate_access_key_request_body: type: object @@ -10653,9 +10697,9 @@ components: description: Array of folders and playlist identifiers items: oneOf: - - $ref: '#/components/schemas/playlist_library_folder' - - $ref: '#/components/schemas/playlist_library_playlist_identifier' - - $ref: '#/components/schemas/playlist_library_explore_playlist_identifier' + - $ref: "#/components/schemas/playlist_library_folder" + - $ref: "#/components/schemas/playlist_library_playlist_identifier" + - $ref: "#/components/schemas/playlist_library_explore_playlist_identifier" playlist_library_folder: type: object description: Folder containing nested playlists and folders @@ -10854,6 +10898,5 @@ components: 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. + The API bearer token or OAuth JWT token for 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 536f2d8b..80d917e3 100644 --- a/api/v1_users_developer_apps.go +++ b/api/v1_users_developer_apps.go @@ -1,7 +1,6 @@ package api import ( - "context" "crypto/ecdsa" "crypto/rand" "encoding/base64" @@ -120,129 +119,106 @@ func (app *ApiServer) v1UsersDeveloperAppsWithMetrics(c *fiber.Ctx, userId int32 }) } -// 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) +type updateDeveloperAppBody struct { + Name string `json:"name"` + Description *string `json:"description"` + ImageUrl *string `json:"imageUrl"` +} - if len(signatureBytes) != 65 { - return 0, fiber.NewError(fiber.StatusBadRequest, "The JWT signature was incorrectly signed") +func (app *ApiServer) putV1UsersDeveloperApp(c *fiber.Ctx) error { + userID := app.getMyId(c) + if userID == 0 { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "user_id query parameter is required", + }) } - if signatureBytes[64] >= 27 { - signatureBytes[64] -= 27 + address := c.Params("address") + if address == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "address is required", + }) } - publicKey, err := crypto.SigToPub(finalHash.Bytes(), signatureBytes) - if err != nil { - return 0, fiber.NewError(fiber.StatusUnauthorized, "The JWT signature is invalid") + if !strings.HasPrefix(address, "0x") { + address = "0x" + address } - recoveredAddr := crypto.PubkeyToAddress(*publicKey) - walletLower := strings.ToLower(recoveredAddr.Hex()) + address = strings.ToLower(address) - 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 body updateDeveloperAppBody + if err := c.BodyParser(&body); err != nil { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "Invalid request body", + }) } - 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") + name := strings.TrimSpace(body.Name) + if name == "" { + return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ + "error": "name is required", + }) } - userIdInterface, exists := payload["userId"] - if !exists { - return 0, fiber.NewError(fiber.StatusBadRequest, "JWT payload missing userId field") + // 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", + }) } - userIdStr, ok := userIdInterface.(string) - if !ok { - return 0, fiber.NewError(fiber.StatusBadRequest, "JWT payload userId must be a string") + + plansSecret := app.config.AudiusApiSecret + if plansSecret == "" { + app.logger.Error("audiusApiSecret required for developer app update") + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Plans app not configured", + }) } - jwtUserId, err := trashid.DecodeHashId(userIdStr) + plansKey, err := crypto.HexToECDSA(strings.TrimPrefix(plansSecret, "0x")) if err != nil { - return 0, fiber.NewError(fiber.StatusBadRequest, "Invalid JWT payload userId") + 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()) - 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 + metadataObj := map[string]interface{}{ + "address": address, + "name": name, + "description": body.Description, + "image_url": body.ImageUrl, } + metadataBytes, _ := json.Marshal(metadataObj) - 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") - } + nonce := time.Now().UnixNano() + manageEntityTx := &corev1.ManageEntityLegacy{ + Signer: common.HexToAddress(plansAddress).String(), + UserId: int64(userID), + EntityId: 0, + Action: indexer.Action_Update, + EntityType: indexer.Entity_DeveloperApp, + Nonce: strconv.FormatInt(nonce, 10), + Metadata: string(metadataBytes), } - return trashid.HashId(jwtUserId), nil -} - -// validateOAuthJWTTokenToWalletAndUserId validates the OAuth JWT and returns (wallet, userId). -// Used by auth middleware when Bearer token is not an api_access_key. -func (app *ApiServer) validateOAuthJWTTokenToWalletAndUserId(ctx context.Context, token string) (wallet string, userId int32, err error) { - id, err := app.validateOAuthJWTTokenToUserId(ctx, token) - if err != nil { - return "", 0, err - } - tokenParts := strings.Split(token, ".") - if len(tokenParts) != 3 { - return "", 0, fiber.NewError(fiber.StatusBadRequest, "Invalid JWT token format") - } - base64Payload, base64Signature := tokenParts[1], 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") - } - signatureBytes := common.FromHex(string(signatureDecoded)) - if len(signatureBytes) != 65 { - return "", 0, fiber.NewError(fiber.StatusBadRequest, "The JWT signature was incorrectly signed") - } - if signatureBytes[64] >= 27 { - signatureBytes[64] -= 27 - } - message := fmt.Sprintf("%s.%s", tokenParts[0], base64Payload) - prefixedMessage := []byte(fmt.Sprintf("\x19Ethereum Signed Message:\n%d%s", len(message), message)) - finalHash := crypto.Keccak256Hash(prefixedMessage) - publicKey, err := crypto.SigToPub(finalHash.Bytes(), signatureBytes) + response, err := app.sendTransactionWithSigner(manageEntityTx, plansKey) if err != nil { - return "", 0, fiber.NewError(fiber.StatusUnauthorized, "The JWT signature is invalid") + app.logger.Error("Failed to send developer app update transaction", zap.Error(err)) + return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{ + "error": "Failed to update developer app", + }) } - recoveredAddr := crypto.PubkeyToAddress(*publicKey) - return strings.ToLower(recoveredAddr.Hex()), int32(id), nil + + return c.JSON(fiber.Map{ + "success": true, + "transaction_hash": response.Msg.GetTransaction().GetHash(), + }) } type createDeveloperAppBody struct { @@ -329,7 +305,7 @@ func (app *ApiServer) postV1UsersDeveloperApp(c *fiber.Ctx) error { } metadataBytes, _ := json.Marshal(metadataObj) - // Sign the ManageEntity tx with the plans app (which has a grant from the user). + // Sign the ManageEntity tx with our api secret (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