Skip to content

Keep the refresh token current when Intuit rotates it (fixes #397) - #401

Open
alikhan126 wants to merge 1 commit into
routablehq:mainfrom
alikhan126:fix/397-refresh-token-rotation
Open

Keep the refresh token current when Intuit rotates it (fixes #397)#401
alikhan126 wants to merge 1 commit into
routablehq:mainfrom
alikhan126:fix/397-refresh-token-rotation

Conversation

@alikhan126

@alikhan126 alikhan126 commented Aug 11, 2026

Copy link
Copy Markdown

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.py and 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

QuickBooks treated the refresh token as static configuration, and the one place the rotated value passed through is where it was dropped:

if 'auth_client' in kwargs:
    ...
    refresh_token = instance._start_session()
    instance.refresh_token = refresh_token       # overwrites the caller's token

def _start_session(self):
    if self.auth_client.access_token is None:
        self.auth_client.refresh(refresh_token=self.refresh_token)
    ...
    return self.auth_client.refresh_token        # None in the documented flow

Three consequences:

  • The caller's credential is discarded. The README's example passes access_token to AuthClient, so no token call is made and auth_client.refresh_token is None — and that None was written over instance.refresh_token. An application storing "the latest refresh token" from the client stored nothing, seconds after authorizing.
  • The rotated token never reaches the application. client.refresh_token is the only public surface, and in that same flow it stays None. When Intuit rotates, the app keeps its old value and the next refresh is rejected.
  • The client gave up on expired access tokens. Access tokens last an hour; a 401 raised AuthorizationException and 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_token are client state that only ever moves forward — an empty auth-client token can no longer overwrite the caller's.
  • New optional refresh_token_callback, called after every token response with refresh_token, access_token, expires_in, x_refresh_token_expires_in, realm_id and rotated, 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.
  • A 401 refreshes the access token and retries the request once, in make_request() and download_pdf(). auto_refresh=False restores the previous raise-immediately behaviour. If the refresh itself is rejected, the AuthorizationException says the connection must be authorized again and carries the service's response in detail.
  • A missing refresh token raises a readable QuickbooksException instead of ValueError: Refresh token not specified from inside intuitlib.

No public API changed shape. refresh_token_callback and auto_refresh are optional, and an application that reads client.refresh_token after each call now gets the current value instead of None. 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 quickbooks service, which implements the Accounting API and the OAuth endpoints (/oauth2/v1/tokens/bearer plus 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:

t+0h    exchange authorization code : 200 refresh_token=d5572620027b...
t+1h    refresh with RT0            : 200 refresh_token=d5572620027b...
t+3h    refresh with RT0            : 200 refresh_token=d5572620027b...
t+23h   refresh with RT0            : 200 refresh_token=d5572620027b...
t+25h   refresh with RT0            : 200 refresh_token=06f22a77f084...   <- rotated
t+25h   refresh with RT0 again      : 400 {"error": "invalid_grant", "error_description": "Incorrect or invalid refresh token"}
t+25h   refresh with RT1            : 200 refresh_token=06f22a77f084...

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 intuitlib AuthClient: on the released code it dies at the first refresh after the rotation with the vendor's invalid_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, 401 refresh-and-retry, auto_refresh=False, a rejected refresh reporting that re-authorization is needed, and download_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-hand auth_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 answers 401. On the released code it fails at t+25h, one hour after the rotation:
tests/unit/test_token_refresh.py:334: in app_request
    auth_client.refresh(refresh_token=self.stored['refresh_token'])
...
presented = 'RT0'
E   InvalidGrant: HTTP status 400, error message: {"error":"invalid_grant","error_description":"Incorrect or invalid refresh token"}

tests/unit/test_token_rotation_e2e.py — the same day, over HTTP, through the real intuitlib AuthClient and a real OAuth2Session (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_grant for 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: if intuitlib changed how it reports tokens, the other module would still pass. On the released code the connection is gone an hour after authorizing:

'the connection died at t+1h'
E   quickbooks.exceptions.AuthorizationException: QB Auth Exception 401: Application authentication failed

Both modules are plain unittest — no credentials, no outside network, no new test dependencies — so they run in CI alongside the existing unit tests.

$ pytest tests/unit -q
251 passed

(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.

@alikhan126
alikhan126 force-pushed the fix/397-refresh-token-rotation branch from f8f770a to 222cb05 Compare August 12, 2026 02:02
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>
@alikhan126
alikhan126 force-pushed the fix/397-refresh-token-rotation branch from 222cb05 to 70f6b45 Compare August 12, 2026 02:07
@justmobilize

Copy link
Copy Markdown
Collaborator

@alikhan126 hello! I see you are still pushing changes. Can I ask 2 things from you:

  1. Please convert this to draft until you are ready for review (and once it's ready, no more force pushes)
  2. Since you are using AI, it would be great to get full disclosure, what did you write vs what did AI/Claude write.

@alikhan126
alikhan126 marked this pull request as draft August 12, 2026 15:19
@alikhan126

Copy link
Copy Markdown
Author

@justmobilize
On force pushes: my fault, I was still narrowing scope after opening it (removing scaffolding, reverting docs churn). Converted to draft now, and any further changes will be additive commits.

On disclosure: essentially all of it is Claude (Opus 5, via Claude Code), the root-cause investigation, quickbooks/client.py, both test modules, the commit message, and the PR description. My part was direction and review: I set the task, and I made the scoping calls, that the reproduction harness and a new reproductions/ folder had no place in your repo, that the README and CHANGELOG should be left alone for you to decide, and that the HTTP-level e2e test was worth keeping when it had been cut. The vendor-behaviour measurements and the integration run against a QuickBooks simulator were executed by Claude with my review.

Happy to walk through any part of it.

@justmobilize

Copy link
Copy Markdown
Collaborator

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

@alikhan126
alikhan126 marked this pull request as ready for review August 12, 2026 19:08
@alikhan126

Copy link
Copy Markdown
Author

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 refresh_token_callback is new and undocumented. Happy to add a short README note if you want one, just tell me how you'd word it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Can't get the access and refresh tokens under 3 hours (Incorrect or invalid refresh token)

2 participants