feat: add token refresh and rotation conformance scenarios#139
feat: add token refresh and rotation conformance scenarios#139jdmaturen wants to merge 2 commits intomodelcontextprotocol:mainfrom
Conversation
The SDK's `requireBearerAuth` middleware only converts `InvalidTokenError` instances to HTTP 401 responses. Generic `Error` instances fall through as HTTP 500, which prevents clients from detecting authentication failures and initiating the OAuth refresh/re-auth flow. This was discovered while building token refresh conformance scenarios — the mock server was returning 500 for expired/invalid tokens instead of the expected 401. Co-authored-by: Cursor <cursoragent@cursor.com>
Add two new client auth conformance scenarios that test OAuth 2.1 refresh token behavior: - `auth/token-refresh-basic`: Tests that clients use the refresh_token grant to obtain a new access token when the current one expires (OAuth 2.1 §6). Server issues 2-second TTL access tokens + refresh token; client must detect 401, send grant_type=refresh_token, and use the new access token. - `auth/token-refresh-rotation`: Same flow but the server rotates the refresh token on each use (OAuth 2.1 §6.1). Client must store the new refresh token and not reuse the old one. Supporting changes: - `createAuthServer`: Add `issueRefreshToken`, `rotateRefreshTokens`, `accessTokenExpiresIn`, and `onRefreshTokenRequest` options. Add full `grant_type=refresh_token` handler with token validation, rotation, and conformance checks. - `createServer`: Add `perRequestServer` option to create a fresh MCP Server per request. Required for token refresh tests where requests span token expiry boundaries (the default behavior calls server.close() on response end, breaking subsequent requests). - `mockTokenVerifier`: Add token expiration tracking (issuedAt, expiresIn per token). Expired tokens now throw InvalidTokenError with an INFO conformance check, enabling proper 401 responses. - `spec-references`: Add OAUTH_2_1_REFRESH_TOKEN (§6) and OAUTH_2_1_TOKEN_ROTATION (§6.1) references. - `everything-client`: Add `runTokenRefreshClient` that exercises the full refresh flow (connect → request → wait for expiry → request). Depends on modelcontextprotocol#138 (InvalidTokenError fix). Co-authored-by: Cursor <cursoragent@cursor.com>
|
Addressed a few issues on this PR: Rotation test didn't actually test rotation. The client only did one refresh cycle, so a client that ignores the rotated refresh token would still pass. The server revokes the old token on rotation, but since there was only one refresh, the client never needed to prove it stored the new one. Fixed by creating a separate Dead variable. Missing idempotency guards in Wrong spec section in comments. Comments referenced Also ran prettier on all changed files. They had formatting drift from the repo's config. |
commit: |
pcarleton
left a comment
There was a problem hiding this comment.
This generally looks great, thanks for adding this.
My one big note is let's just do the rotation example. I don't see a ton of value of a client that passes the first one but not the second one. Progress on making the client better can be gained by passing more checks out of the single scenario (e.g. pass the first set of checks, fail the 2nd one when they re-use the old token).
Reasons for fewer tests:
- less code to maintain & update
- these suites will often run in CI, in some cases on every push for every sdk. each test often involves spinning up its own http server which takes time. So in general we want to keep it fast.
On the 2nd bullet, we may add a "slow" test tag or something in the future to indicate that we shouldn't run it on CI, and only run occasionally or when doing certification.
|
That's great feedback. I'll update it. May be early next week behind a few other things. |
Summary
Adds two new client auth conformance scenarios that test OAuth 2.1 refresh token behavior:
auth/token-refresh-basic— Tests that clients use therefresh_tokengrant to obtain a new access token when the current one expires (OAuth 2.1 §6). Server issues 2-second TTL access tokens + refresh token; client must detect 401, sendgrant_type=refresh_token, and use the new access token successfully.auth/token-refresh-rotation— Same flow but the server rotates the refresh token on each use (OAuth 2.1 §6.1). Client must store the new refresh token and not reuse the old one.Motivation
Multiple MCP client implementations have been reported as not handling token refresh reliably — leading to auth loops, death spirals, and excessive retry RPM when access tokens expire. These scenarios provide a concrete conformance check against the OAuth 2.1 refresh token spec.
Changes
token-refresh.tsTokenRefreshBasicScenarioandTokenRefreshRotationScenariocreateAuthServer.tsissueRefreshToken,rotateRefreshTokens,accessTokenExpiresIn,onRefreshTokenRequestoptions; fullgrant_type=refresh_tokenhandlercreateServer.tsperRequestServeroption (fresh MCP Server per request, needed becauseserver.close()on response end breaks subsequent requests across token boundaries)mockTokenVerifier.tsissuedAt,expiresInper token); expired tokens throwInvalidTokenErrorwith INFO conformance checkspec-references.tsOAUTH_2_1_REFRESH_TOKEN(§6) andOAUTH_2_1_TOKEN_ROTATION(§6.1)index.tseverything-client.tsrunTokenRefreshClientexercising the full refresh flowTest plan
auth/token-refresh-basicpasses in ~3s (2s token TTL + 1s buffer)auth/token-refresh-rotationpasses in ~3stsc --noEmitcleanDependencies
Depends on #138 (
InvalidTokenErrorfix) — without it, expired tokens return 500 instead of 401 and the refresh flow never triggers.Made with Cursor