Keep the refresh token current when Intuit rotates it (fixes #397) - #401
Keep the refresh token current when Intuit rotates it (fixes #397)#401alikhan126 wants to merge 1 commit into
Conversation
f8f770a to
222cb05
Compare
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>
222cb05 to
70f6b45
Compare
|
@alikhan126 hello! I see you are still pushing changes. Can I ask 2 things from you:
|
|
@justmobilize On disclosure: essentially all of it is Claude (Opus 5, via Claude Code), the root-cause investigation, Happy to walk through any part of it. |
|
Not against AI code, just making sure I know the scope. Let me know when this is ready for review and will take a look |
|
Ready for review. Kept it to three files: the client fix plus two test modules. Left the README and CHANGELOG alone, those seemed like your call. Ran the suite on 3.8 and 3.14, both green, and the new tests do fail against current main, so they're pinning the actual bug, not just describing the new behavior. Also |
Fixes #397 ("Can't get the access and refresh tokens under 3 hours — Incorrect or invalid refresh token").
There is no 3-hour cool-down. Connections end because the library saw a rotated refresh token, did not report it, and then let the application replay the value Intuit had already replaced.
Three files:
quickbooks/client.pyand two new test modules.What Intuit's token service actually does
Every token response — the authorization-code exchange and every refresh — carries a
refresh_token. For about 24 hours after a value is issued it is stable: the same value comes back from every refresh, and repeated or concurrent use is fine. Rotation is anchored to when the value was issued, not when it was last used. The first refresh after that window replaces it, the old value is dead immediately (no grace period), and the replacement exists only in that one response body.Rotation does not revoke the connection — the new token keeps working, and only the caller replaying a stale value is locked out. That is why re-authorizing fixes it and why it comes back a day later.
What the library assumed
QuickBookstreated the refresh token as static configuration, and the one place the rotated value passed through is where it was dropped:Three consequences:
access_tokentoAuthClient, so no token call is made andauth_client.refresh_tokenisNone— and thatNonewas written overinstance.refresh_token. An application storing "the latest refresh token" from the client stored nothing, seconds after authorizing.client.refresh_tokenis the only public surface, and in that same flow it staysNone. When Intuit rotates, the app keeps its old value and the next refresh is rejected.401raisedAuthorizationExceptionand nothing refreshed. Applications work around it with the exact call in Can't get the access and refresh tokens under 3 hours (Incorrect or invalid refresh token) #397's traceback —auth_client.refresh(refresh_token=STORED_TOKEN)— which is where the superseded value gets replayed and the connection dies.The change
quickbooks/client.py:refresh_token/access_tokenare client state that only ever moves forward — an empty auth-client token can no longer overwrite the caller's.refresh_token_callback, called after every token response withrefresh_token,access_token,expires_in,x_refresh_token_expires_in,realm_idandrotated, so an application can persist the value the instant it changes.refresh_access_token()is public: it presents the newest token the client holds (never a superseded one), captures what comes back, and updates the session's access token.401refreshes the access token and retries the request once, inmake_request()anddownload_pdf().auto_refresh=Falserestores the previous raise-immediately behaviour. If the refresh itself is rejected, theAuthorizationExceptionsays the connection must be authorized again and carries the service's response indetail.QuickbooksExceptioninstead ofValueError: Refresh token not specifiedfrom insideintuitlib.No public API changed shape.
refresh_token_callbackandauto_refreshare optional, and an application that readsclient.refresh_tokenafter each call now gets the current value instead ofNone. I left the README and CHANGELOG alone — happy to add a short note on storing the refresh token if you want one, in whatever form suits the docs.How the behaviour was verified
The token-service behaviour above was measured, not assumed. I ran the sequence against a QuickBooks Online vendor simulator with a controllable virtual clock — a Veris sandbox running its
quickbooksservice, which implements the Accounting API and the OAuth endpoints (/oauth2/v1/tokens/bearerplus the authorize/callback flow) with the vendor's measured semantics. Because the clock is controllable, a 24-hour rotation window can be crossed in seconds instead of waiting a day or burning a real sandbox connection. Connecting fresh and then refreshing on that clock:Alternative explanations were tested against the same service and ruled out: repeated refreshes inside the window (fine), three concurrent refreshes with the same value (all
200, same value returned), twelve refreshes in a row (no cap on live tokens), replaying an authorization code (the replay is rejected, the tokens it minted keep working), and re-authorizing (does not disturb the existing connection). Reusing a superseded token does not revoke the connection — only the caller holding it is locked out.I also ran a full application timeline — connect, then a request every hour for 26 hours — against that simulator with the real
intuitlibAuthClient: on the released code it dies at the first refresh after the rotation with the vendor'sinvalid_grant; with this change it runs the whole timeline and is handed the rotated token at t+24h. Those measured rules are what the two test modules below encode, so the behaviour is pinned in the test suite rather than in a scratch script.Tests
Per contributing.md, the change is covered — 17 tests across two modules, of which 12 fail on the released code.
tests/unit/test_token_refresh.py— the client's logic, against a scripted auth client (15 tests, 10 fail without the fix):RefreshTokenTestCase— the caller's token surviving a supplied access token, rotation replacing it, a superseded value never being presented twice, callback payloads, session token updates,401refresh-and-retry,auto_refresh=False, a rejected refresh reporting that re-authorization is needed, anddownload_pdf().Issue397CycleTestCase— the issue's sequence end to end: an application written the way the README shows connects and makes a request every hour for 26 hours, storing the refresh token the client reports and falling back to the by-handauth_client.refresh(...)from Can't get the access and refresh tokens under 3 hours (Incorrect or invalid refresh token) #397's traceback when the API answers401. On the released code it fails at t+25h, one hour after the rotation:tests/unit/test_token_rotation_e2e.py— the same day, over HTTP, through the realintuitlibAuthClientand a realOAuth2Session(2 tests, both fail without the fix). A self-contained token service listens on localhost and answers the way Intuit's does — stable value inside the 24-hour window, replaced after it,invalid_grantfor the old one, one-hour access tokens — and the test runs the connect flow,get_bearer_token(), and a request every hour. This is what the scripted auth client cannot reach: ifintuitlibchanged how it reports tokens, the other module would still pass. On the released code the connection is gone an hour after authorizing:Both modules are plain
unittest— no credentials, no outside network, no new test dependencies — so they run in CI alongside the existing unit tests.(234 before this change, plus the 17 added here.)
flake8 --select=E9,F63,F7,F82, the gate the CI workflow enforces, is clean.This PR was prepared with Claude Code (Claude Opus 5): the diagnosis, the vendor-behaviour measurements, the fix and the tests were produced in an agentic session, and every claim above is backed by a command whose output is quoted verbatim. Please review it as you would any other contribution.