Testing an OAuth Integration: The Token Lifecycle, Not the Login Screen
Last verified July 27, 2026 · 16 min read
A user's wearable grant dies on a Tuesday. Your refresh worker gets 400 invalid_grant back from the token endpoint, logs a warning, and moves on to the next user. Nothing tells the person, because the connection row still says active and nothing in your code flipped it. For nineteen days the nightly rollup runs, finds no new samples for that user, and writes what it always writes for a day with no samples: a zero. The streak breaks. The seven-day average slides. Whatever readiness or load score you compute runs over a window that is two-thirds absent and returns a number with no error attached to it. When the user eventually notices and reconnects, they get a new grant — and a new grant does not hand back the nineteen days.
Notice what that failure is not. It is not a login bug; the consent screen worked fine, months ago. An expired OAuth grant in a checkout flow shows the user an error and they retry. An expired OAuth grant in a health product produces a plausible-looking chart, because a hole in a time series is indistinguishable from a fortnight of rest days. That is why the tests worth writing here are about the token lifecycle after authorization, and why almost none of them involve a browser.
The states worth an assertion each#
Four states break in production. All four are reachable from a recorded fixture, and none of them needs a live provider.
| State | What the fixture returns | What you assert (observable, not internal) |
|---|---|---|
| Access token expired | 401 with WWW-Authenticate: Bearer error="invalid_token", then 200 on the retry | Exactly one token request left your process, exactly one retry of the original call, the caller got data, connection state unchanged |
| Scope missing | 403 with error="insufficient_scope" | Zero token requests. No retry. The connection is marked scope-incomplete, not expired |
| Refresh token rotated | Token response whose refresh_token differs from the one you sent | The new value is in the store before anything else runs, and the next outbound refresh carries it |
| Grant gone | 400 invalid_grant on refresh | Connection flips to revoked-upstream, the sync scheduler stops calling, the user is told |
Row two is the cheapest test on the list and the one teams skip: a 403 must produce zero token requests. Conflating it with a 401 gives you an infinite refresh loop against a scope problem, burning a user's quota on a condition no refresh can fix. Why a refresh cannot fix a scope error, and how to tell the two apart on a live integration, is on the 401 troubleshooting page; the assertion above is the regression that stops it coming back.
invalid_grant is the one to be humble about. RFC 6749 §5.2 defines it as covering a grant or refresh token that "is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client." Six causes, one code. Your test cannot assert that you correctly identified why the grant died, because the provider did not tell you. It can only assert what your state machine did next. Assert the state transition, never the diagnosis.
Put the fake at the HTTP boundary, not at your client class#
If you stub your own FitbitClient.refresh() method, your test proves that a method you wrote calls another method you wrote. The bugs on this page all live below that line: a refresh_token field you never read out of the body, a header you built wrong, a retry that fires twice because two layers both own it. Intercept HTTP.
The tooling is mature and domain-agnostic, so pick from the list and move on: VCR and WebMock in Ruby, vcrpy, responses and RESPX in Python, MSW in JavaScript and TypeScript, WireMock on the JVM or as a standalone container. All of them can hold a request/response pair and replay it; WireMock and MSW can also serve a scripted sequence, which is what the rotation test below needs. That is the entire generic-tooling content of this page.
The health-specific part is what goes into the recording.
Recording fixtures without recording a person#
A cassette from a live fitness provider contains two classes of secret and most teams scrub only one.
The first is credentials: client_secret, access_token, refresh_token, Authorization headers, and the authorization code if you recorded the initial exchange. VCR documents filtering of sensitive data out of cassettes and every comparable tool has an equivalent hook. Set the filter list before your first recording run, not after, because a scrubber added later does not clean the file already in git history.
The second is the response body, and it is the one that gets missed. The payload you just committed to your repository is a real person's resting heart rate, their sleep stages, their workout GPS track, and the provider's stable identifier for them. It is health data about an identifiable human sitting in a test fixture, replicated to every developer laptop and every CI runner, outside whatever deletion pipeline you built. When that person asks to be erased, your deletion assertions now have to reach a git repository, which they will not.
Our recommendation, and this is judgement rather than anything a spec says: record against a throwaway account you own, and post-process every cassette through a body rewriter that replaces the provider's user identifier with a constant, shifts all timestamps onto a fixed synthetic date, and overwrites numeric values with generated ones. Keep the response shape — field names, nesting, null handling, string-versus-number typing, the timezone representation — because that is the only part of the recording your parser actually exercises. The values are not evidence of anything, and a deliberately constructed edge case is a better use of a fixture than a real person's Tuesday.
The rotation test that fails when you break it#
RFC 6749 §6 is deliberately permissive: "The authorization server MAY issue a new refresh token, in which case the client MUST discard the old refresh token and replace it with the new refresh token. The authorization server MAY revoke the old refresh token after issuing a new refresh token to the client." Two MAYs. A client has to tolerate both rotating and non-rotating servers, which means the test matrix has two rows, not one.
The modern position is stronger. The IETF OAuth working group's Security Best Current Practice — quoted here from the group's editing repository rather than a published RFC, so treat the section numbering as unstable — states that refresh tokens for public clients "MUST be sender-constrained or use refresh token rotation," and describes what rotation buys:
Refresh token rotation: the authorization server issues a new refresh token with every access token refresh response. The previous refresh token is invalidated but information about the relationship is retained by the authorization server. If a refresh token is compromised and subsequently used by both the attacker and the legitimate client, one of them will present an invalidated refresh token, which will inform the authorization server of the breach. The authorization server cannot determine which party submitted the invalid refresh token, but it will revoke the active refresh token.
Read that last sentence as a test requirement. Under rotation, a refresh is destructive and non-idempotent, and presenting a stale refresh token does not merely fail — it can take down the good one. A benign race inside your own worker pool is, from the provider's side, indistinguishable from a stolen token, and the penalty is a full re-authorization that the user has to perform by hand. Which providers rotate is named on the refresh-token failure page; re-verify it against the provider's own docs before you rely on it, because we could not confirm a single provider's current rotation or lifetime behaviour from primary sources while writing this.
Three assertions, and the ordering of them is the point:
test("a rotated refresh token is persisted before the next call", async () => {
// Scripted sequence: first refresh rotates, second refresh REJECTS the old value.
provider.onPost("/oauth/token").respondInOrder([
{ status: 200, body: { access_token: "at-2", refresh_token: "rt-2", expires_in: 3600 } },
{ status: 400, body: { error: "invalid_grant" } }, // replaying rt-1 lands here
]);
await store.put(userId, { refreshToken: "rt-1", accessExpiresAt: PAST });
await sync.run(userId);
// 1. Assert the STORE, not the response object. The response is the fixture you wrote.
expect((await store.get(userId)).refreshToken).toBe("rt-2");
// 2. Assert the wire. This is what a provider would actually have seen.
expect(provider.requests("/oauth/token")).toHaveLength(1);
expect(provider.requests("/oauth/token")[0].form.refresh_token).toBe("rt-1");
// 3. Assert the next run reuses the rotated value and never trips the 400.
await store.expireAccessToken(userId);
await sync.run(userId);
expect(provider.requests("/oauth/token")[1].form.refresh_token).toBe("rt-2");
expect(await connectionState(userId)).toBe("active");
});
Assertion 1 is the one people write; on its own it is weak, because a client that holds the rotated token in memory and never commits it passes. Assertion 3 is the one that catches the real bug, because it forces a second round trip through persistence. Make the fake reject the stale token rather than silently accepting it — a fake that accepts anything cannot fail, and a test that cannot fail is worse than no test, because it reads as coverage.
Then the race, which is a separate test and needs a real concurrency primitive rather than two sequential awaits:
test("two workers refreshing one user produce exactly one token request", async () => {
provider.onPost("/oauth/token").respondWithDelay(150, {
status: 200, body: { access_token: "at-2", refresh_token: "rt-2", expires_in: 3600 },
});
await store.put(userId, { refreshToken: "rt-1", accessExpiresAt: PAST });
await Promise.all([sync.run(userId), sync.run(userId)]);
expect(provider.requests("/oauth/token")).toHaveLength(1); // single-flight held
expect((await store.get(userId)).refreshToken).toBe("rt-2");
});
The delay matters. Without it the first call completes before the second starts and the test passes on a codebase with no lock at all.
One more source of accidental replays that is easy to miss: your HTTP client's own retry policy. Terra's official Python SDK documents automatic retries with exponential backoff on 408, 429 and 5XX responses, with a default of two attempts and a 60-second timeout (read from the SDK README on 2026-07-30). Pointed at a rotating token endpoint, a transport-level retry of a refresh that actually succeeded server-side is a replay. Test retry policy and rotation together, in one test, with the fixture returning 502 on the first attempt and 200 on the second — then assert on the store. Fault injection more broadly belongs on the rate limits and outages page.
Revocation you discover on Thursday#
The user opened the provider's own app on Tuesday and disconnected you there. Nothing was delivered to you. There is no webhook for "the grant you hold is now worthless" that you can rely on across providers, so the state is discovered lazily, by a 401 on a routine call and then an invalid_grant on the refresh you attempt in response.
The test is a two-step fixture and the assertions are about your state machine, not about the provider:
test("upstream revocation is discovered on the next call and stops the sync", async () => {
provider.onGet("/v1/activities").respond(401, {
headers: { "WWW-Authenticate": 'Bearer error="invalid_token"' },
});
provider.onPost("/oauth/token").respond(400, { error: "invalid_grant" });
await sync.run(userId);
expect(await connectionState(userId)).toBe("revoked_upstream");
expect(provider.requests("/oauth/token")).toHaveLength(1); // refreshed once, not in a loop
expect(await scheduler.dueTasks(userId)).toEqual([]); // stopped calling
expect(await outbox.for(userId)).toContainEqual({ kind: "reconnect_required" });
expect(await samples.count(userId)).toBeGreaterThan(0); // history retained, not deleted
});
Four separate things are being pinned there and each corresponds to a distinct production incident: the refresh loop that hammers a dead grant, the scheduler that keeps burning quota, the user who is never told, and the cleanup routine that deletes a disconnected user's history when it should only have stopped adding to it. The state vocabulary — active, expired, revoked_upstream, disconnected_by_user — and the reason those last two must not be collapsed is set out in the account-linking design; this test is what proves your code implements it.
The nineteen days are the bug, not the dead grant#
Everything above tests the token. The failure this page opened with was never really about the token — it was about what the pipeline wrote for nineteen days while nobody was watching, and what it fails to rewrite when the grant comes back. Two assertions, neither of which mentions OAuth.
A day with no samples because the grant was dead is not a day with zero steps. Script it against the clock you already injected: revoke the grant, run the nightly rollup nineteen simulated times, and assert that those nineteen days carry a no-data state rather than a zero. This is the assertion that catches the whole class, because a rollup that cannot tell "the user rested" from "we were locked out" feeds zeros into every average, streak and readiness score you compute, and attaches no error to any of them. A person on holiday and a person you cannot reach look identical in the database and completely different to the person. Missing data and gaps is the design; this is the test that proves your rollup implements it rather than merely intending to.
Reconnection must repair the hole, not ratify it. After a re-authorization the connection is a new grant, and the naive resume starts from now. Assert three things: the incremental sync resumes from the watermark that predates the outage rather than from the reconnect timestamp; whatever floor you place on how far back a fresh grant may read is applied; and the nineteen no-data days are recomputed once the real samples land, rather than being left as they were. The third is the one that gets missed, and it is the one that decides whether the user's chart is right or permanently wrong.
The revocation assertion that cannot fail#
When the user presses Disconnect in your app, you call the provider's revocation endpoint. RFC 7009 requires that endpoint to answer 200 even when the token you sent is invalid — so expect(revokeResponse.status).toBe(200) is an assertion that you sent a syntactically valid POST. It passes against a live provider, against a dead token, against a token belonging to somebody else, and against a fake that returns 200 to everything. It cannot fail. Delete it. The RFC's reasoning, and the propagation window it acknowledges, are worked through on testing that a user's data is really gone; what belongs here is the token-lifecycle half.
What you assert instead is the consequence, and there are four observable ones:
- The stored token is gone. Read the row back and assert the token columns are null or the row is absent — and assert it after your transaction commits, not inside it.
- The next call behaves like an unauthenticated one. Point the fixture at a
401and assert your code does not attempt a refresh, because there is nothing left to refresh with. - The connection state moved to disconnected-by-user, which is not the same as revoked-upstream and drives different user-facing copy.
- The scheduler and the webhook subscription are both torn down. A revoked token with a live subscription still delivers events at you.
Three more assertions come straight out of the RFC's own hedging. Cascade is not guaranteed — revoking a refresh token means the server SHOULD also invalidate access tokens from the same grant, and revoking an access token means it MAY revoke the refresh token — so a test that assumes revoking one kills the other is testing your fake's generosity. Revoke both explicitly and assert both are gone locally. After a successful revoke, assert that zero further requests carrying that token leave your process, regardless of what a still-warm connection cache thinks. And assert that a 503 from the revocation endpoint leaves the disconnect task incomplete and re-queued, rather than marking the user disconnected on the strength of a call that explicitly did not happen.
Clock skew and the expiry boundary#
expires_in is a duration relative to the moment the provider issued the response, not the moment your code parsed it, and the difference is a token you believe is good for slightly longer than it is. Make the clock a dependency and the boundary cases become ordinary unit tests, none of which takes an hour:
- Token expires in one second. Assert a proactive refresh happens before the call, not a
401and a repair. - Token expired one second ago, but a cached copy elsewhere in the process still says valid. Assert the
401path recovers and that exactly one refresh runs across both code paths. - Local clock is five minutes fast. Every token looks expired on arrival. Assert you do not refresh in a loop on every single request — a bug that only shows up on one badly-synced host in a fleet and looks like a provider problem.
- Local clock is five minutes slow. Tokens look valid past their real death. Assert the
401-then-refresh path is still wired, because in this case it is the only thing that saves you.
Our rule, and it is judgement: subtract a safety margin from expires_in on the way into the store, and keep the reactive 401 handler even after you have a proactive refresher. The proactive path is an optimisation; the reactive path is the correctness guarantee, and a suite that covers only the first passes on a machine with a wrong clock and fails in production on one.
What fixtures will not give you#
Stated plainly, because pretending otherwise is how this cluster goes wrong.
A recorded fixture encodes what one provider did on the day you recorded it. It will not notice that the provider started rotating refresh tokens last month, changed a token lifetime, tightened a scope, or began returning invalid_request where it used to return invalid_grant. Fixtures catch your regressions. They cannot catch the provider's changes, and no amount of test-suite design will make them.
What replaces that coverage is not CI. It is a small scheduled job — daily is plenty — that runs one real refresh against one real staging account per provider and alerts on anything unexpected: a refresh token that came back different when you expected it not to, an expires_in that changed, a new field, an error code you have no branch for. Run it outside the build so a provider outage does not turn your pipeline red, and page nobody at 3am for it. The point is a diff, not a gate. What each provider actually offers as a test environment is its own question, and the honest answer for most of them is less than you would hope.
Also outside the reach of this suite: the consent screen itself, the exact scope strings a provider grants versus what it displays, and anything that requires a human to press Allow. Those get a manual pass when you change scopes, and nothing else. Redirect-URI registration is a deploy-time configuration problem, not a unit test.
Finally, the honest limit on the specifics above: we verified the RFC 6749, 6750 and 7009 semantics on this page against the specification text, and we verified none of the per-provider behaviour, because every fitness provider's documentation host was unreachable from our research environment on 2026-07-30. Token lifetimes, rotation behaviour, revocation endpoint URLs and scope names must be read from the provider's own current documentation. If you are still assembling the mental model underneath all this, the OAuth primer for health data is the place to start; if a specific integration is broken right now rather than under test, the /fix pages linked above are faster than this one.
Frequently asked questions
- Should my CI pipeline ever call a real provider's token endpoint?
- No, and the reason is stronger than flakiness. Under refresh token rotation a refresh is destructive and non-idempotent, so a CI run that refreshes a shared staging account invalidates the refresh token every other run and every developer laptop is holding. Add parallel jobs and you get a replay, which the OAuth Security Best Current Practice describes as triggering the authorization server's breach detection and revoking the active token. Your build then fails for a reason that has nothing to do with the commit. Run the suite on fixtures, and keep the one real refresh in a scheduled job outside the build.
- How do I keep recorded OAuth fixtures from leaking a real user's health data?
- Scrub two classes of thing, not one. Credentials are the obvious class: client secret, access and refresh tokens, Authorization headers, and the authorization code if you recorded the initial exchange. The class teams miss is the response body, which for a fitness provider is somebody's resting heart rate, sleep stages, GPS track and stable provider identifier, now committed to a repository outside your deletion pipeline. Configure the filter before the first recording run, because adding it later does not clean the file already in git history, and post-process bodies to replace identifiers, shift timestamps to a fixed synthetic date, and overwrite values while keeping the field shape. The shape is the only part your parser exercises.
- Why would two workers refreshing one user cost that person a full re-authorization?
- Because under rotation a refresh is destructive and non-idempotent. Both workers notice the same expired access token and both post the same refresh token; the second one arrives with a value the authorization server has already invalidated, and from the server's side a benign race inside your worker pool is indistinguishable from a stolen token being replayed. The documented response to that is to revoke the active refresh token, which means your concurrency bug ends as a real person being asked to reconnect their wearable through a browser. Test it with an actual concurrency primitive rather than two sequential awaits, and put a delay on the fake's token response — without the delay the first call finishes before the second starts and the test passes on a codebase with no lock in it at all. Then assert that exactly one token request reached the wire.
- How do I test refresh token rotation when my fake always hands back the same token?
- Make the fake hostile. Script the token endpoint as an ordered sequence in which the first call returns a different refresh token and the second call rejects the original with 400 invalid_grant. A fake that accepts any refresh token forever cannot fail, so a client that reads the rotated value into memory and never commits it will pass. Then assert three things in order: the new value is in the store, the first outbound request carried the old value, and a second sync run sends the new value and does not trip the 400. It is that third assertion, which forces another round trip through persistence, that catches the real bug.
- What should the nightly rollup write for a day when the provider grant was dead?
- A no-data state, never a zero, and this is the assertion most token-lifecycle suites never write. A dead grant in a checkout flow shows somebody an error; a dead grant in a health product produces a plausible chart, because a hole in a time series is indistinguishable from a fortnight of rest days. Script it against the clock you already injected: kill the grant, run the rollup for however many simulated days, and assert those days are marked unknown rather than zero, because zeros feed straight into averages, streaks and readiness scores with no error attached. Then assert the recovery leg: after re-authorization the incremental sync resumes from the watermark that predates the outage, any floor on how far back a fresh grant may read is applied, and the affected days are recomputed once real samples arrive. Skip that last one and the reconnect quietly ratifies the hole.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Independent comparison, last reviewed July 27, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All testing · by AIFitnessAPI