Skip to content

Commit 222cb05

Browse files
alikhan126claude
andcommitted
Keep the refresh token current when Intuit rotates it (#397)
Intuit returns a refresh token with every token response and replaces the value roughly 24 hours after it was issued. From that moment the previous value is rejected with "Incorrect or invalid refresh token", and the replacement appears in that one response and nowhere else. The client treated the refresh token as static configuration. _start_session() returned auth_client.refresh_token and __new__ wrote it over the caller's value -- which is None in the documented flow, where an access token is supplied and no token call is made. So the credential the application had just stored was discarded, the rotated token never reached the application, and a 401 raised AuthorizationException instead of refreshing. Applications worked around that by refreshing by hand with their stored token, replaying a superseded value and ending the connection. - track refresh_token/access_token as client state that only moves forward - add refresh_token_callback, called after every token response so the caller can persist the value the moment it changes - add public refresh_access_token(), which always presents the newest token - refresh and retry once on a 401 in make_request()/download_pdf(); auto_refresh=False keeps the previous behaviour - raise a readable QuickbooksException when no refresh token is available Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 264a0f6 commit 222cb05

2 files changed

Lines changed: 487 additions & 11 deletions

File tree

quickbooks/client.py

Lines changed: 126 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ class QuickBooks(object):
3030
invoice_link = False
3131
use_decimal = False
3232

33+
# The refresh token currently issued for this connection. Intuit rotates
34+
# this value roughly every 24 hours and the previous value stops working
35+
# the moment it does, so it is kept up to date after every token response
36+
# rather than treated as static configuration.
37+
refresh_token = None
38+
access_token = None
39+
40+
# Called with the token payload after every token response so the caller
41+
# can persist the current refresh token. See README, "Storing tokens".
42+
refresh_token_callback = None
43+
44+
# Refresh the access token and retry once when the API answers 401.
45+
auto_refresh = True
46+
3347
sandbox_api_url_v3 = "https://sandbox-quickbooks.api.intuit.com/v3"
3448
api_url_v3 = "https://quickbooks.api.intuit.com/v3"
3549
current_user_url = "https://appcenter.intuit.com/api/v1/user/current"
@@ -62,6 +76,12 @@ def __new__(cls, **kwargs):
6276
if 'refresh_token' in kwargs:
6377
instance.refresh_token = kwargs['refresh_token']
6478

79+
if 'refresh_token_callback' in kwargs:
80+
instance.refresh_token_callback = kwargs['refresh_token_callback']
81+
82+
if 'auto_refresh' in kwargs:
83+
instance.auto_refresh = kwargs['auto_refresh']
84+
6585
if 'auth_client' in kwargs:
6686
instance.auth_client = kwargs['auth_client']
6787

@@ -70,8 +90,7 @@ def __new__(cls, **kwargs):
7090
else:
7191
instance.sandbox = False
7292

73-
refresh_token = instance._start_session()
74-
instance.refresh_token = refresh_token
93+
instance._start_session()
7594

7695
if 'company_id' in kwargs:
7796
instance.company_id = kwargs['company_id']
@@ -101,18 +120,101 @@ def __new__(cls, **kwargs):
101120
return instance
102121

103122
def _start_session(self):
123+
"""Prepare the HTTP session, getting an access token if there isn't one.
124+
125+
Returns the refresh token this client will use from here on: the most
126+
recent one the token service issued, or the caller's if no token call
127+
was needed. Never None, and never an older value than either.
128+
"""
104129
if self.auth_client.access_token is None:
105-
self.auth_client.refresh(refresh_token=self.refresh_token)
130+
self.refresh_access_token()
131+
else:
132+
# An access token was supplied, so no token call is needed. Read
133+
# whatever the auth client holds without discarding what we were
134+
# given: it may have no refresh token at all.
135+
self._store_tokens(notify=False)
136+
137+
self.session = OAuth2Session(self.auth_client.client_id, token=self._session_token())
138+
139+
return self.refresh_token
106140

107-
self.session = OAuth2Session(
108-
self.auth_client.client_id,
109-
token={
110-
'access_token': self.auth_client.access_token,
111-
'refresh_token': self.auth_client.refresh_token,
112-
}
113-
)
141+
def refresh_access_token(self):
142+
"""Get a new access token, keeping the refresh token current.
114143
115-
return self.auth_client.refresh_token
144+
Intuit hands back a refresh token with every token response. Most of the
145+
time it is the value that was sent; roughly 24 hours after a value is
146+
issued the service rotates it, and from that moment the previous value
147+
is rejected with "Incorrect or invalid refresh token". The replacement
148+
appears in that one response and nowhere else, so it is captured here
149+
and passed to ``refresh_token_callback`` for the caller to persist.
150+
"""
151+
presented = self.refresh_token or getattr(self.auth_client, 'refresh_token', None)
152+
153+
if not presented:
154+
raise exceptions.QuickbooksException(
155+
'No refresh token available. Pass refresh_token to QuickBooks() '
156+
'(or to AuthClient()) with the value stored for this company.',
157+
10000)
158+
159+
self.auth_client.refresh(refresh_token=presented)
160+
self._store_tokens(presented=presented)
161+
162+
if self.session is not None:
163+
self.session.token = self._session_token()
164+
165+
return self.refresh_token
166+
167+
def _store_tokens(self, presented=None, notify=True):
168+
"""Take the tokens off the auth client and tell the caller about them."""
169+
issued = getattr(self.auth_client, 'refresh_token', None)
170+
171+
# Only ever move forward: a token response always carries a refresh
172+
# token, but an auth client that has not made one yet carries None.
173+
if issued:
174+
self.refresh_token = issued
175+
176+
if self.auth_client.access_token:
177+
self.access_token = self.auth_client.access_token
178+
179+
if notify and self.refresh_token_callback is not None:
180+
self.refresh_token_callback({
181+
'refresh_token': self.refresh_token,
182+
'access_token': self.access_token,
183+
'expires_in': getattr(self.auth_client, 'expires_in', None),
184+
'x_refresh_token_expires_in': getattr(
185+
self.auth_client, 'x_refresh_token_expires_in', None),
186+
'realm_id': getattr(self.auth_client, 'realm_id', None),
187+
'rotated': bool(presented and issued and issued != presented),
188+
})
189+
190+
def _session_token(self):
191+
return {
192+
'access_token': self.access_token,
193+
'refresh_token': self.refresh_token,
194+
}
195+
196+
def _refresh_and_retry(self, request_type, url, headers, params, data):
197+
"""Answer to a 401: get a new access token and send the request again.
198+
199+
Returns None when refreshing is not possible or did not help.
200+
"""
201+
if self.auth_client is None or not self.auto_refresh:
202+
return None
203+
204+
try:
205+
self.refresh_access_token()
206+
except exceptions.QuickbooksException:
207+
return None
208+
except Exception as error:
209+
# The refresh token is gone as far as the service is concerned --
210+
# the connection has to be authorized again.
211+
raise exceptions.AuthorizationException(
212+
'Failed to refresh the access token. The connection must be '
213+
'authorized again.', error_code=httplib.UNAUTHORIZED,
214+
detail=str(error))
215+
216+
return self.process_request(
217+
request_type, url, headers=headers, params=params, data=data)
116218

117219
def _drop(self):
118220
QuickBooks.__instance = None
@@ -219,6 +321,14 @@ def make_request(self, request_type, url, request_body=None, content_type='appli
219321

220322
req = self.process_request(request_type, url, headers=headers, params=params, data=request_body)
221323

324+
if req.status_code == httplib.UNAUTHORIZED:
325+
# Access tokens last an hour. Get a fresh one and send the request
326+
# once more before giving up on the connection.
327+
retried = self._refresh_and_retry(
328+
request_type, url, headers, params, request_body)
329+
if retried is not None:
330+
req = retried
331+
222332
if req.status_code == httplib.UNAUTHORIZED:
223333
raise exceptions.AuthorizationException(
224334
"Application authentication failed", error_code=req.status_code, detail=req.text)
@@ -363,6 +473,11 @@ def download_pdf(self, qbbo, item_id):
363473

364474
response = self.process_request("GET", url, headers=headers)
365475

476+
if response.status_code == httplib.UNAUTHORIZED:
477+
retried = self._refresh_and_retry("GET", url, headers, "", "")
478+
if retried is not None:
479+
response = retried
480+
366481
if response.status_code != httplib.OK:
367482

368483
if response.status_code == httplib.UNAUTHORIZED:

0 commit comments

Comments
 (0)