eBay / digital-signature-verification-ebay-api

Verification of digital signatures for use by developers sending HTTP requests to eBay's APIs
Apache License 2.0
8 stars 7 forks source link

Sending POST request to issueRefund in Python #20

Closed net-caster closed 1 year ago

net-caster commented 1 year ago

Hi,

I know Python is technically not supported but I thought I'd ask anyway

I've been able to make the GET requests work (e.g. by fetching https://apiz.ebay.com/sell/finances/v1/transaction?limit=20&offset=0) but can't seem to POST to the issueRefund endpoint for some reason.

Here's the script I found (and slightly modified) that I'm using:

Note: this is the POST version of the script. The GET version doesn't have the content-digest header/signature property as you can guess

from base64 import b64encode
from urllib.parse import urlparse
import json
import sys
import time
import requests
from Crypto.PublicKey import ECC
from Crypto.Signature import eddsa
from Crypto.Hash import SHA256
from requests.exceptions import HTTPError

class EBayRefund:

    __config: dict = {}

    def __init__(self):
        with open('config.json') as user_file:
            json_file = user_file.read()

        self.__config = json.loads(json_file)

    def __get_access_token(self, ebay_refresh_token: str, oauth_token: str, scope: str):
        """Returns an eBay API access token, required to make further API calls."""

        headers = {
            "Authorization": "Basic " + oauth_token,
            "Content-Type": "application/x-www-form-urlencoded",
        }

        data = {
            "grant_type": "refresh_token",
            "refresh_token": ebay_refresh_token,
            "scope": scope,
        }

        try:
            response = requests.post(
                "https://api.ebay.com/identity/v1/oauth2/token",
                headers=headers,
                data=data,
                timeout=10,
            )

            response.raise_for_status()
            result = response.json()

            return result["access_token"]
        except (HTTPError, KeyError) as error:
            sys.exit(f"Unable to fetch access token: {error}")

    def __get_content_digest(self, content: str) -> str:
        """
        Generate a digest of the provided content.

        A content digest is needed when using one of the few POST requests requiring a digital
        signature.
        """

        hasher = SHA256.new()
        hasher.update(bytes(content, encoding="utf-8"))
        digest = b64encode(hasher.digest()).decode()
        return digest

    def __get_digital_signature(self, ebay_private_key: str, ebay_public_key_jwe: str, request_url: str, signature_params: str, digest: str = "") -> str:
        """
        Generate the digital signature using the details provided. The signature is created
        using ED25519.

        To add support for POST requests, pass in a content digest and add a "Content-Digest"
        entry to params, with a value of sha-256:digest:
        """
        url = urlparse(request_url)
        params = (
            f'"x-ebay-signature-key": {ebay_public_key_jwe}\n'
            f'"@method": POST\n'
            f'"@path": {url.path}\n'
            f'"@authority": {url.netloc}\n'
            f'"@signature-params": {signature_params}\n'
            f'"content-digest": sha-256=:{digest}:'
        ).encode()

        print(params)

        try:
            private_key = ECC.import_key(f"""-----BEGIN PRIVATE KEY-----\n{ebay_private_key}\n-----END PRIVATE KEY-----""")
            signer = eddsa.new(private_key, mode="rfc8032")
            signature = signer.sign(params)
            return b64encode(signature).decode()
        except ValueError as error:
            sys.exit(f"Error creating digital signature: {error}")

    def __send_signed_api_request(self, ebay_private_key: str, ebay_public_key_jwe: str, access_token: str) -> None:
        """
        Sends a request to the eBay API with a digital signature attached.

        The API response text is printed before exiting.
        """

        order_id = "XX-XXXXX-XXXXX"

        request_url: str = f"https://api.ebay.com/sell/fulfillment/v1/order/{order_id}/issue_refund"

        signature_input = f'("content-digest" "x-ebay-signature-key" "@method" "@path" "@authority");created={int(time.time())}'

        body = {
            "orderLevelRefundAmount": 
                {
                    "value": "17.52",
                    "currency": "GBP"
                },
                "reasonForRefund": "BUYER_RETURN"
            }

        content = json.dumps(body)

        content_digest = self.__get_content_digest(content = content)

        signature = self.__get_digital_signature(
            ebay_private_key=ebay_private_key,
            ebay_public_key_jwe=ebay_public_key_jwe,
            request_url=request_url,
            signature_params=signature_input,
            digest=content_digest
        )

        headers = {
            "Authorization": "Bearer " + access_token,
            "Signature-Input": f'sig1={signature_input}',
            "Signature": f"sig1=:{signature}:",
            "x-ebay-signature-key": ebay_public_key_jwe,
            "x-ebay-enforce-signature": "true",
            "content-digest": f"sha-256=:{content_digest}:"
        }

        print(json.dumps(headers, indent=4))

        try:
            response = requests.post(request_url, headers = headers, data = content, timeout = 10)
            result = response.json()

            print(json.dumps(result, indent = 4))
            sys.exit()
        except HTTPError as error:
            sys.exit(f"Unable to send request: {error}")

    def start(self):
        """Load credentials and read runtime arguments."""

        scope = "https://api.ebay.com/oauth/api_scope/sell.fulfillment"

        access_token = self.__get_access_token(
            ebay_refresh_token = self.__config["refreshToken"],
            oauth_token = self.__config["credentials"],
            scope = scope,
        )

        self.__send_signed_api_request(
            ebay_private_key = self.__config["privateKey"],
            ebay_public_key_jwe = self.__config["jweKey"],
            access_token = access_token,
        )

if __name__ == "__main__":
    EBR = EBayRefund()
    EBR.start()

After running the code above I get this error:

{
    "errors": [
        {
            "errorId": 215122,
            "domain": "ACCESS",
            "category": "REQUEST",
            "message": "Signature validation failed",
            "longMessage": "Signature validation failed to fulfill the request."
        }
    ]
}

Any help would be appreciated

Thanks

uherberg commented 1 year ago

Hi @net-caster. Try moving content-digest before @signature-params in the signature base. @signature-params needs to be last. Also, when you perform a POST, add the following headers to your HTTP request:

Accept: application/json
Content-Type: application/json

And BTW, you will no longer need to include the x-ebay-enforce-signature header.

net-caster commented 1 year ago

@uherberg Awesome! It works.

Here's the new signature base I'm using:

params = (
   f'"content-digest": sha-256=:{digest}:\n'
   f'"x-ebay-signature-key": {ebay_public_key_jwe}\n'
   f'"@method": POST\n'
   f'"@path": {url.path}\n'
   f'"@authority": {url.netloc}\n'
   f'"@signature-params": {signature_params}'
).encode()

and the headers:

headers = {
   "Authorization": "TOKEN " + access_token,
   "Signature-Input": f'sig1={signature_input}',
   "Signature": f"sig1=:{signature}:",
   "Accept": "application/json",
   "Content-Type": "application/json",
   "x-ebay-signature-key": ebay_public_key_jwe,
   "content-digest": f"sha-256=:{content_digest}:"
}

Note: Because I'm calling the /post-order/v2/return/{return_id}/issue_refund API endpoint I had to replace Bearer with TOKEN inside the Authorization header.

Speaking of authorization, right now I'm generating a token within the sell.fulfillment scope and the post-order API doesn't specify one. It seems to be working but I was wondering if it matters/is a good idea to do it like that.

Thanks

uherberg commented 1 year ago

@net-caster Great that it works now!

I am not sure about the scope; I will ask my colleague to respond here.

LokeshRishi commented 1 year ago

Hello @net-caster, Post Order API accepts both Auth'n'Auth and OAuth tokens. We recommend generating the OAuth token with sell.fulfillment scope, whereas for Auth'n'Auth tokens it is not required as they do not use OAuth scopes.

For more information, please refer to the documentation here.

net-caster commented 1 year ago

Oh, I guess I've been doing it right, then.

I'll keep using the OAuth token with the sell.fulfillment scope going forward.

Thank you, @LokeshRishi and thanks again, @uherberg

mehboobafridi commented 1 year ago

@net-caster Can you please post a working code which refunds ebay cases using PostOrderAPI. I have been trying more then a month but all in vain. If you have a PHP version would be best, or Python will be a base for me to transform. Thanks