intuit / QuickBooks-V3-PHP-SDK

Official PHP SDK for QuickBooks REST API v3.0: https://developer.intuit.com/
Apache License 2.0
241 stars 243 forks source link

Refresh OAuth 2 Token failed #240

Closed rimoi closed 5 years ago

rimoi commented 5 years ago

Hello, I have a problem when updating the token, yet I use Refresh_token, but it is valid one hour! every time I have to go to https://developer.intuit.com/app/developer/playground to retrieve the refresh_token and access_token

Before I did like this

$dataService = DataService::Configure([
            'auth_mode' => 'oauth2',
            'ClientID' => $this->param->get('client_id'),
            'ClientSecret' => $this->param->get('secret_key'),
            'accessTokenKey' => $this->param->get('access_token'),
            'refreshTokenKey' => $this->param->get('refresh_token'),
            'QBORealmID' => $this->param->get('realm_id'),
            'baseUrl' => 'Production',
]);

$OAuth2LoginHelper = $dataService->getOAuth2LoginHelper();

try {
       $accessToken = $OAuth2LoginHelper->refreshToken();
       $dataService->updateOAuth2Token($accessToken);

       return $dataService->Query('SELECT * FROM JournalEntry', 0, 500);
} catch (ServiceException $e) {
       throw new \Exception("error token");
}

Now I discovered the method refreshAccessTokenWithRefreshToken

$OAuth2LoginHelper = new OAuth2LoginHelper($this->param->get('client_id'), $this->param->get('secret_key'));
$accessTokenObj = $OAuth2LoginHelper->refreshAccessTokenWithRefreshToken($this->param->get('refresh_token'));
$accessTokenValue = $accessTokenObj->getAccessToken();
$refreshTokenValue = $accessTokenObj->getRefreshToken();

$dataService = DataService::Configure([
            'auth_mode' => 'oauth2',
            'ClientID' => $this->param->get('client_id'),
            'ClientSecret' => $this->param->get('secret_key'),
            'accessTokenKey' => $accessTokenValue,
            'refreshTokenKey' => $refreshTokenValue,
            'QBORealmID' => $this->param->get('realm_id'),
            'baseUrl' => 'Production',
]);

 $OAuth2LoginHelper = $dataService->getOAuth2LoginHelper();

try {
       $accessToken = $OAuth2LoginHelper->refreshToken();
       $dataService->updateOAuth2Token($accessToken);

       return $dataService->Query('SELECT * FROM JournalEntry', 0, 500);
} catch (ServiceException $e) {
       throw new \Exception("Error token'");
}

It is work for 24 hours and i have this message error : Refresh OAuth 2 Access token with Refresh Token failed. Body: [{"error":"invalid_grant"}]

Someone has an idea?

Thanks Sidi

khyatibhojawala commented 5 years ago

I have the same problem if you had solved this, please let me know. @rimoi

rimoi commented 5 years ago

hello @khyatibhojawala ,

apparently, the information on the site is not correct. It indicates that Refresh_Token is valid for 101 days! with the difference that in the documentation it is valid for 24 hours (https://intuit.github.io/QuickBooks-V3-PHP-SDK/authorization.html#oauth-2-0-vs-1-0a-in-quickbooks-online (Third point)) The Api generate a new token every 24 hours based on the old token.

I store the Refresh Token every time in the database

$OAuth2LoginHelper = new OAuth2LoginHelper($this->param->get('client_id'), $this->param->get('secret_key'));
// i get refresh Token from database
$refreshTokenValue = $this->em->getRepository(Param::class)->get('qbo_refresh_token');
$accessTokenObj = $OAuth2LoginHelper->refreshAccessTokenWithRefreshToken($refreshTokenValue);

$accessTokenValue = $accessTokenObj->getAccessToken();
// i update the refresh Token in database
$this->em->getRepository(Param::class)->set('qbo_refresh_token', $accessTokenObj->getRefreshToken());

return DataService::Configure([
            'auth_mode' => 'oauth2',
            'ClientID' => $this->param->get('client_id'),
            'ClientSecret' => $this->param->get('secret_key'),
            'accessTokenKey' => $accessTokenValue,
            'refreshTokenKey' => $refreshTokenValue,
            'QBORealmID' => $this->param->get('realm_id'),
            'baseUrl' => 'Production',
 ]);

I'll tell you if it works :wink:

lemnisk8 commented 5 years ago

You are right @rimoi If you use the method refreshAccessTokenWithRefreshToken after 24h, the previous refresh token gets invalidated. For any consequent refreshAccessTokenWithRefreshToken requests, the new refresh token must be used, which is valid for 101 days or till the method is called again after 24h.

Cheers

rimoi commented 5 years ago

Thanks @lemnisk8 for your reply,

Ok if I understand, I have to use the refreshAccessTokenWithRefreshToken method once a day like this :

$dataService = DataService::Configure([
          // my configuration
          // get the last Token from the database
]);

$OAuth2LoginHelper = $dataService->getOAuth2LoginHelper();

try {
       $accessToken = $OAuth2LoginHelper->refreshToken();
       $dataService->updateOAuth2Token($accessToken);

       return $dataService->Query('SELECT * FROM JournalEntry', 0, 500);
} catch (ServiceException $e) {
     // use refreshAccessTokenWithRefreshToken and store it in datatabase every 24h
}
lemnisk8 commented 5 years ago

@rimoi you can use both refreshToken OR refreshAccessTokenWithRefreshToken

You should save these values to the DB

$accessTokenValue      = $accessTokenObj->getAccessToken(); 
$refreshTokenValue     = $accessTokenObj->getRefreshToken(); 
$accessTokenExpiresAt  = $accessTokenObj->getAccessTokenExpiresAt(); 
$refreshTokenExpiresAt = $accessTokenObj->getRefreshTokenExpiresAt(); 

Next time check if access token has expired...

if( strtotime( $accessTokenExpiresAt ) >= time() ) {

/*
Call refreshToken OR refreshAccessTokenWithRefreshToken again 
and save/update new values to the DB
*/

}
rimoi commented 5 years ago

Tremendous Thanks @lemnisk8 I try this and I inform you :wink:

rimoi commented 5 years ago

Hi @lemnisk8,

I have a question why are you comparing to AccessToken not to RefreshToken ?

if( strtotime( $refreshTokenExpiresAt ) >= time() ) {

/*
Call refreshToken OR refreshAccessTokenWithRefreshToken again 
and save/update new values to the DB
*/
}

Edit
I think I can see why ! because the value in variable $refreshTokenExpiresAt (corresponds timestamp to 101 days)

So I think in this case we should do rather

$afterOneDay = $accessTokenExpiresAt  + 3600 * 23;
if( strtotime( $afterOneDay ) >= time() ) {
    // use method refreshAccessTokenWithRefreshToken and store in database 
} else {
   // get refresh token from database and use method refreshToken
}

what do you think?

lemnisk8 commented 5 years ago

@rimoi refreshAccessTokenWithRefreshToken and refreshToken will give the same response... with oauth2 the refresh token sent back changes every 24h

Instead of keeping track of when the refreshToken has changed, its easier to just save it every time... :)

rimoi commented 5 years ago

Okay good :ok_hand: thanks you so much for your time @lemnisk8 :wink:

hriziya commented 4 years ago

Hi @lemnisk8 I am facing same problem. In my case, the I can get the new AccessToken from RefreshToken in Sandbox mode.. but when I use the Production mode, this logic does not work and throws the invalid_grant error. here is exact error details. image

imvishalchodvadiya commented 4 years ago

I am also facing a same problem and getting an error {"error":"invalid_grant"}]

asadmughal92 commented 1 year ago

i am doing in laravel first time when I generate access token it is working fine but when we fetch refresh token it generates an error Refresh OAuth 2 Access token with Refresh Token failed. Body: [{"error_description":"Incorrect Token type or clientID","error":"invalid_grant"}]. my code is attached @lemnisk8 @imvishalchodvadiya @hriziya

            public function test()
            {
                $clientID= 'clientid';
                $clientSecret='clientsecret';

            $accessTokenKey = 'eyJlbmMiOiJBMTI4Q0JDLUhTMjU2IiwiYWxnIjoiZGlyIn0..ZreAgv5F6yGly2ma-ZKtOg.dljIAppj5UPbmkHX1qKA1MAjA1nzDpd4qYzgtCmN_yROJs2luw7xug-ooGWeQ3rDs4KNK0rERJQlgvuJ-Qlv16Cfhtx9pDgKuGkJHEs7fGaNVEaQvoz8EIV3dOM4ASWhbvbaVuacb8B6GSWs-ocFDymZZq7LW-g6YpPku2RVc9KFvn2uwBCrIZR6LV2A0L1kmQw7YvbmNJfA-FsxfchMIPFjNIFxSlbEqo1U44ksHwpfhnkesdkzdnnFmN93-ZB4aApWhE0QpFmD2kGSnHoo8LbU4YEwy4UMRCeQ2FjKGsIdpnYO1JRL15oXD5n5XXXQ12Ohz1Q71eEonovGEj7ScWLY8OQCc5ThsK3RqZ9UYVs2lSSnAGtHkff1r_KsY9Ldzc8ldrblstQdnWo8XeKHIFEvnyzmzGVfwv1Ji2PZt54ul7XhPOUxKQRHFKft5o9sl4sYwxzcScw95oNSdttz44v01T0vpcY7jj9yQlCE3RrSgwJOf01Moly6-m5MhbH8ZEocR5hpDtzClD5CTQkf5bVCiiKKzYDwwSu_ICqdd_2RHUvllV2nTQl7arAX6vjLK11Auh2PL7CQhTzJ3_kvnpFr3FnPVE95wT15XvdAvvkUtNBuwUqJsJ9Lx_i_BHkFYf9Eeh1uwqSoPJv-9dYy1QzkQ1iI0GFQfkO3Ybcdai46bzaInTr0DQ4pL6IdQoCwoaAEMNwTpAhjPOHplKJDnw5LgbTv1GEa211YXe7OaVySl5SotC3tCInOIxWdxHvUr9TRQb3FVthn3k2MroAd8qMzH3Z9eEJDkndwSVznFONAKPG581R0bVvwnbFdwMr1k_Yd0gspGPYdL968BkZjkLzA21UnLqEXSAu5LlYX9S6MXjlNX0tlnVOPYcn2M3K5.8nOkmBMzg0_bKGKKpTqoMA';
                $refresh_token='AB116791234451aEqdi28l8OL1CHORRhNP4HixTb6Z8sCoDAds';

                $dataService = DataService::Configure(array(
                    'auth_mode' => 'oauth2',
                    'ClientID' => $clientID,
                    'ClientSecret' =>$clientSecret,  
                    'RedirectURI' => 'http://localhost:8000/callback.php',
                    'scope' => 'com.intuit.quickbooks.accounting openid profile email phone address',
                    'baseUrl' => "development",
                ));

                $Token = new OAuth2AccessToken($clientID,$clientSecret,$accessTokenKey,$refresh_token,'1670400645','1679123445');

                $OAuth2LoginHelper = $dataService->getOAuth2LoginHelper();

                try {

                    $dataService->updateOAuth2Token($Token);

                    $afterOneDay = strtotime($Token->getaccessTokenExpiresAt())  + 3600 * 23;

                    if( $afterOneDay  >= time() ) {

                        // use method refreshAccessTokenWithRefreshToken and store in database 
                        $newAccessTokenObj = $OAuth2LoginHelper->refreshAccessTokenWithRefreshToken($Token->getRefreshToken());

                        $newAccessTokenObj->setRealmID($Token->getRealmID());
                        $newAccessTokenObj->setBaseURL($Token->getBaseURL());
                        print_r($newAccessTokenObj);
                    } else {
                    // get refresh token from database and use method refreshToken

                        $CompanyInfo = $dataService->getCompanyInfo();

                        return json_encode($CompanyInfo);
                    }

                } catch (ServiceException $e) {

                }
            }
npapratovic commented 12 months ago

In short, the recommended workflow for all apps is: Step 1. If the Access Token fails, use the current Refresh Token to request new Access and Refresh Tokens. Step 2. Store BOTH the Access and Refresh Tokens that are returned. Step 3. Use the new Access Token for making QuickBooks Online API calls. Step 4. When the Access Token fails in 1 hour, go to Step 1.

To solve this error: "Refresh OAuth 2 Access token with Refresh Token failed" in my Laravel application I had this approach:

1) store config data in .env files, store tokens in database; fetch them on every DataService object creation, and recreate tokens on the fly and save them again in database:


use QuickBooksOnline\API\Core\OAuth\OAuth2\OAuth2LoginHelper;
use QuickBooksOnline\API\DataService\DataService;
use QuickBooksOnline\API\Exception\SdkException;
use QuickBooksOnline\API\Exception\ServiceException;

    public function getTokensFromDataBase()
    {
        $config = config('quickbooks');
        $quickbook = \App\Models\QuickBook::where('realm_id', $config['realm_id'])->first();
        return [
            'access_token' => $quickbook->access_token,
            'refresh_token' => $quickbook->refresh_token,
            'x_refresh_token_expires_in' => $quickbook->x_refresh_token_expires_in,
        ];
    }

    public function getTokens()
    {
        // step 0 fetch latest tokens from db
        $tokens_from_db = $this->getTokensFromDataBase();

        // step 1: get config data, and get refresh token
        $config = config('quickbooks');
        $dataService = DataService::Configure([
            'auth_mode' => 'oauth2',
            'ClientID' => $config['client_id'],
            'ClientSecret' => $config['client_secret'],
            'RedirectURI' => $config['redirect_uri'],
            'scope' => 'com.intuit.quickbooks.accounting',
            'baseUrl' => $config['base_url'],
            // Get the refresh token from session or database
            'refreshTokenKey' => $tokens_from_db['refresh_token'],
            'QBORealmId' => $config['realm_id'],
        ]);

        // step 2: refresh access token using the refresh token
        // The first parameter of OAuth2LoginHelper is the ClientID, the second parameter is the client Secret
        $oauth2LoginHelper = new OAuth2LoginHelper($config['client_id'], $config['client_secret']);
        $accessToken = $oauth2LoginHelper->refreshAccessTokenWithRefreshToken($tokens_from_db['refresh_token']);
        $dataService->updateOAuth2Token($accessToken);
        // step 3: save the new tokens in the database

        $token_in_db = \App\Models\QuickBook::where('realm_id', $config['realm_id'])->first();
        $token_in_db->access_token = $accessToken->getAccessToken();
        $token_in_db->refresh_token = $accessToken->getRefreshToken();
        $token_in_db->x_refresh_token_expires_in = $accessToken->getRefreshTokenExpiresAt();
        $token_in_db->save();

        return [
            'access_token' => $accessToken->getAccessToken(),
            'refresh_token' => $accessToken->getRefreshToken(),
            'x_refresh_token_expires_in' => $accessToken->getRefreshTokenExpiresAt(),
            'expires_in' => $accessToken->getAccessTokenExpiresAt(),
        ];
    }

2) In your controller where you wwant to store invoice in QB, fetch tokens by calling method getTokens() and set DataService object again:

        $tokens = $this->getTokens();

        $config = config('quickbooks');

        $dataService = DataService::Configure([
            'auth_mode' => 'oauth2',
            'ClientID' => $config['client_id'],
            'ClientSecret' => $config['client_secret'],
            'RedirectURI' => $config['redirect_uri'],
            'accessTokenKey' => $tokens['access_token'],
            'refreshTokenKey' => $tokens['refresh_token'],
            'QBORealmID' => $config['realm_id'],
            'baseUrl' => $config['base_url'],
        ]);

        $dataService->throwExceptionOnError(true);

3) now, when DataSerice object is set up properly, you can follow instructions to save or ftech data to / from QB:


    ....

        $invoice = Invoice::create([
            "Line" => $lines,
            "CustomerRef" => [
                "value" => $customer->Id,
                "name" => $customer->name,
            ],
            "BillEmail" => [
                "Address" => $customer->email,
            ],
        ]);

        $response_from_qb = $dataService->Add($invoice);

        $xmlResponseQB = XmlObjectSerializer::getPostXmlFromArbitraryEntity($response_from_qb, $urlResource);

        // use this to debug response from QB
        echo "Invoice Created Id={$response_from_qb->Id}. Reconstructed response body:\n\n";
        echo $xmlResponseQB;

    ....

4) problem with tokens is that they expire - access token is valid for 60 minutes, and by using approach above, if your users do not perform query to the QB, access tokens in your db will expire

My approach was to create Laravel command, and set up cron job which will run every 10 minutes and update tokens in db by itself:

<?php

namespace App\Console\Commands;

use Illuminate\Console\Command;
use QuickBooksOnline\API\Core\OAuth\OAuth2\OAuth2LoginHelper;
use QuickBooksOnline\API\DataService\DataService;
use QuickBooksOnline\API\Exception\SdkException;
use QuickBooksOnline\API\Exception\ServiceException;

class refreshQBToken extends Command
{
    /**
     * The name and signature of the console command.
     *
     * @var string
     */
    protected $signature = 'app:refresh-q-b-token';

    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'This command refreshes the QuickBooks token.';

    /**
     * Execute the console command.
     * @throws SdkException
     * @throws ServiceException
     */
    public function handle()
    { 

        $config = config('quickbooks');
        $token_in_db = \App\Models\QuickBook::where('realm_id', $config['realm_id'])->first();
        $dataService = DataService::Configure([
            'auth_mode' => 'oauth2',
            'ClientID' => $config['client_id'],
            'ClientSecret' => $config['client_secret'],
            'RedirectURI' => $config['redirect_uri'],
            'scope' => 'com.intuit.quickbooks.accounting',
            'baseUrl' => $config['base_url'],
            // Get the refresh token from session or database
            'refreshTokenKey' => $token_in_db->refresh_token,
            'QBORealmId' => $config['realm_id'],
        ]);
        $oauth2LoginHelper = new OAuth2LoginHelper($config['client_id'], $config['client_secret']);
        $accessToken = $oauth2LoginHelper->refreshAccessTokenWithRefreshToken($token_in_db->refresh_token);
        $dataService->updateOAuth2Token($accessToken);
        //save the new tokens in the database
        $token_in_db->access_token = $accessToken->getAccessToken();
        $token_in_db->refresh_token = $accessToken->getRefreshToken();
        $token_in_db->x_refresh_token_expires_in = $accessToken->getRefreshTokenExpiresAt();
        $token_in_db->save();

        $this->info('Successfully refreshed QB tokens.');
    }
}

Thats how I managed to solve problem with tokens expiry