Skip to content
This repository was archived by the owner on Jul 8, 2026. It is now read-only.

SC-308993: tolerate stale rotated refresh tokens - #163

Merged
mdthorpe-sc merged 1 commit into
mainfrom
kurt/sc-308993/oauth-refresh-can-fail-with-invalid-grant-when
Mar 3, 2026
Merged

mdthorpe-sc merged 1 commit into
mainfrom
kurt/sc-308993/oauth-refresh-can-fail-with-invalid-grant-when

Conversation

@kschrader

@kschrader kschrader commented Mar 2, 2026

Copy link
Copy Markdown
Member

Summary

  • map previously-issued refresh tokens to the latest known token in OAuth provider cache
  • use aliased refresh token during refresh exchange to avoid invalid_grant on stale client token
  • add regression test for refresh-token rotation fallback

Testing

  • bun test src/auth/provider.test.ts
  • full test suite via push hook

Summary by CodeRabbit

  • Tests

    • Added test coverage for server-side refresh token rotation scenarios.
  • Bug Fixes

    • Enhanced token refresh handling to properly manage server-side token rotation.
    • Improved token cache management to maintain correct token mappings during refresh cycles.

@coderabbitai

coderabbitai Bot commented Mar 2, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

The pull request introduces server-side refresh token rotation support to the authentication provider. A new test validates the rotation flow where an expired refresh token triggers a server error and subsequent exchanges use newly returned refresh tokens. The implementation adds a refresh token cache map and updates token exchange logic to track and resolve token aliases across multiple refresh cycles.

Changes

Cohort / File(s) Summary
Refresh Token Rotation Test
src/auth/provider.test.ts
New test case validating the provider's handling of server-side refresh token rotation, including error propagation when a refresh token has already been used and correct state updates when new tokens are exchanged.
Refresh Token Cache Implementation
src/auth/provider.ts
Adds issuedRefreshTokens map to track token mappings; modifies cacheIssuedToken to return TokenCacheEntry and manage previous refresh tokens; updates refreshExpiredToken, exchangeAuthorizationCode, exchangeRefreshToken, and verifyAccessToken to maintain dual cache consistency and resolve aliased refresh tokens.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • mdthorpe-sc
  • opoku

Poem

🐰 A hop and a skip through tokens we go,
Old refresh tokens fade as new ones flow,
Caches and mappings, all in their place,
Server-side rotation sets a steady pace!
*wiggles nose*

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: handling stale rotated refresh tokens through aliasing/tolerance mechanisms in the OAuth provider cache.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch kurt/sc-308993/oauth-refresh-can-fail-with-invalid-grant-when

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/auth/provider.ts (1)

511-537: ⚠️ Potential issue | 🟠 Major

Refresh alias chain can get stuck one generation behind.

When refreshToken is aliased, the request uses refreshTokenToUse, but cache update uses the original refreshToken. That leaves refreshTokenToUse potentially pointing at an older entry after the next rotation, causing avoidable invalid_grant on later calls.

🔧 Proposed fix
-			const tokens = normalizeTokensForClient((await response.json()) as OAuthTokens);
-			cacheIssuedToken(tokens, client.client_id, refreshToken);
+			const tokens = normalizeTokensForClient((await response.json()) as OAuthTokens);
+			const cachedEntry = cacheIssuedToken(tokens, client.client_id, refreshTokenToUse);
+			if (refreshTokenToUse !== refreshToken) {
+				issuedRefreshTokens.set(refreshToken, cachedEntry);
+			}
 			return tokens;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/auth/provider.ts` around lines 511 - 537, The refresh alias caching is
using the original refreshToken when calling cacheIssuedToken, which leaves the
alias chain one generation behind; change the cache update to use the actual
token sent to the upstream (refreshTokenToUse) or the new returned refresh token
as the cache key so the alias map (issuedRefreshTokens) is updated for the token
that was used (refer to issuedRefreshTokens, refreshTokenToUse, and
cacheIssuedToken); ensure you pass refreshTokenToUse (or the new
tokens.refresh_token if you intend to key by the returned token) into
cacheIssuedToken so subsequent refreshes follow the correct alias chain.
🧹 Nitpick comments (1)
src/auth/provider.test.ts (1)

214-296: Extend this regression to cover intermediate-token reuse.

Current assertions validate one stale-token hop, but not that new-refresh-token is remapped after the second rotation. Adding that third-step assertion would catch alias-chain regressions.

✅ Suggested test extension
 	test("accepts a previously issued refresh token after server-side rotation", async () => {
 		let oldRefreshTokenCalls = 0;
+		let newRefreshTokenCalls = 0;
 		const fetchMock = mock(async (_url: string | URL, init?: RequestInit) => {
 			const body = typeof init?.body === "string" ? init.body : "";
 			const params = new URLSearchParams(body);
 			const refreshToken = params.get("refresh_token");
@@
 			if (refreshToken === "new-refresh-token") {
-				return new Response(
-					JSON.stringify({
-						access_token: "access-token-3",
-						refresh_token: "newer-refresh-token",
-						scope: "openid",
-					}),
-					{
-						status: 200,
-						headers: { "Content-Type": "application/json" },
-					},
-				);
+				newRefreshTokenCalls += 1;
+				if (newRefreshTokenCalls === 1) {
+					return new Response(
+						JSON.stringify({
+							access_token: "access-token-3",
+							refresh_token: "newer-refresh-token",
+							scope: "openid",
+						}),
+						{
+							status: 200,
+							headers: { "Content-Type": "application/json" },
+						},
+					);
+				}
+				return new Response(
+					JSON.stringify({
+						error: "invalid_grant",
+						error_description: "Refresh token already used",
+					}),
+					{
+						status: 400,
+						headers: { "Content-Type": "application/json" },
+					},
+				);
+			}
+
+			if (refreshToken === "newer-refresh-token") {
+				return new Response(
+					JSON.stringify({
+						access_token: "access-token-4",
+						refresh_token: "latest-refresh-token",
+						scope: "openid",
+					}),
+					{
+						status: 200,
+						headers: { "Content-Type": "application/json" },
+					},
+				);
 			}
@@
 		expect(secondTokens.access_token).toBe("access-token-3");
 		expect(secondTokens.refresh_token).toBe("newer-refresh-token");
-		expect(fetchMock).toHaveBeenCalledTimes(2);
+
+		const thirdTokens = await provider.exchangeRefreshToken(
+			{
+				client_id: "test-client-id",
+				redirect_uris: [],
+			} as never,
+			"new-refresh-token",
+		);
+
+		expect(thirdTokens.access_token).toBe("access-token-4");
+		expect(thirdTokens.refresh_token).toBe("latest-refresh-token");
+		expect(fetchMock).toHaveBeenCalledTimes(3);
 	});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/auth/provider.test.ts` around lines 214 - 296, Add a third exchange to
assert intermediate-token reuse: after the existing secondTokens call, call
provider.exchangeRefreshToken(...) with "new-refresh-token" (store as
thirdTokens) and assert thirdTokens.access_token === "access-token-3" and
thirdTokens.refresh_token === "newer-refresh-token"; also update the fetchMock
call count expectation to account for the extra request. This ensures
provider.exchangeRefreshToken correctly remaps the intermediate token and that
fetchMock, firstTokens, secondTokens, and thirdTokens are checked accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/auth/provider.ts`:
- Around line 511-537: The refresh alias caching is using the original
refreshToken when calling cacheIssuedToken, which leaves the alias chain one
generation behind; change the cache update to use the actual token sent to the
upstream (refreshTokenToUse) or the new returned refresh token as the cache key
so the alias map (issuedRefreshTokens) is updated for the token that was used
(refer to issuedRefreshTokens, refreshTokenToUse, and cacheIssuedToken); ensure
you pass refreshTokenToUse (or the new tokens.refresh_token if you intend to key
by the returned token) into cacheIssuedToken so subsequent refreshes follow the
correct alias chain.

---

Nitpick comments:
In `@src/auth/provider.test.ts`:
- Around line 214-296: Add a third exchange to assert intermediate-token reuse:
after the existing secondTokens call, call provider.exchangeRefreshToken(...)
with "new-refresh-token" (store as thirdTokens) and assert
thirdTokens.access_token === "access-token-3" and thirdTokens.refresh_token ===
"newer-refresh-token"; also update the fetchMock call count expectation to
account for the extra request. This ensures provider.exchangeRefreshToken
correctly remaps the intermediate token and that fetchMock, firstTokens,
secondTokens, and thirdTokens are checked accordingly.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 81d51a7 and a153086.

📒 Files selected for processing (2)
  • src/auth/provider.test.ts
  • src/auth/provider.ts

@kschrader
kschrader requested a review from mdthorpe-sc March 2, 2026 22:33
@mdthorpe-sc
mdthorpe-sc merged commit def5cb1 into main Mar 3, 2026
2 checks passed
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants