@@ -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