Repository navigation
fix(oauth): keep refresh tokens alive when Kody refreshes concurrently - #123
Conversation
Concurrent MCP clients present the same rotating refresh token when an access token expires. Treating that overlap as theft revoked the family and forced a manual login. Reuse within 60 seconds now returns the live refresh token; later reuse still revokes the family. Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
|
Navigate logical layers of code changes, visualize relationships, and explore their blast radius. No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (7)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughRefresh-token redemption now records successor tokens and permits reuse within a 60-second grace window. Reuse after that window revokes the token family. The OAuth token route uses the redemption result, and the migration and OAuth tests cover the updated behavior. ChangesRefresh token redemption
Priority: ➖ Normal Estimated code review effort: 4 (Complex) | ~45 minutes Change: Bug fix Sequence Diagram(s)sequenceDiagram
participant Client
participant OAuthTokenRoute
participant redeemRefreshToken
participant RefreshTokenStore
Client->>OAuthTokenRoute: Submit refresh-token grant
OAuthTokenRoute->>redeemRefreshToken: Pass token, clientId, and scope
redeemRefreshToken->>RefreshTokenStore: Claim token and find live family token
redeemRefreshToken->>RefreshTokenStore: Create successor and link replaced_by
redeemRefreshToken-->>OAuthTokenRoute: Return rotated, reused, or rejected result
OAuthTokenRoute-->>Client: Return token response or error
Merge Risk: ⚪ Minimal · up to Overlapping refresh requests from the same OAuth client now receive the same rotated refresh token instead of revoking the session. Replay after 60 seconds still revokes the token family. The schema change is additive and reversible, and the new tests cover the intended timing behavior. No concrete merge-blocking issue was found. 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
Full details: Docstring CoverageExplanation Docstring coverage is 27.78% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 18 functions across 5 files. (2 skipped: 2 unsupported.)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Comment |
The live authorize request includes scope mcp:read mcp:write and resource=https://mediarss.doddsfamily.us/mcp. That parameter is ignored by the token endpoint and must not be what drops the refresh token. Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com>
|
Squash-merged and the Docker image is published ( One-time re-auth, after the host is running that commit (
Live |
MediRSS was forcing a weekly MediRSS re-auth in Kody because overlapping refresh-token rotations were treated as theft and the whole token family was revoked.
Root cause
Access tokens last 1 hour. Refresh tokens rotate on every use, and any reuse revoked the family. That is correct for a stolen token replayed later. It is not correct for the client Kody actually is.
Kody's MCP OAuth client is
@modelcontextprotocol/sdk1.30.0auth()(packages/workerdepends onagents0.22.0, which uses that SDK).auth()has no single-flight lock around refresh. When the access token expires, two in-flight calls both POST the same refresh token. The loser receivesinvalid_grant. The SDK then callsinvalidateCredentials('tokens'), which deletes the stored refresh token. If the family was already revoked, the winner's replacement is dead too, so the next refresh fails the same way.The account card is written after that wipe, so storage no longer has a refresh token:
This MCP server's stored access token is no longer usable and Kody has no refresh token to renew it (phase token exchange, mcp https://mediarss.doddsfamily.us/mcp, id 7b628aeb-def2-45b6-9c48-e42585e84026).That
idis Kody's recovery attempt id, not the saved server id.formatMcpOAuthSettleDetailsappendsid ${attemptId}(packages/worker/src/mcp-client/oauth-token-recovery.tsstamps a new attempt id when it parks the connection). The saved server is293a8499-2649-43b6-bd09-ac72ba674274.mcpServerListshowshasRefreshToken: falsefor that server.Checked against the live request and ruled out as the cause of the missing refresh token:
https://kody.codes/oauth/client-metadata.jsonincludesgrant_types: ["authorization_code", "refresh_token"]. Redirect ishttps://kody.codes/account/mcp-servers/oauth/callback.refresh_token.scope=mcp:read mcp:writeandresource=https://mediarss.doddsfamily.us/mcp. The token endpoint ignores unknown form fields, includingresource. A code exchange and a refresh that both send that resource still return arefresh_token(new test).expis 3600s, and every MCP request re-checks it), not after a week of use.home(https://kody-home.doddsfamily.us/mcp) stays ready withhasRefreshToken: true. The same Kody client survives there. That server also advertisesrefresh_token, but it is a different authorization server. The failure is specific to this server's rotate-and-revoke-on-any-reuse policy. A sibling agent is checking Kody's client; this PR fixes the server behavior that turns that client's overlap into a logged-out connection.Fix
Reuse of a refresh token within 60 seconds returns the family's current live refresh token (HTTP 200, same
refresh_tokenthe first caller received) and does not revoke the family. Requests for that token are serialized per family in-process, with atomicused_at/replaced_byupdates if two processes share the database file.Reuse after 60 seconds still revokes the family, including when the presented token's own
expires_athas already passed. Revoked tokens are stamped outside the grace window so presenting one cannot mint a replacement.replaced_byis added in20260923235900_refresh_token_reuse_grace. Public clients are still required to rotate refresh tokens (MCP authorization spec); the grace window is only for the overlap.Test Plan
npm run validate(format, lint, typecheck, 271 tests)scope=mcp:read mcp:writeandresource=https://mediarss.doddsfamily.us/mcpstill return a refresh tokeninvalid_grantand revoke the descendantAfter deploy, Kent should:
/account/mcp-serversfor the saved server293a8499-2649-43b6-bd09-ac72ba674274(https://mediarss.doddsfamily.us/mcp).mcpServerListshowsmediarsswithhasRefreshToken: trueand connected.hasRefreshTokenshould stay true without another browser login.[oauth.token] refresh_reused_within_gracein the logs means an overlap was absorbed.refresh_replay_revokedstill means a real replay outside the window.Checklist
Screenshots
Summary by CodeRabbit